6070 lines
236 KiB
Python
6070 lines
236 KiB
Python
from flask import Flask, request, redirect, session, Response, jsonify
|
|
import json, os, random, time, uuid, re, html, base64, hashlib, hmac, struct
|
|
from datetime import datetime
|
|
from functools import wraps
|
|
from urllib.parse import quote
|
|
|
|
app = Flask(__name__)
|
|
app.secret_key = "bank_secret_key_2025"
|
|
app.config['SESSION_PERMANENT'] = True
|
|
app.config['PERMANENT_SESSION_LIFETIME'] = 3600
|
|
|
|
USERS_FILE = "users.json"
|
|
HISTORY_FILE = "history.json"
|
|
SHOP_FILE = "shop.json"
|
|
PAYMENTS_FILE = "payments.json" # Новый файл для платежей
|
|
JOURNAL_FILE = "journal.json"
|
|
CLASSES_FILE = "classes.json"
|
|
SUBJECTS_FILE = "subjects.json"
|
|
PARENT_LINKS_FILE = "parent_links.json"
|
|
CRYPTO_FILE = "crypto.json"
|
|
|
|
SERKRIPTO_NAME = "SerKripto"
|
|
SERKRIPTO_SYMBOL = "SERK"
|
|
|
|
ROLE_ADMIN = "admin"
|
|
ROLE_MANAGER = "manager"
|
|
ROLE_TEACHER = "teacher"
|
|
ROLE_PARENT = "parent"
|
|
ROLE_STUDENT = "student"
|
|
ROLE_CLIENT = "client"
|
|
|
|
VALID_ROLES = {
|
|
ROLE_ADMIN,
|
|
ROLE_MANAGER,
|
|
ROLE_TEACHER,
|
|
ROLE_PARENT,
|
|
ROLE_STUDENT,
|
|
ROLE_CLIENT
|
|
}
|
|
|
|
FINANCE_ROLES = (ROLE_ADMIN, ROLE_MANAGER, ROLE_TEACHER, ROLE_PARENT, ROLE_STUDENT, ROLE_CLIENT)
|
|
CREDIT_ROLES = (ROLE_ADMIN, ROLE_MANAGER, ROLE_TEACHER, ROLE_PARENT, ROLE_CLIENT)
|
|
CRYPTO_ROLES = (ROLE_ADMIN, ROLE_MANAGER, ROLE_TEACHER, ROLE_PARENT, ROLE_CLIENT)
|
|
SHOP_SELL_ROLES = (ROLE_ADMIN, ROLE_MANAGER, ROLE_TEACHER, ROLE_PARENT, ROLE_CLIENT)
|
|
JOURNAL_ROLES = (ROLE_ADMIN, ROLE_TEACHER)
|
|
|
|
TOTP_DIGITS = 6
|
|
TOTP_PERIOD_SEC = 30
|
|
TOTP_DRIFT_WINDOWS = 1
|
|
PENDING_2FA_TTL_SEC = 300
|
|
|
|
def normalize_role(role_value):
|
|
role = str(role_value or ROLE_CLIENT).strip().lower()
|
|
if role not in VALID_ROLES:
|
|
return ROLE_CLIENT
|
|
return role
|
|
|
|
def parse_bool(value):
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (int, float)):
|
|
return value != 0
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
return False
|
|
|
|
def normalize_totp_secret(secret_value):
|
|
cleaned = re.sub(r"[^A-Z2-7]", "", str(secret_value or "").upper())
|
|
return cleaned
|
|
|
|
def generate_totp_secret(length=32):
|
|
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
|
return "".join(random.choice(alphabet) for _ in range(max(16, int(length))))
|
|
|
|
def generate_totp_code(secret, for_timestamp=None):
|
|
normalized_secret = normalize_totp_secret(secret)
|
|
if not normalized_secret:
|
|
return None
|
|
|
|
pad_len = (8 - (len(normalized_secret) % 8)) % 8
|
|
padded = normalized_secret + ("=" * pad_len)
|
|
|
|
try:
|
|
key = base64.b32decode(padded, casefold=True)
|
|
except Exception:
|
|
return None
|
|
|
|
timestamp = int(time.time()) if for_timestamp is None else int(for_timestamp)
|
|
counter = int(timestamp // TOTP_PERIOD_SEC)
|
|
counter_bytes = struct.pack(">Q", counter)
|
|
digest = hmac.new(key, counter_bytes, hashlib.sha1).digest()
|
|
offset = digest[-1] & 0x0F
|
|
binary = (
|
|
((digest[offset] & 0x7F) << 24)
|
|
| ((digest[offset + 1] & 0xFF) << 16)
|
|
| ((digest[offset + 2] & 0xFF) << 8)
|
|
| (digest[offset + 3] & 0xFF)
|
|
)
|
|
return str(binary % (10 ** TOTP_DIGITS)).zfill(TOTP_DIGITS)
|
|
|
|
def verify_totp_code(secret, raw_code):
|
|
code = re.sub(r"\D", "", str(raw_code or ""))
|
|
if len(code) != TOTP_DIGITS:
|
|
return False
|
|
|
|
now = int(time.time())
|
|
for offset in range(-TOTP_DRIFT_WINDOWS, TOTP_DRIFT_WINDOWS + 1):
|
|
check_time = now + (offset * TOTP_PERIOD_SEC)
|
|
expected = generate_totp_code(secret, check_time)
|
|
if expected and code == expected:
|
|
return True
|
|
return False
|
|
|
|
def get_totp_uri(username, secret):
|
|
issuer = quote("Bank School")
|
|
account = quote(str(username or "user"))
|
|
return f"otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&digits={TOTP_DIGITS}&period={TOTP_PERIOD_SEC}"
|
|
|
|
def clear_pending_2fa_session():
|
|
session.pop("pending_2fa_user", None)
|
|
session.pop("pending_2fa_started", None)
|
|
|
|
def is_pending_2fa_alive():
|
|
started = int(session.get("pending_2fa_started", 0) or 0)
|
|
if started <= 0:
|
|
return False
|
|
return (int(time.time()) - started) <= PENDING_2FA_TTL_SEC
|
|
|
|
def get_user_role(user_data):
|
|
if not isinstance(user_data, dict):
|
|
return ROLE_CLIENT
|
|
return normalize_role(user_data.get("role", ROLE_CLIENT))
|
|
|
|
def has_role(user_data, allowed_roles):
|
|
role = get_user_role(user_data)
|
|
normalized_allowed = {normalize_role(r) for r in allowed_roles}
|
|
return role in normalized_allowed
|
|
|
|
def default_admin_user():
|
|
return {
|
|
"password": "CreateSergeyPass",
|
|
"balance": 0,
|
|
"deposit": 0,
|
|
"credit": 0,
|
|
"serkripto": 0.0,
|
|
"role": "admin",
|
|
"full_name": "Администратор Системы",
|
|
"email": "kompania.bank@gmail.com",
|
|
"api_key": "Sergey_API_KEY_1",
|
|
"twofa_enabled": False,
|
|
"twofa_secret": "",
|
|
}
|
|
|
|
# ---------- ИНИЦИАЛИЗАЦИЯ ФАЙЛОВ ----------
|
|
def init_files():
|
|
"""Инициализация файлов (без удаления существующих)"""
|
|
# Создаем файлы если они не существуют
|
|
files_to_init = [
|
|
(USERS_FILE, {"Sergey": default_admin_user()}),
|
|
(HISTORY_FILE, []),
|
|
(SHOP_FILE, {"items": [], "ads": []}),
|
|
(PAYMENTS_FILE, []), # Инициализация файла платежей
|
|
(JOURNAL_FILE, {}),
|
|
(CLASSES_FILE, {}),
|
|
(SUBJECTS_FILE, []),
|
|
(PARENT_LINKS_FILE, {}),
|
|
(CRYPTO_FILE, {
|
|
"name": SERKRIPTO_NAME,
|
|
"symbol": SERKRIPTO_SYMBOL,
|
|
"price": 100.0,
|
|
"min_price": 1.0,
|
|
"max_price": 1000000.0,
|
|
"price_step_buy": 0.012,
|
|
"price_step_sell": 0.01,
|
|
"idle_decay_rate": 0.0015,
|
|
"idle_decay_interval_sec": 30,
|
|
"last_buy_at": int(time.time()),
|
|
"last_update": int(time.time()),
|
|
"stats": {
|
|
"buy_ops": 0,
|
|
"sell_ops": 0,
|
|
"buy_volume": 0.0,
|
|
"sell_volume": 0.0
|
|
}
|
|
})
|
|
]
|
|
|
|
for filename, default_data in files_to_init:
|
|
if not os.path.exists(filename):
|
|
with open(filename, "w", encoding='utf-8') as f:
|
|
json.dump(default_data, f, indent=4, ensure_ascii=False)
|
|
|
|
# Инициализируем файлы при запуске
|
|
init_files()
|
|
|
|
# Совместимые no-op хуки уведомлений
|
|
def ws_emit_system(event_name, payload=None):
|
|
return None
|
|
|
|
def ws_emit_user(username, event_name, payload=None):
|
|
return None
|
|
|
|
def ws_emit_balance_update(username):
|
|
return None
|
|
|
|
# ---------- ФУНКЦИИ ДЛЯ РАБОТЫ С ДАННЫМИ ----------
|
|
def load_users():
|
|
try:
|
|
with open(USERS_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
# Убеждаемся, что данные в правильном формате
|
|
if not isinstance(data, dict):
|
|
print("Warning: users.json is not a dictionary, resetting to default")
|
|
data = {"admin": default_admin_user()}
|
|
save_users(data)
|
|
return data
|
|
# Проверяем, что все значения являются словарями
|
|
cleaned_data = {}
|
|
for username, user_data in data.items():
|
|
if isinstance(user_data, dict):
|
|
user_data["role"] = get_user_role(user_data)
|
|
try:
|
|
user_data["serkripto"] = round(float(user_data.get("serkripto", 0.0)), 6)
|
|
except:
|
|
user_data["serkripto"] = 0.0
|
|
user_data["twofa_secret"] = normalize_totp_secret(user_data.get("twofa_secret", ""))
|
|
user_data["twofa_enabled"] = parse_bool(user_data.get("twofa_enabled", False))
|
|
if not user_data["twofa_secret"]:
|
|
user_data["twofa_enabled"] = False
|
|
cleaned_data[username] = user_data
|
|
else:
|
|
print(f"Warning: User {username} data is not a dict, resetting")
|
|
if username == "admin":
|
|
cleaned_data[username] = default_admin_user()
|
|
else:
|
|
cleaned_data[username] = {
|
|
"password": "reset123",
|
|
"balance": 1000,
|
|
"deposit": 0,
|
|
"credit": 0,
|
|
"serkripto": 0.0,
|
|
"role": "client",
|
|
"full_name": "Пользователь",
|
|
"email": "user@example.com",
|
|
"api_key": None,
|
|
"twofa_enabled": False,
|
|
"twofa_secret": "",
|
|
}
|
|
|
|
# Гарантируем наличие рабочего admin даже после ручной порчи users.json
|
|
admin_defaults = default_admin_user()
|
|
admin_user = cleaned_data.get("admin")
|
|
if not isinstance(admin_user, dict):
|
|
cleaned_data["admin"] = admin_defaults
|
|
else:
|
|
for key, value in admin_defaults.items():
|
|
admin_user.setdefault(key, value)
|
|
admin_user["role"] = ROLE_ADMIN
|
|
if not admin_user.get("password"):
|
|
admin_user["password"] = admin_defaults["password"]
|
|
|
|
if cleaned_data != data:
|
|
save_users(cleaned_data)
|
|
return cleaned_data
|
|
except Exception as e:
|
|
print(f"Error loading users: {e}")
|
|
data = {"admin": default_admin_user()}
|
|
save_users(data)
|
|
return data
|
|
|
|
def save_users(data):
|
|
with open(USERS_FILE, "w", encoding='utf-8') as f:
|
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
|
|
def load_history():
|
|
try:
|
|
with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
except:
|
|
return []
|
|
|
|
def save_history(data):
|
|
with open(HISTORY_FILE, "w", encoding='utf-8') as f:
|
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
|
|
# ---------- ЭЛЕКТРОННЫЙ ДНЕВНИК ----------
|
|
def load_journal():
|
|
try:
|
|
with open(JOURNAL_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
return data if isinstance(data, dict) else {}
|
|
except:
|
|
return {}
|
|
|
|
def save_journal(data):
|
|
with open(JOURNAL_FILE, "w", encoding='utf-8') as f:
|
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
|
|
def load_classes():
|
|
try:
|
|
with open(CLASSES_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
return data if isinstance(data, dict) else {}
|
|
except:
|
|
return {}
|
|
|
|
def save_classes(data):
|
|
with open(CLASSES_FILE, "w", encoding='utf-8') as f:
|
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
|
|
def load_subjects():
|
|
try:
|
|
with open(SUBJECTS_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
return data if isinstance(data, list) else []
|
|
except:
|
|
return []
|
|
|
|
def save_subjects(data):
|
|
with open(SUBJECTS_FILE, "w", encoding='utf-8') as f:
|
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
|
|
def load_parent_links():
|
|
try:
|
|
with open(PARENT_LINKS_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
return data if isinstance(data, dict) else {}
|
|
except:
|
|
return {}
|
|
|
|
def save_parent_links(data):
|
|
with open(PARENT_LINKS_FILE, "w", encoding='utf-8') as f:
|
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
|
|
def get_parent_children(parent_username):
|
|
links = load_parent_links()
|
|
children = links.get(parent_username, [])
|
|
if not isinstance(children, list):
|
|
return []
|
|
unique_children = []
|
|
for child in children:
|
|
child_name = str(child).strip()
|
|
if child_name and child_name not in unique_children:
|
|
unique_children.append(child_name)
|
|
return unique_children
|
|
|
|
def calculate_money_for_grade(grade):
|
|
grade = int(grade)
|
|
mapping = {
|
|
5: 20,
|
|
4: 10,
|
|
3: -10,
|
|
2: -20
|
|
}
|
|
return mapping.get(grade, 0)
|
|
|
|
# ---------- ФУНКЦИИ ДЛЯ ПЛАТЕЖЕЙ ----------
|
|
def load_payments():
|
|
"""Загрузка платежей"""
|
|
try:
|
|
with open(PAYMENTS_FILE, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
except:
|
|
return []
|
|
|
|
def save_payments(data):
|
|
"""Сохранение платежей"""
|
|
with open(PAYMENTS_FILE, "w", encoding='utf-8') as f:
|
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
|
|
def get_payment_by_id(payment_id):
|
|
payments = load_payments()
|
|
for payment in payments:
|
|
if payment.get("id") == payment_id:
|
|
return payment
|
|
return None
|
|
|
|
def sanitize_payment_output(payment):
|
|
if not isinstance(payment, dict):
|
|
return payment
|
|
safe_payment = payment.copy()
|
|
balances = safe_payment.get("balances")
|
|
if isinstance(balances, dict):
|
|
safe_payment["balances"] = {
|
|
"payer_before": balances.get("payer_before"),
|
|
"payer_after": balances.get("payer_after")
|
|
}
|
|
return safe_payment
|
|
|
|
def ws_emit_payment_event(event_name, payment, extra_payload=None):
|
|
if not isinstance(payment, dict):
|
|
return
|
|
payload = {
|
|
"id": payment.get("id"),
|
|
"status": payment.get("status"),
|
|
"payer": payment.get("payer"),
|
|
"receiver": payment.get("receiver"),
|
|
"amount": payment.get("amount"),
|
|
"allow_any_payer": bool(payment.get("allow_any_payer", False)),
|
|
"created_by": payment.get("created_by")
|
|
}
|
|
if isinstance(extra_payload, dict):
|
|
payload.update(extra_payload)
|
|
|
|
ws_emit_system(event_name, payload)
|
|
|
|
notified_users = set()
|
|
for username in [payment.get("payer"), payment.get("receiver"), payment.get("created_by")]:
|
|
username = str(username or "").strip()
|
|
if not username or username == "*" or username in notified_users:
|
|
continue
|
|
ws_emit_user(username, event_name, payload)
|
|
notified_users.add(username)
|
|
|
|
def ws_emit_crypto_market_event(market, reason="update"):
|
|
if not isinstance(market, dict):
|
|
return
|
|
payload = {
|
|
"reason": reason,
|
|
"coin": market.get("name", SERKRIPTO_NAME),
|
|
"symbol": market.get("symbol", SERKRIPTO_SYMBOL),
|
|
"price_rub": round(float(market.get("price", 0.0)), 4),
|
|
"min_price_rub": round(float(market.get("min_price", 1.0)), 4),
|
|
"max_price_rub": round(float(market.get("max_price", 1000000.0)), 4),
|
|
"time": int(time.time())
|
|
}
|
|
ws_emit_system("crypto.market", payload)
|
|
|
|
def create_payment(payer, receiver, amount, description="", external_id="", allow_any_payer=False, created_by=None):
|
|
"""Создание платежа"""
|
|
payments = load_payments()
|
|
|
|
payment_id = str(uuid.uuid4())[:12]
|
|
safe_payer = "*" if allow_any_payer else payer
|
|
payment_data = {
|
|
"id": payment_id,
|
|
"payer": safe_payer,
|
|
"receiver": receiver,
|
|
"amount": int(amount),
|
|
"description": description,
|
|
"external_id": external_id,
|
|
"allow_any_payer": bool(allow_any_payer),
|
|
"created_by": created_by if created_by else payer,
|
|
"created": int(time.time()),
|
|
"status": "pending", # pending, completed, failed, cancelled
|
|
"completed_at": None
|
|
}
|
|
|
|
payments.append(payment_data)
|
|
save_payments(payments)
|
|
ws_emit_payment_event("payment.created", payment_data)
|
|
|
|
return payment_id, payment_data
|
|
|
|
def process_payment(payment_id, payer_username=None):
|
|
"""Обработка платежа"""
|
|
payments = load_payments()
|
|
users = load_users()
|
|
|
|
payment = None
|
|
for p in payments:
|
|
if p["id"] == payment_id:
|
|
payment = p
|
|
break
|
|
|
|
if not payment:
|
|
return False, "Платеж не найден"
|
|
|
|
if payment["status"] != "pending":
|
|
return False, f"Платеж уже обработан (статус: {payment['status']})"
|
|
|
|
receiver = payment["receiver"]
|
|
amount = payment["amount"]
|
|
allow_any_payer = bool(payment.get("allow_any_payer", False))
|
|
|
|
if allow_any_payer:
|
|
if not payer_username:
|
|
return False, "Для этого платежа нужен логин плательщика"
|
|
payer = payer_username
|
|
else:
|
|
payer = payment["payer"]
|
|
if payer_username and payer != payer_username:
|
|
return False, "Неверный плательщик"
|
|
|
|
# Проверяем существование пользователей
|
|
if payer not in users:
|
|
return False, "Плательщик не найден"
|
|
|
|
if receiver not in users:
|
|
return False, "Получатель не найден"
|
|
|
|
if payer == receiver:
|
|
return False, "Нельзя оплатить самому себе"
|
|
|
|
# Проверяем баланс плательщика
|
|
if users[payer]["balance"] < amount:
|
|
return False, "Недостаточно средств"
|
|
|
|
payer_balance_before = users[payer]["balance"]
|
|
receiver_balance_before = users[receiver]["balance"]
|
|
|
|
# Выполняем перевод
|
|
users[payer]["balance"] -= amount
|
|
users[receiver]["balance"] += amount
|
|
save_users(users)
|
|
|
|
payer_balance_after = users[payer]["balance"]
|
|
receiver_balance_after = users[receiver]["balance"]
|
|
|
|
# Обновляем статус платежа
|
|
for p in payments:
|
|
if p["id"] == payment_id:
|
|
if allow_any_payer:
|
|
p["payer"] = payer
|
|
p["status"] = "completed"
|
|
p["completed_at"] = int(time.time())
|
|
p["balances"] = {
|
|
"payer_before": payer_balance_before,
|
|
"payer_after": payer_balance_after,
|
|
"receiver_before": receiver_balance_before,
|
|
"receiver_after": receiver_balance_after
|
|
}
|
|
break
|
|
|
|
save_payments(payments)
|
|
|
|
# Добавляем в историю
|
|
add_history(payer, "payment_sent", amount, f"Платеж #{payment_id} для {receiver}")
|
|
add_history(receiver, "payment_received", amount, f"Платеж #{payment_id} от {payer}")
|
|
ws_emit_payment_event("payment.completed", payment, {
|
|
"payer_before": payer_balance_before,
|
|
"payer_after": payer_balance_after
|
|
})
|
|
|
|
return True, "Платеж успешно выполнен"
|
|
|
|
def cancel_payment(payment_id):
|
|
"""Отмена платежа"""
|
|
payments = load_payments()
|
|
|
|
for payment in payments:
|
|
if payment["id"] == payment_id and payment["status"] == "pending":
|
|
payment["status"] = "cancelled"
|
|
save_payments(payments)
|
|
ws_emit_payment_event("payment.cancelled", payment)
|
|
return True, "Платеж отменен"
|
|
|
|
return False, "Платеж не найден или уже обработан"
|
|
|
|
# ---------- ФУНКЦИИ ДЛЯ МАГАЗИНА ----------
|
|
def load_shop():
|
|
"""Загрузка данных магазина"""
|
|
try:
|
|
with open(SHOP_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
# Обеспечиваем наличие всех ключей
|
|
if "items" not in data:
|
|
data["items"] = []
|
|
if "ads" not in data:
|
|
data["ads"] = []
|
|
return data
|
|
except:
|
|
return {"items": [], "ads": []}
|
|
|
|
def save_shop(data):
|
|
"""Сохранение данных магазина"""
|
|
with open(SHOP_FILE, "w", encoding='utf-8') as f:
|
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
|
|
def create_shop_item(seller_username, title, description, price, category="other"):
|
|
"""Создание товара в магазине"""
|
|
shop_data = load_shop()
|
|
|
|
item_id = str(uuid.uuid4())[:8]
|
|
new_item = {
|
|
"id": item_id,
|
|
"seller": seller_username,
|
|
"title": title,
|
|
"description": description,
|
|
"price": int(price),
|
|
"category": category,
|
|
"created": int(time.time()),
|
|
"status": "available",
|
|
"buyer": None
|
|
}
|
|
|
|
shop_data["items"].append(new_item)
|
|
save_shop(shop_data)
|
|
|
|
# Добавляем в историю
|
|
add_history(seller_username, "create_shop_item", 0, f"Товар: {title}")
|
|
ws_emit_system("shop.item_created", {
|
|
"item_id": item_id,
|
|
"seller": seller_username,
|
|
"title": title,
|
|
"price": int(price),
|
|
"category": category
|
|
})
|
|
ws_emit_user(seller_username, "shop.item_created", {
|
|
"item_id": item_id,
|
|
"title": title,
|
|
"price": int(price)
|
|
})
|
|
|
|
return item_id
|
|
|
|
def create_advertisement(author, title, content, contact_info):
|
|
"""Создание объявления"""
|
|
shop_data = load_shop()
|
|
|
|
ad_id = str(uuid.uuid4())[:8]
|
|
new_ad = {
|
|
"id": ad_id,
|
|
"author": author,
|
|
"title": title,
|
|
"content": content,
|
|
"contact_info": contact_info,
|
|
"created": int(time.time()),
|
|
"status": "active"
|
|
}
|
|
|
|
shop_data["ads"].append(new_ad)
|
|
save_shop(shop_data)
|
|
|
|
# Добавляем в историю
|
|
add_history(author, "create_advertisement", 0, f"Объявление: {title}")
|
|
ws_emit_system("shop.ad_created", {
|
|
"ad_id": ad_id,
|
|
"author": author,
|
|
"title": title
|
|
})
|
|
ws_emit_user(author, "shop.ad_created", {
|
|
"ad_id": ad_id,
|
|
"title": title
|
|
})
|
|
|
|
return ad_id
|
|
|
|
def buy_item(item_id, buyer_username):
|
|
"""Покупка товара"""
|
|
shop_data = load_shop()
|
|
users = load_users()
|
|
|
|
# Находим товар
|
|
item_to_buy = None
|
|
for item in shop_data["items"]:
|
|
if item["id"] == item_id and item["status"] == "available":
|
|
item_to_buy = item
|
|
break
|
|
|
|
if not item_to_buy:
|
|
return False, "Товар не найден или уже продан"
|
|
|
|
seller = item_to_buy["seller"]
|
|
price = item_to_buy["price"]
|
|
|
|
# Проверяем, есть ли у покупателя достаточно средств
|
|
if buyer_username not in users:
|
|
return False, "Покупатель не найден"
|
|
|
|
if users[buyer_username]["balance"] < price:
|
|
return False, "Недостаточно средств"
|
|
|
|
# Проверяем, что покупатель не покупает у себя
|
|
if buyer_username == seller:
|
|
return False, "Нельзя купить собственный товар"
|
|
|
|
# Проверяем, существует ли продавец
|
|
if seller not in users:
|
|
return False, "Продавец не найден"
|
|
|
|
# Выполняем транзакцию
|
|
users[buyer_username]["balance"] -= price
|
|
users[seller]["balance"] += price
|
|
save_users(users)
|
|
|
|
# Обновляем статус товара
|
|
for item in shop_data["items"]:
|
|
if item["id"] == item_id:
|
|
item["status"] = "sold"
|
|
item["buyer"] = buyer_username
|
|
item["sold_date"] = int(time.time())
|
|
break
|
|
|
|
save_shop(shop_data)
|
|
|
|
# Добавляем в историю
|
|
add_history(buyer_username, "buy_item", price, f"Товар: {item_to_buy['title']} от {seller}")
|
|
add_history(seller, "sell_item", price, f"Товар: {item_to_buy['title']} покупателю {buyer_username}")
|
|
ws_emit_system("shop.item_sold", {
|
|
"item_id": item_id,
|
|
"title": item_to_buy["title"],
|
|
"seller": seller,
|
|
"buyer": buyer_username,
|
|
"price": price
|
|
})
|
|
ws_emit_user(buyer_username, "shop.item_sold", {
|
|
"item_id": item_id,
|
|
"title": item_to_buy["title"],
|
|
"price": price,
|
|
"role": "buyer"
|
|
})
|
|
ws_emit_user(seller, "shop.item_sold", {
|
|
"item_id": item_id,
|
|
"title": item_to_buy["title"],
|
|
"price": price,
|
|
"role": "seller"
|
|
})
|
|
|
|
return True, "Покупка успешно завершена"
|
|
|
|
def default_crypto_market():
|
|
now = int(time.time())
|
|
return {
|
|
"name": SERKRIPTO_NAME,
|
|
"symbol": SERKRIPTO_SYMBOL,
|
|
"price": 100.0,
|
|
"min_price": 1.0,
|
|
"max_price": 1000000.0,
|
|
"price_step_buy": 0.012,
|
|
"price_step_sell": 0.01,
|
|
"idle_decay_rate": 0.0015,
|
|
"idle_decay_interval_sec": 30,
|
|
"last_buy_at": now,
|
|
"last_update": now,
|
|
"stats": {
|
|
"buy_ops": 0,
|
|
"sell_ops": 0,
|
|
"buy_volume": 0.0,
|
|
"sell_volume": 0.0
|
|
}
|
|
}
|
|
|
|
def normalize_crypto_market(data):
|
|
defaults = default_crypto_market()
|
|
market = {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
|
|
market["name"] = str(data.get("name", defaults["name"]) or defaults["name"])
|
|
market["symbol"] = str(data.get("symbol", defaults["symbol"]) or defaults["symbol"]).upper()
|
|
|
|
def to_float(value, fallback):
|
|
try:
|
|
return float(value)
|
|
except:
|
|
return float(fallback)
|
|
|
|
def to_int(value, fallback):
|
|
try:
|
|
return int(value)
|
|
except:
|
|
return int(fallback)
|
|
|
|
market["min_price"] = max(0.01, to_float(data.get("min_price"), defaults["min_price"]))
|
|
market["max_price"] = max(market["min_price"], to_float(data.get("max_price"), defaults["max_price"]))
|
|
market["price"] = to_float(data.get("price"), defaults["price"])
|
|
market["price"] = min(market["max_price"], max(market["min_price"], market["price"]))
|
|
market["price"] = round(market["price"], 4)
|
|
|
|
market["price_step_buy"] = min(0.5, max(0.0, to_float(data.get("price_step_buy"), defaults["price_step_buy"])))
|
|
market["price_step_sell"] = min(0.5, max(0.0, to_float(data.get("price_step_sell"), defaults["price_step_sell"])))
|
|
market["idle_decay_rate"] = min(0.2, max(0.0, to_float(data.get("idle_decay_rate"), defaults["idle_decay_rate"])))
|
|
market["idle_decay_interval_sec"] = max(5, to_int(data.get("idle_decay_interval_sec"), defaults["idle_decay_interval_sec"]))
|
|
|
|
market["last_buy_at"] = to_int(data.get("last_buy_at"), defaults["last_buy_at"])
|
|
market["last_update"] = to_int(data.get("last_update"), defaults["last_update"])
|
|
|
|
stats = data.get("stats", {})
|
|
if not isinstance(stats, dict):
|
|
stats = {}
|
|
market["stats"] = {
|
|
"buy_ops": max(0, to_int(stats.get("buy_ops"), 0)),
|
|
"sell_ops": max(0, to_int(stats.get("sell_ops"), 0)),
|
|
"buy_volume": max(0.0, round(to_float(stats.get("buy_volume"), 0.0), 6)),
|
|
"sell_volume": max(0.0, round(to_float(stats.get("sell_volume"), 0.0), 6))
|
|
}
|
|
return market
|
|
|
|
def load_crypto_market():
|
|
need_save = False
|
|
raw = None
|
|
try:
|
|
with open(CRYPTO_FILE, 'r', encoding='utf-8') as f:
|
|
raw = json.load(f)
|
|
except:
|
|
raw = default_crypto_market()
|
|
need_save = True
|
|
|
|
market = normalize_crypto_market(raw)
|
|
if raw != market:
|
|
need_save = True
|
|
if need_save:
|
|
save_crypto_market(market)
|
|
return market
|
|
|
|
def save_crypto_market(data):
|
|
with open(CRYPTO_FILE, "w", encoding='utf-8') as f:
|
|
json.dump(normalize_crypto_market(data), f, indent=4, ensure_ascii=False)
|
|
|
|
def apply_serkripto_idle_decay(market):
|
|
market = normalize_crypto_market(market)
|
|
now = int(time.time())
|
|
last_update = int(market.get("last_update", now))
|
|
interval = int(market.get("idle_decay_interval_sec", 30))
|
|
if now <= last_update:
|
|
return market, False
|
|
if now - int(market.get("last_buy_at", 0)) < interval:
|
|
market["last_update"] = now
|
|
return market, False
|
|
|
|
steps = (now - last_update) // interval
|
|
if steps <= 0:
|
|
return market, False
|
|
|
|
price_before = float(market.get("price", 100.0))
|
|
decay_rate = float(market.get("idle_decay_rate", 0.0015))
|
|
min_price = float(market.get("min_price", 1.0))
|
|
decayed_price = max(min_price, price_before * ((1 - decay_rate) ** steps))
|
|
market["price"] = round(decayed_price, 4)
|
|
market["last_update"] = now
|
|
changed = abs(price_before - market["price"]) > 1e-9
|
|
return market, changed
|
|
|
|
def get_user_serkripto_amount(user_data):
|
|
if not isinstance(user_data, dict):
|
|
return 0.0
|
|
try:
|
|
return round(max(0.0, float(user_data.get("serkripto", 0.0))), 6)
|
|
except:
|
|
return 0.0
|
|
|
|
def buy_serkripto(username, rub_amount):
|
|
try:
|
|
amount = int(rub_amount)
|
|
except:
|
|
return False, "invalid_amount"
|
|
if amount <= 0:
|
|
return False, "invalid_amount"
|
|
|
|
users = load_users()
|
|
user_data = users.get(username)
|
|
if not isinstance(user_data, dict):
|
|
return False, "user_not_found"
|
|
|
|
market = load_crypto_market()
|
|
market, decayed = apply_serkripto_idle_decay(market)
|
|
if decayed:
|
|
save_crypto_market(market)
|
|
ws_emit_crypto_market_event(market, "idle_decay")
|
|
|
|
price_before = float(market.get("price", 0.0))
|
|
if price_before <= 0:
|
|
return False, "market_unavailable"
|
|
if int(user_data.get("balance", 0)) < amount:
|
|
return False, "insufficient_funds"
|
|
|
|
quantity = round(amount / price_before, 6)
|
|
if quantity <= 0:
|
|
return False, "amount_too_small"
|
|
|
|
user_data["balance"] = int(user_data.get("balance", 0)) - amount
|
|
user_data["serkripto"] = round(get_user_serkripto_amount(user_data) + quantity, 6)
|
|
save_users(users)
|
|
|
|
growth = min(0.25, quantity * float(market.get("price_step_buy", 0.012)))
|
|
max_price = float(market.get("max_price", 1000000.0))
|
|
price_after = min(max_price, price_before * (1 + growth))
|
|
now = int(time.time())
|
|
market["price"] = round(price_after, 4)
|
|
market["last_buy_at"] = now
|
|
market["last_update"] = now
|
|
market["stats"]["buy_ops"] = int(market["stats"].get("buy_ops", 0)) + 1
|
|
market["stats"]["buy_volume"] = round(float(market["stats"].get("buy_volume", 0.0)) + quantity, 6)
|
|
save_crypto_market(market)
|
|
ws_emit_crypto_market_event(market, "buy")
|
|
|
|
add_history(username, "crypto_buy", amount, f"{SERKRIPTO_NAME} +{quantity:.6f} по {price_before:.4f} ₽")
|
|
ws_emit_balance_update(username)
|
|
ws_emit_user(username, "crypto.trade", {
|
|
"side": "buy",
|
|
"coin": SERKRIPTO_NAME,
|
|
"quantity": quantity,
|
|
"amount_rub": amount,
|
|
"price_before": round(price_before, 4),
|
|
"price_after": round(market["price"], 4)
|
|
})
|
|
ws_emit_system("crypto.trade", {
|
|
"side": "buy",
|
|
"user": username,
|
|
"coin": SERKRIPTO_NAME,
|
|
"quantity": quantity,
|
|
"price": round(market["price"], 4)
|
|
})
|
|
|
|
return True, {
|
|
"coin": SERKRIPTO_NAME,
|
|
"symbol": market.get("symbol", SERKRIPTO_SYMBOL),
|
|
"quantity": quantity,
|
|
"amount_rub": amount,
|
|
"price_before": round(price_before, 4),
|
|
"price_after": round(float(market.get("price", price_before)), 4),
|
|
"balance_rub": int(user_data.get("balance", 0)),
|
|
"wallet_quantity": get_user_serkripto_amount(user_data)
|
|
}
|
|
|
|
def sell_serkripto(username, quantity):
|
|
try:
|
|
qty = round(float(quantity), 6)
|
|
except:
|
|
return False, "invalid_quantity"
|
|
if qty <= 0:
|
|
return False, "invalid_quantity"
|
|
|
|
users = load_users()
|
|
user_data = users.get(username)
|
|
if not isinstance(user_data, dict):
|
|
return False, "user_not_found"
|
|
|
|
wallet_qty = get_user_serkripto_amount(user_data)
|
|
if wallet_qty + 1e-9 < qty:
|
|
return False, "insufficient_crypto"
|
|
|
|
market = load_crypto_market()
|
|
market, decayed = apply_serkripto_idle_decay(market)
|
|
if decayed:
|
|
save_crypto_market(market)
|
|
ws_emit_crypto_market_event(market, "idle_decay")
|
|
|
|
price_before = float(market.get("price", 0.0))
|
|
if price_before <= 0:
|
|
return False, "market_unavailable"
|
|
|
|
payout = int(round(qty * price_before))
|
|
if payout <= 0:
|
|
return False, "amount_too_small"
|
|
|
|
user_data["serkripto"] = round(max(0.0, wallet_qty - qty), 6)
|
|
user_data["balance"] = int(user_data.get("balance", 0)) + payout
|
|
save_users(users)
|
|
|
|
drop = min(0.25, qty * float(market.get("price_step_sell", 0.01)))
|
|
min_price = float(market.get("min_price", 1.0))
|
|
price_after = max(min_price, price_before * (1 - drop))
|
|
now = int(time.time())
|
|
market["price"] = round(price_after, 4)
|
|
market["last_update"] = now
|
|
market["stats"]["sell_ops"] = int(market["stats"].get("sell_ops", 0)) + 1
|
|
market["stats"]["sell_volume"] = round(float(market["stats"].get("sell_volume", 0.0)) + qty, 6)
|
|
save_crypto_market(market)
|
|
ws_emit_crypto_market_event(market, "sell")
|
|
|
|
add_history(username, "crypto_sell", payout, f"{SERKRIPTO_NAME} -{qty:.6f} по {price_before:.4f} ₽")
|
|
ws_emit_balance_update(username)
|
|
ws_emit_user(username, "crypto.trade", {
|
|
"side": "sell",
|
|
"coin": SERKRIPTO_NAME,
|
|
"quantity": qty,
|
|
"amount_rub": payout,
|
|
"price_before": round(price_before, 4),
|
|
"price_after": round(market["price"], 4)
|
|
})
|
|
ws_emit_system("crypto.trade", {
|
|
"side": "sell",
|
|
"user": username,
|
|
"coin": SERKRIPTO_NAME,
|
|
"quantity": qty,
|
|
"price": round(market["price"], 4)
|
|
})
|
|
|
|
return True, {
|
|
"coin": SERKRIPTO_NAME,
|
|
"symbol": market.get("symbol", SERKRIPTO_SYMBOL),
|
|
"quantity": qty,
|
|
"amount_rub": payout,
|
|
"price_before": round(price_before, 4),
|
|
"price_after": round(float(market.get("price", price_before)), 4),
|
|
"balance_rub": int(user_data.get("balance", 0)),
|
|
"wallet_quantity": get_user_serkripto_amount(user_data)
|
|
}
|
|
|
|
# ---------- API КЛЮЧИ ----------
|
|
def generate_api_key():
|
|
"""Генерация API ключа"""
|
|
return f"bank_api_{uuid.uuid4().hex[:16]}"
|
|
|
|
def get_user_by_api_key(api_key):
|
|
"""Получение пользователя по API ключу - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
if not api_key:
|
|
return None, None
|
|
|
|
users = load_users()
|
|
for username, user_data in users.items():
|
|
# Проверяем, что user_data является словарем
|
|
if isinstance(user_data, dict) and user_data.get('api_key') == api_key:
|
|
return username, user_data
|
|
return None, None
|
|
|
|
# ---------- БАНКОВСКИЕ ОПЕРАЦИИ ----------
|
|
def daily_update():
|
|
"""Ежедневное обновление депозитов и кредитов"""
|
|
users = load_users()
|
|
updated = False
|
|
updated_users = []
|
|
for user_id, u in users.items():
|
|
if isinstance(u, dict): # Проверяем, что это словарь
|
|
if u.get('deposit', 0) > 0:
|
|
u['deposit'] = int(u['deposit'] * 1.01)
|
|
updated = True
|
|
updated_users.append(user_id)
|
|
if u.get('credit', 0) > 0:
|
|
u['credit'] = int(u['credit'] * 1.05)
|
|
updated = True
|
|
if user_id not in updated_users:
|
|
updated_users.append(user_id)
|
|
if updated:
|
|
save_users(users)
|
|
ws_emit_system("bank.daily_update", {"users": updated_users})
|
|
for username in updated_users:
|
|
ws_emit_balance_update(username)
|
|
|
|
def add_history(user, action, amount, target=None):
|
|
"""Добавление записи в историю"""
|
|
hist = load_history()
|
|
history_item = {
|
|
"time": int(time.time()),
|
|
"user": user,
|
|
"action": action,
|
|
"amount": amount,
|
|
"target": target,
|
|
"id": str(uuid.uuid4())[:8]
|
|
}
|
|
hist.append(history_item)
|
|
save_history(hist)
|
|
|
|
ws_emit_user(user, "history.new", history_item)
|
|
ws_emit_system("history.new", {
|
|
"user": user,
|
|
"action": action,
|
|
"amount": amount,
|
|
"target": target
|
|
})
|
|
ws_emit_balance_update(user)
|
|
|
|
# ---------- ДЕКОРАТОРЫ ----------
|
|
def login_required(f):
|
|
"""Декоратор для проверки авторизации"""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
if 'user' not in session:
|
|
return redirect('/')
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
|
|
def web_roles_required(*allowed_roles):
|
|
"""Проверка ролей для WEB-маршрутов (требует активную сессию)"""
|
|
normalized_allowed = {normalize_role(r) for r in allowed_roles}
|
|
|
|
def decorator(f):
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
if 'user' not in session:
|
|
return redirect('/')
|
|
|
|
users = load_users()
|
|
user_data = users.get(session['user'])
|
|
if not isinstance(user_data, dict):
|
|
return redirect('/')
|
|
|
|
user_role = get_user_role(user_data)
|
|
if user_role not in normalized_allowed:
|
|
return redirect('/dashboard')
|
|
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
return decorator
|
|
|
|
def admin_required(f):
|
|
"""Декоратор для проверки прав администратора"""
|
|
return web_roles_required(ROLE_ADMIN)(f)
|
|
|
|
# ИСПРАВЛЕННЫЙ ДЕКОРАТОР API
|
|
def api_key_required(f):
|
|
"""Декоратор для проверки API ключа - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
api_key = None
|
|
|
|
# 1. Проверяем заголовок X-API-Key
|
|
if 'X-API-Key' in request.headers:
|
|
api_key = request.headers.get('X-API-Key')
|
|
|
|
# 2. Проверяем параметр запроса api_key
|
|
if not api_key and request.args.get('api_key'):
|
|
api_key = request.args.get('api_key')
|
|
|
|
# 3. Проверяем JSON тело
|
|
if not api_key and request.is_json:
|
|
try:
|
|
data = request.get_json()
|
|
if data and 'api_key' in data:
|
|
api_key = data.get('api_key')
|
|
except:
|
|
pass
|
|
|
|
# 4. Проверяем форму
|
|
if not api_key and request.form.get('api_key'):
|
|
api_key = request.form.get('api_key')
|
|
|
|
if not api_key:
|
|
return jsonify({'success': False, 'error': 'API key required'}), 401
|
|
|
|
# Получаем пользователя по API ключу
|
|
username, user_data = get_user_by_api_key(api_key)
|
|
if not username:
|
|
return jsonify({'success': False, 'error': 'Invalid API key'}), 401
|
|
|
|
# Сохраняем данные пользователя в объекте request
|
|
request.username = username
|
|
request.user_data = user_data
|
|
request.user_role = get_user_role(user_data)
|
|
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
|
|
def api_roles_required(*allowed_roles):
|
|
"""Проверка ролей для API маршрутов (использовать после api_key_required)"""
|
|
normalized_allowed = {normalize_role(r) for r in allowed_roles}
|
|
|
|
def decorator(f):
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
user_role = getattr(request, "user_role", get_user_role(getattr(request, "user_data", {})))
|
|
if user_role not in normalized_allowed:
|
|
return jsonify({
|
|
"success": False,
|
|
"error": "Access denied for your role",
|
|
"role": user_role
|
|
}), 403
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
return decorator
|
|
|
|
# ---------- HTML ШАБЛОНЫ ----------
|
|
def render_login(error_message="", twofa_mode=False, username_hint=""):
|
|
safe_error = html.escape(str(error_message or "").strip())
|
|
alert_html = f'<div class="alert alert-danger mt-3">{safe_error}</div>' if safe_error else ""
|
|
safe_username_hint = html.escape(str(username_hint or "").strip())
|
|
security_notice_html = '<div class="alert alert-warning mt-3 mb-0 small"><strong>Важно:</strong> Мы не гарантируем полную безопасность сервиса. Все пароли хранятся в открытом виде, в базе данных, всем этим заведует один человек, поэтому я не гарантирую полную безопасность. Если понадобится восстановление, пишите на почту kompania.bank@gmail.com.</div>'
|
|
|
|
subtitle = "Вход в систему"
|
|
auth_form_html = '''
|
|
<form method="post">
|
|
<div class="mb-3">
|
|
<label class="form-label">Логин</label>
|
|
<div class="input-group">
|
|
<span class="input-group-text"><i class="fas fa-user"></i></span>
|
|
<input type="text" name="username" class="form-control" placeholder="Введите логин" required>
|
|
</div>
|
|
</div>
|
|
<div class="mb-4">
|
|
<label class="form-label">Пароль</label>
|
|
<div class="input-group">
|
|
<span class="input-group-text"><i class="fas fa-lock"></i></span>
|
|
<input type="password" name="password" class="form-control" placeholder="Введите пароль" required>
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary w-100 mb-3">
|
|
<i class="fas fa-sign-in-alt me-2"></i>Войти
|
|
</button>
|
|
<div class="text-center">
|
|
<p class="mb-0">Нет аккаунта? <a href="/register" class="text-decoration-none">Зарегистрироваться</a></p>
|
|
</div>
|
|
</form>
|
|
'''
|
|
|
|
if twofa_mode:
|
|
subtitle = f'Подтверждение входа: <b>{safe_username_hint or "пользователь"}</b>'
|
|
auth_form_html = '''
|
|
<form method="post">
|
|
<input type="hidden" name="login_step" value="2fa">
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-shield-alt me-2"></i>
|
|
Введите 6-значный код из приложения-аутентификатора
|
|
</div>
|
|
<div class="mb-4">
|
|
<label class="form-label">Код 2FA</label>
|
|
<div class="input-group">
|
|
<span class="input-group-text"><i class="fas fa-mobile-alt"></i></span>
|
|
<input type="text" name="twofa_code" class="form-control" placeholder="123456" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}" required autofocus>
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary w-100 mb-3">
|
|
<i class="fas fa-check-circle me-2"></i>Подтвердить вход
|
|
</button>
|
|
<div class="text-center">
|
|
<p class="mb-0"><a href="/?reset_2fa=1" class="text-decoration-none">Войти под другим пользователем</a></p>
|
|
</div>
|
|
</form>
|
|
'''
|
|
|
|
page = '''
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Банк "Школьный" | Вход в систему</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
<style>
|
|
:root {
|
|
--primary: #4361ee;
|
|
--secondary: #3a0ca3;
|
|
--gradient: linear-gradient(135deg, #4361ee 0%, #3a0ca3 100%);
|
|
}
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
margin: 0;
|
|
background: var(--gradient);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 16px;
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
}
|
|
.auth-shell {
|
|
width: min(420px, 100%);
|
|
}
|
|
.container, .row, .col-md-5 {
|
|
width: 100%;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
.login-card {
|
|
background: white;
|
|
border-radius: 20px;
|
|
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
}
|
|
.login-header {
|
|
background: var(--gradient);
|
|
color: white;
|
|
padding: 28px 24px;
|
|
text-align: center;
|
|
}
|
|
.login-header h2 {
|
|
font-size: 1.4rem;
|
|
line-height: 1.25;
|
|
margin-bottom: 8px;
|
|
}
|
|
.login-body {
|
|
padding: 24px 20px;
|
|
}
|
|
.form-label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
color: #283247;
|
|
}
|
|
.input-group {
|
|
display: flex;
|
|
align-items: center;
|
|
border: 2px solid #e9ecef;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
background: #fff;
|
|
transition: all 0.3s;
|
|
}
|
|
.input-group:focus-within {
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25);
|
|
}
|
|
.input-group-text {
|
|
border: none;
|
|
background: #f8f9ff;
|
|
padding: 0 12px;
|
|
color: #657089;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.form-control {
|
|
border: none;
|
|
border-radius: 0;
|
|
padding: 12px 15px;
|
|
transition: all 0.3s;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.form-control:focus {
|
|
outline: none;
|
|
box-shadow: none;
|
|
}
|
|
.btn-primary {
|
|
background: var(--gradient);
|
|
border: none;
|
|
border-radius: 10px;
|
|
padding: 12px;
|
|
font-weight: 600;
|
|
transition: all 0.3s;
|
|
color: white;
|
|
width: 100%;
|
|
cursor: pointer;
|
|
}
|
|
.btn-primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 20px rgba(67, 97, 238, 0.3);
|
|
}
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.alert {
|
|
border-radius: 10px;
|
|
}
|
|
.text-center {
|
|
text-align: center;
|
|
}
|
|
.mb-0 {
|
|
margin-bottom: 0;
|
|
}
|
|
.mb-3 {
|
|
margin-bottom: 1rem;
|
|
}
|
|
.mb-4 {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.w-100 {
|
|
width: 100%;
|
|
}
|
|
.me-2 {
|
|
margin-right: 0.5rem;
|
|
}
|
|
a {
|
|
color: var(--primary);
|
|
}
|
|
.text-decoration-none {
|
|
text-decoration: none;
|
|
}
|
|
@media (min-width: 992px) {
|
|
body {
|
|
padding: 24px;
|
|
}
|
|
.auth-shell {
|
|
width: 420px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="auth-shell">
|
|
<div class="container">
|
|
<div class="row justify-content-center">
|
|
<div class="col-md-5">
|
|
<div class="login-card">
|
|
<div class="login-header">
|
|
<i class="fas fa-university fa-3x mb-3"></i>
|
|
<h2>Добро пожаловать в Банк "Школьный"</h2>
|
|
<p class="mb-0">__AUTH_SUBTITLE__</p>
|
|
</div>
|
|
<div class="login-body">
|
|
__AUTH_FORM__
|
|
__AUTH_ALERT__
|
|
__SECURITY_NOTICE__
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
return (
|
|
page.replace("__AUTH_SUBTITLE__", subtitle)
|
|
.replace("__AUTH_FORM__", auth_form_html)
|
|
.replace("__AUTH_ALERT__", alert_html)
|
|
.replace("__SECURITY_NOTICE__", security_notice_html)
|
|
)
|
|
|
|
def render_register():
|
|
return '''
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Банк "Школьный" | Регистрация</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
<style>
|
|
:root {
|
|
--primary: #4361ee;
|
|
--secondary: #3a0ca3;
|
|
--gradient: linear-gradient(135deg, #4361ee 0%, #3a0ca3 100%);
|
|
}
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
margin: 0;
|
|
background: var(--gradient);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 16px;
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
}
|
|
.auth-shell {
|
|
width: min(460px, 100%);
|
|
}
|
|
.container, .row, .col-md-6 {
|
|
width: 100%;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
.register-card {
|
|
background: white;
|
|
border-radius: 20px;
|
|
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
}
|
|
.register-header {
|
|
background: var(--gradient);
|
|
color: white;
|
|
padding: 28px 24px;
|
|
text-align: center;
|
|
}
|
|
.register-header h2 {
|
|
font-size: 1.3rem;
|
|
line-height: 1.25;
|
|
margin-bottom: 8px;
|
|
}
|
|
.register-body {
|
|
padding: 24px 20px;
|
|
}
|
|
.form-label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
color: #283247;
|
|
}
|
|
.form-control {
|
|
border: 2px solid #e9ecef;
|
|
border-radius: 10px;
|
|
padding: 12px 15px;
|
|
transition: all 0.3s;
|
|
width: 100%;
|
|
}
|
|
.form-control:focus {
|
|
border-color: var(--primary);
|
|
outline: none;
|
|
box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25);
|
|
}
|
|
.btn-primary {
|
|
background: var(--gradient);
|
|
border: none;
|
|
border-radius: 10px;
|
|
padding: 12px;
|
|
font-weight: 600;
|
|
transition: all 0.3s;
|
|
color: white;
|
|
width: 100%;
|
|
cursor: pointer;
|
|
}
|
|
.btn-primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 20px rgba(67, 97, 238, 0.3);
|
|
}
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.text-center {
|
|
text-align: center;
|
|
}
|
|
.mb-0 {
|
|
margin-bottom: 0;
|
|
}
|
|
.mb-3 {
|
|
margin-bottom: 1rem;
|
|
}
|
|
.mb-4 {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.w-100 {
|
|
width: 100%;
|
|
}
|
|
.me-2 {
|
|
margin-right: 0.5rem;
|
|
}
|
|
a {
|
|
color: var(--primary);
|
|
}
|
|
.text-decoration-none {
|
|
text-decoration: none;
|
|
}
|
|
@media (min-width: 992px) {
|
|
body {
|
|
padding: 24px;
|
|
}
|
|
.auth-shell {
|
|
width: 460px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="auth-shell">
|
|
<div class="container">
|
|
<div class="row justify-content-center">
|
|
<div class="col-md-6">
|
|
<div class="register-card">
|
|
<div class="register-header">
|
|
<i class="fas fa-user-plus fa-3x mb-3"></i>
|
|
<h2>Регистрация аккаунта в банке "Школьный"</h2>
|
|
<p class="mb-0">Создайте свой банковский аккаунт</p>
|
|
</div>
|
|
<div class="register-body">
|
|
<form method="post">
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">Логин</label>
|
|
<input type="text" name="username" class="form-control" placeholder="Придумайте логин" required>
|
|
</div>
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">Пароль</label>
|
|
<input type="password" name="password" class="form-control" placeholder="Придумайте пароль" required>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">Имя</label>
|
|
<input type="text" name="first_name" class="form-control" placeholder="Ваше имя" required>
|
|
</div>
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">Фамилия</label>
|
|
<input type="text" name="last_name" class="form-control" placeholder="Ваша фамилия" required>
|
|
</div>
|
|
</div>
|
|
<div class="mb-4">
|
|
<label class="form-label">Email</label>
|
|
<input type="email" name="email" class="form-control" placeholder="email@example.com" required>
|
|
</div>
|
|
<div class="alert alert-warning small">
|
|
<strong>Важно:</strong> Мы не гарантируем полную безопасность сервиса. Все пароли хранятся в открытом виде, в базе данных, всем этим заведует один человек, поэтому я не гарантирую полную безопасность. Если понадобится восстановление, пишите на почту kompania.bank@gmail.com.
|
|
</div>
|
|
<button type="submit" class="btn btn-primary w-100 mb-3">
|
|
<i class="fas fa-user-plus me-2"></i>Создать аккаунт
|
|
</button>
|
|
<div class="text-center">
|
|
<p class="mb-0">Уже есть аккаунт? <a href="/" class="text-decoration-none">Войти</a></p>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
def render_dashboard(user_data, history_data, shop_data=None, crypto_market=None, crypto_flash=None, active_tab="services"):
|
|
"""Генерация HTML личного кабинета"""
|
|
current_role = get_user_role(user_data)
|
|
can_take_credit = current_role in CREDIT_ROLES
|
|
can_trade_crypto = current_role in CRYPTO_ROLES
|
|
|
|
allowed_tabs = {"services", "shop", "history", "api"}
|
|
if can_trade_crypto:
|
|
allowed_tabs.add("crypto")
|
|
if active_tab not in allowed_tabs:
|
|
active_tab = "services"
|
|
|
|
if not isinstance(crypto_market, dict):
|
|
crypto_market = load_crypto_market()
|
|
crypto_market, decayed = apply_serkripto_idle_decay(crypto_market)
|
|
if decayed:
|
|
save_crypto_market(crypto_market)
|
|
ws_emit_crypto_market_event(crypto_market, "idle_decay")
|
|
|
|
crypto_price = round(float(crypto_market.get("price", 0.0)), 4)
|
|
crypto_user_amount = get_user_serkripto_amount(user_data)
|
|
crypto_portfolio_value = round(crypto_user_amount * crypto_price, 2)
|
|
crypto_stats = crypto_market.get("stats", {})
|
|
if not isinstance(crypto_stats, dict):
|
|
crypto_stats = {}
|
|
crypto_buy_ops = int(crypto_stats.get("buy_ops", 0))
|
|
crypto_sell_ops = int(crypto_stats.get("sell_ops", 0))
|
|
crypto_buy_volume = round(float(crypto_stats.get("buy_volume", 0.0)), 6)
|
|
crypto_sell_volume = round(float(crypto_stats.get("sell_volume", 0.0)), 6)
|
|
decay_rate_pct = round(float(crypto_market.get("idle_decay_rate", 0.0)) * 100, 3)
|
|
decay_interval = int(crypto_market.get("idle_decay_interval_sec", 30))
|
|
net_flow = round(crypto_buy_volume - crypto_sell_volume, 6)
|
|
max_buy_amount = max(0, int(user_data.get("balance", 0))) if isinstance(user_data, dict) else 0
|
|
max_sell_quantity = max(0.0, crypto_user_amount)
|
|
|
|
crypto_flash_html = ""
|
|
if isinstance(crypto_flash, dict) and crypto_flash.get("text"):
|
|
flash_class = crypto_flash.get("class", "info")
|
|
flash_text = str(crypto_flash.get("text"))
|
|
crypto_flash_html = f'<div class="alert alert-{flash_class} mb-3">{flash_text}</div>'
|
|
|
|
# Форматирование времени истории
|
|
for h in history_data:
|
|
h['formatted_time'] = datetime.fromtimestamp(h['time']).strftime('%d.%m.%Y %H:%M')
|
|
|
|
# HTML для истории
|
|
history_html = ""
|
|
for h in history_data[-10:][::-1]:
|
|
icon = "fa-history"
|
|
color = "text-primary"
|
|
if "deposit" in h['action']:
|
|
icon = "fa-piggy-bank"
|
|
color = "text-info"
|
|
elif "credit" in h['action']:
|
|
icon = "fa-credit-card"
|
|
color = "text-warning"
|
|
elif "transfer" in h['action']:
|
|
icon = "fa-exchange-alt"
|
|
color = "text-primary"
|
|
elif "card" in h['action']:
|
|
icon = "fa-credit-card"
|
|
color = "text-success"
|
|
elif "api" in h['action']:
|
|
icon = "fa-key"
|
|
color = "text-secondary"
|
|
elif "shop" in h['action'] or "item" in h['action'] or "advertisement" in h['action']:
|
|
icon = "fa-shopping-cart"
|
|
color = "text-success"
|
|
elif "payment" in h['action']:
|
|
icon = "fa-credit-card"
|
|
color = "text-success"
|
|
|
|
history_html += f'''
|
|
<tr>
|
|
<td><i class="fas {icon} {color} me-2"></i>{h['formatted_time']}</td>
|
|
<td><span class="badge bg-light text-dark">{h['user']}</span></td>
|
|
<td>{h['action']}</td>
|
|
<td class="fw-bold">{h['amount']:,} ₽</td>
|
|
<td>{h.get('target', '-')}</td>
|
|
</tr>
|
|
'''
|
|
|
|
# HTML для API ключа
|
|
api_key_html = ""
|
|
if user_data.get('api_key'):
|
|
api_key_html = f'''
|
|
<div class="alert alert-success">
|
|
<h6><i class="fas fa-key me-2"></i>Ваш API ключ</h6>
|
|
<div class="input-group">
|
|
<input type="text" id="apiKey" class="form-control" value="{user_data['api_key']}" readonly>
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyApiKey()">
|
|
<i class="fas fa-copy"></i>
|
|
</button>
|
|
</div>
|
|
<small class="text-muted">Используйте этот ключ для доступа к API</small>
|
|
</div>
|
|
'''
|
|
else:
|
|
api_key_html = '''
|
|
<div class="alert alert-warning">
|
|
<h6><i class="fas fa-key me-2"></i>API ключ не создан</h6>
|
|
<form action="/generate_api_key" method="post">
|
|
<button type="submit" class="btn btn-primary btn-sm">
|
|
<i class="fas fa-plus me-1"></i>Создать API ключ
|
|
</button>
|
|
</form>
|
|
</div>
|
|
'''
|
|
|
|
# HTML для магазина
|
|
shop_tab_content = ""
|
|
if shop_data:
|
|
# Товары
|
|
shop_items_html = ""
|
|
available_items = [item for item in shop_data["items"] if item["status"] == "available"]
|
|
|
|
if available_items:
|
|
for item in available_items[-10:][::-1]: # Последние 10 товаров
|
|
created_date = datetime.fromtimestamp(item["created"]).strftime('%d.%m.%Y')
|
|
shop_items_html += f'''
|
|
<div class="col-md-6 col-lg-4">
|
|
<div class="card h-100 shop-item-card">
|
|
<div class="card-body">
|
|
<h5 class="card-title">{item["title"]}</h5>
|
|
<p class="card-text">{item["description"]}</p>
|
|
<p class="card-text">
|
|
<small class="text-muted">Продавец: {item["seller"]}</small><br>
|
|
<small class="text-muted">Категория: {item["category"]}</small><br>
|
|
<small class="text-muted">Дата: {created_date}</small>
|
|
</p>
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<span class="h4 text-success">{item["price"]:,} ₽</span>
|
|
<form action="/buy_item/{item['id']}" method="post">
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="fas fa-shopping-cart me-1"></i> Купить
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
'''
|
|
else:
|
|
shop_items_html = '''
|
|
<div class="col-12">
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-info-circle me-2"></i>
|
|
В магазине пока нет товаров. Будьте первым, кто добавит товар!
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
# Объявления
|
|
ads_html = ""
|
|
active_ads = [ad for ad in shop_data["ads"] if ad["status"] == "active"]
|
|
|
|
if active_ads:
|
|
for ad in active_ads[-10:][::-1]: # Последние 10 объявлений
|
|
created_date = datetime.fromtimestamp(ad["created"]).strftime('%d.%m.%Y')
|
|
ads_html += f'''
|
|
<div class="col-md-6">
|
|
<div class="card h-100">
|
|
<div class="card-body">
|
|
<h5 class="card-title">{ad["title"]}</h5>
|
|
<p class="card-text">{ad["content"]}</p>
|
|
<p class="card-text">
|
|
<small class="text-muted">Автор: {ad["author"]}</small><br>
|
|
<small class="text-muted">Контакты: {ad["contact_info"]}</small><br>
|
|
<small class="text-muted">Дата: {created_date}</small>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
shop_tab_content = f'''
|
|
<div class="tab-pane fade" id="shop">
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="d-flex justify-content-between mb-3">
|
|
<h4><i class="fas fa-store me-2"></i>Магазин</h4>
|
|
<div>
|
|
<button class="btn btn-success me-2" data-bs-toggle="modal" data-bs-target="#addItemModal">
|
|
<i class="fas fa-plus me-1"></i>Добавить товар
|
|
</button>
|
|
<button class="btn btn-info" data-bs-toggle="modal" data-bs-target="#addAdModal">
|
|
<i class="fas fa-bullhorn me-1"></i>Добавить объявление
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h5><i class="fas fa-shopping-bag me-2"></i>Товары в магазине</h5>
|
|
<div class="row g-4 mb-4">
|
|
{shop_items_html}
|
|
</div>
|
|
|
|
<h5><i class="fas fa-bullhorn me-2"></i>Объявления</h5>
|
|
<div class="row g-4">
|
|
{ads_html}
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
crypto_tab_content = f'''
|
|
<div class="tab-pane fade" id="crypto">
|
|
<div class="row g-4">
|
|
<div class="col-lg-7">
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body">
|
|
<h4 class="mb-3"><i class="fas fa-coins text-warning me-2"></i>{SERKRIPTO_NAME} ({crypto_market.get("symbol", SERKRIPTO_SYMBOL)})</h4>
|
|
{crypto_flash_html}
|
|
<div class="crypto-price-box mb-3">
|
|
<div class="small text-muted">Текущая цена</div>
|
|
<div class="display-6 fw-bold">{crypto_price:,.4f} ₽</div>
|
|
<div class="small text-muted mt-1">
|
|
Если монету покупают, цена растет. Если покупок нет и идут продажи, цена снижается.
|
|
</div>
|
|
</div>
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-md-6">
|
|
<div class="crypto-stat">
|
|
<div class="text-muted small">У вас монет</div>
|
|
<div class="h5 mb-0">{crypto_user_amount:,.6f} {crypto_market.get("symbol", SERKRIPTO_SYMBOL)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="crypto-stat">
|
|
<div class="text-muted small">Оценка портфеля</div>
|
|
<div class="h5 mb-0">{crypto_portfolio_value:,.2f} ₽</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="small text-muted">
|
|
Автоспад цены без покупок: {decay_rate_pct}% каждые {decay_interval} сек.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-5">
|
|
<div class="card border-0 shadow-sm mb-3">
|
|
<div class="card-body">
|
|
<h5 class="mb-3"><i class="fas fa-arrow-up text-success me-2"></i>Купить {SERKRIPTO_NAME}</h5>
|
|
<form action="/crypto/buy" method="post">
|
|
<label class="form-label">Сумма покупки (₽)</label>
|
|
<div class="input-group mb-2">
|
|
<input type="number" id="cryptoBuyAmount" name="amount" class="form-control" min="1" step="1" max="{max_buy_amount}" data-max-value="{max_buy_amount}" required placeholder="Например: 1000">
|
|
<button type="button" class="btn btn-outline-secondary" id="cryptoBuyMaxBtn">Максимум</button>
|
|
</div>
|
|
<small class="text-muted d-block mb-3">Максимум для покупки: {max_buy_amount:,} ₽</small>
|
|
<button type="submit" class="btn btn-success w-100">
|
|
<i class="fas fa-cart-plus me-1"></i>Купить
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body">
|
|
<h5 class="mb-3"><i class="fas fa-arrow-down text-danger me-2"></i>Продать {SERKRIPTO_NAME}</h5>
|
|
<form action="/crypto/sell" method="post">
|
|
<label class="form-label">Количество монет</label>
|
|
<div class="input-group mb-2">
|
|
<input type="number" id="cryptoSellQuantity" name="quantity" class="form-control" min="0.000001" step="0.000001" max="{max_sell_quantity:.6f}" data-max-value="{max_sell_quantity:.6f}" required placeholder="Например: 0.250000">
|
|
<button type="button" class="btn btn-outline-secondary" id="cryptoSellMaxBtn">Максимум</button>
|
|
</div>
|
|
<small class="text-muted d-block mb-3">Максимум для продажи: {max_sell_quantity:,.6f} {crypto_market.get("symbol", SERKRIPTO_SYMBOL)}</small>
|
|
<button type="submit" class="btn btn-danger w-100">
|
|
<i class="fas fa-money-bill-wave me-1"></i>Продать
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card border-0 shadow-sm mt-4">
|
|
<div class="card-body">
|
|
<h5 class="mb-3"><i class="fas fa-chart-line me-2"></i>Статистика рынка</h5>
|
|
<div class="row g-3">
|
|
<div class="col-md-3">
|
|
<div class="crypto-stat"><div class="text-muted small">Покупок</div><div class="h6 mb-0">{crypto_buy_ops}</div></div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="crypto-stat"><div class="text-muted small">Продаж</div><div class="h6 mb-0">{crypto_sell_ops}</div></div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="crypto-stat"><div class="text-muted small">Куплено монет</div><div class="h6 mb-0">{crypto_buy_volume:,.6f}</div></div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="crypto-stat"><div class="text-muted small">Чистый поток</div><div class="h6 mb-0 {'text-success' if net_flow >= 0 else 'text-danger'}">{net_flow:,.6f}</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
crypto_tab_block = crypto_tab_content if can_trade_crypto else ""
|
|
|
|
crypto_nav_tab = ""
|
|
crypto_service_card = ""
|
|
if can_trade_crypto:
|
|
crypto_nav_tab = '''
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#crypto">
|
|
<i class="fas fa-coins me-1"></i>SerKripto
|
|
</a>
|
|
</li>
|
|
'''
|
|
crypto_service_card = '''
|
|
<div class="service-card">
|
|
<h5><i class="fas fa-coins text-warning me-2"></i>SerKripto</h5>
|
|
<p class="text-muted">Покупки поднимают курс, продажи и отсутствие покупок снижают цену.</p>
|
|
</div>
|
|
'''
|
|
|
|
credit_action_btn = ""
|
|
if can_take_credit:
|
|
credit_action_btn = '''
|
|
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#creditModal">
|
|
<i class="fas fa-money-bill-wave me-1"></i>Взять кредит
|
|
</button>
|
|
'''
|
|
|
|
# HTML для админ-панели
|
|
admin_tab = ""
|
|
if current_role == ROLE_ADMIN:
|
|
admin_tab = '''
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/admin">
|
|
<i class="fas fa-cogs me-2"></i>Админ-панель
|
|
</a>
|
|
</li>
|
|
'''
|
|
|
|
teacher_tab = ""
|
|
if current_role in [ROLE_TEACHER, ROLE_ADMIN]:
|
|
teacher_tab = '''
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/teacher">
|
|
<i class="fas fa-school me-2"></i>Учительская панель
|
|
</a>
|
|
</li>
|
|
'''
|
|
|
|
student_tab = ""
|
|
student_panel_btn = ""
|
|
if current_role == ROLE_STUDENT:
|
|
student_tab = '''
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/student_panel">
|
|
<i class="fas fa-user-graduate me-2"></i>Панель ученика
|
|
</a>
|
|
</li>
|
|
'''
|
|
student_panel_btn = '''
|
|
<a href="/student_panel" class="btn btn-info">
|
|
<i class="fas fa-chart-line me-1"></i>Моя успеваемость
|
|
</a>
|
|
'''
|
|
|
|
parent_tab = ""
|
|
parent_panel_btn = ""
|
|
if current_role == ROLE_PARENT:
|
|
parent_tab = '''
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/parent_panel">
|
|
<i class="fas fa-people-roof me-2"></i>Родительская панель
|
|
</a>
|
|
</li>
|
|
'''
|
|
parent_panel_btn = '''
|
|
<a href="/parent_panel" class="btn btn-secondary">
|
|
<i class="fas fa-children me-1"></i>Мои дети
|
|
</a>
|
|
'''
|
|
|
|
manager_tab = ""
|
|
manager_panel_btn = ""
|
|
if current_role in [ROLE_MANAGER, ROLE_ADMIN]:
|
|
manager_tab = '''
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/manager_panel">
|
|
<i class="fas fa-briefcase me-2"></i>Панель менеджера
|
|
</a>
|
|
</li>
|
|
'''
|
|
manager_panel_btn = '''
|
|
<a href="/manager_panel" class="btn btn-dark">
|
|
<i class="fas fa-chart-pie me-1"></i>Аналитика
|
|
</a>
|
|
'''
|
|
|
|
return f'''
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Банк "Школьный" | Личный кабинет</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
<style>
|
|
:root {{
|
|
--primary: #4361ee;
|
|
--secondary: #3a0ca3;
|
|
--gradient: linear-gradient(135deg, #4361ee 0%, #3a0ca3 100%);
|
|
}}
|
|
|
|
body {{
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: #f5f7fb;
|
|
}}
|
|
|
|
.navbar-custom {{
|
|
background: var(--gradient) !important;
|
|
box-shadow: 0 4px 20px rgba(67, 97, 238, 0.3);
|
|
}}
|
|
|
|
.sidebar {{
|
|
background: white;
|
|
border-radius: 15px;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
|
|
padding: 20px;
|
|
height: fit-content;
|
|
}}
|
|
|
|
.main-content {{
|
|
background: white;
|
|
border-radius: 15px;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
|
|
padding: 25px;
|
|
}}
|
|
|
|
.balance-card {{
|
|
background: var(--gradient);
|
|
color: white;
|
|
border-radius: 15px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
}}
|
|
|
|
.stat-card {{
|
|
background: white;
|
|
border-radius: 15px;
|
|
padding: 15px;
|
|
box-shadow: 0 3px 10px rgba(0,0,0,0.05);
|
|
border-left: 4px solid var(--primary);
|
|
margin-bottom: 15px;
|
|
}}
|
|
|
|
.nav-tabs .nav-link {{
|
|
border: none;
|
|
border-radius: 10px;
|
|
padding: 10px 15px;
|
|
margin: 0 5px;
|
|
color: #666;
|
|
font-weight: 500;
|
|
}}
|
|
|
|
.nav-tabs .nav-link.active {{
|
|
background: var(--gradient);
|
|
color: white;
|
|
}}
|
|
|
|
.table-custom {{
|
|
background: white;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
box-shadow: 0 3px 10px rgba(0,0,0,0.05);
|
|
}}
|
|
|
|
.table-custom thead {{
|
|
background: var(--gradient);
|
|
color: white;
|
|
}}
|
|
|
|
.btn-primary {{
|
|
background: var(--gradient);
|
|
border: none;
|
|
border-radius: 10px;
|
|
padding: 8px 15px;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.btn-primary:hover {{
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 5px 15px rgba(67, 97, 238, 0.3);
|
|
}}
|
|
|
|
.modal-header {{
|
|
background: var(--gradient);
|
|
color: white;
|
|
}}
|
|
|
|
.services-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-top: 20px;
|
|
}}
|
|
|
|
.service-card {{
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
box-shadow: 0 3px 10px rgba(0,0,0,0.05);
|
|
border-left: 4px solid var(--primary);
|
|
transition: all 0.3s;
|
|
}}
|
|
|
|
.service-card:hover {{
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
|
}}
|
|
|
|
.api-docs {{
|
|
background: #f8f9fa;
|
|
border-radius: 10px;
|
|
padding: 20px;
|
|
margin-top: 20px;
|
|
}}
|
|
|
|
.code-block {{
|
|
background: #1e1e1e;
|
|
color: #d4d4d4;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
font-family: "Courier New", monospace;
|
|
overflow-x: auto;
|
|
margin: 10px 0;
|
|
}}
|
|
|
|
.shop-item-card {{
|
|
transition: all 0.3s;
|
|
}}
|
|
|
|
.shop-item-card:hover {{
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
|
}}
|
|
|
|
.crypto-price-box {{
|
|
background: linear-gradient(135deg, #f7fbff 0%, #eef3ff 100%);
|
|
border: 1px solid #d8e4ff;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
}}
|
|
|
|
.crypto-stat {{
|
|
background: #f8faff;
|
|
border: 1px solid #e5edff;
|
|
border-radius: 10px;
|
|
padding: 12px;
|
|
height: 100%;
|
|
}}
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="navbar navbar-expand-lg navbar-custom navbar-dark">
|
|
<div class="container">
|
|
<a class="navbar-brand d-flex align-items-center" href="/dashboard">
|
|
<i class="fas fa-university fa-2x me-2"></i>
|
|
<div>
|
|
<h4 class="mb-0">Банк "Школьный"</h4>
|
|
<small class="opacity-75">Финансовая система в школьных условиях</small>
|
|
</div>
|
|
</a>
|
|
<div class="d-flex align-items-center">
|
|
<span class="badge bg-light text-dark me-3">
|
|
<i class="fas fa-user-circle me-1"></i>
|
|
{session['user']} ({get_user_role(user_data)})
|
|
</span>
|
|
<a href="/logout" class="btn btn-outline-light btn-sm">
|
|
<i class="fas fa-sign-out-alt me-1"></i>Выход
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container mt-4 mb-5">
|
|
<div class="row g-4">
|
|
<div class="col-lg-3">
|
|
<div class="sidebar">
|
|
<h5 class="mb-3"><i class="fas fa-chart-line me-2"></i>Финансы</h5>
|
|
|
|
<div class="balance-card">
|
|
<h6 class="opacity-75">Текущий баланс</h6>
|
|
<h2 class="{'text-success' if user_data['balance'] >= 0 else 'text-danger'}">
|
|
{user_data['balance']:,} ₽
|
|
</h2>
|
|
<small>Доступные средства</small>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<h6>Вклад</h6>
|
|
<h5 class="text-success">{user_data['deposit']:,} ₽</h5>
|
|
</div>
|
|
<i class="fas fa-piggy-bank fa-2x text-success"></i>
|
|
</div>
|
|
<small class="text-muted">+1% ежедневно</small>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<h6>Кредит</h6>
|
|
<h5 class="text-danger">{user_data['credit']:,} ₽</h5>
|
|
</div>
|
|
<i class="fas fa-credit-card fa-2x text-danger"></i>
|
|
</div>
|
|
<small class="text-muted">+5% ежедневно</small>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<h6><i class="fas fa-wallet me-2"></i>Операции</h6>
|
|
<div class="d-grid gap-2 mt-2">
|
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#depositModal">
|
|
<i class="fas fa-plus-circle me-1"></i>Пополнить вклад
|
|
</button>
|
|
{credit_action_btn}
|
|
<button class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#transferModal">
|
|
<i class="fas fa-exchange-alt me-1"></i>Перевод
|
|
</button>
|
|
<a href="/payment_page" class="btn btn-warning">
|
|
<i class="fas fa-credit-card me-1"></i>Оплата
|
|
</a>
|
|
{student_panel_btn}
|
|
{parent_panel_btn}
|
|
{manager_panel_btn}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
{api_key_html}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-9">
|
|
<div class="main-content">
|
|
<ul class="nav nav-tabs mb-4">
|
|
<li class="nav-item">
|
|
<a class="nav-link active" data-bs-toggle="tab" href="#services">
|
|
<i class="fas fa-concierge-bell me-1"></i>Услуги
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#shop">
|
|
<i class="fas fa-store me-1"></i>Магазин
|
|
</a>
|
|
</li>
|
|
{crypto_nav_tab}
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#history">
|
|
<i class="fas fa-history me-1"></i>История
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#api">
|
|
<i class="fas fa-code me-1"></i>API
|
|
</a>
|
|
</li>
|
|
{student_tab}
|
|
{parent_tab}
|
|
{manager_tab}
|
|
{teacher_tab}
|
|
{admin_tab}
|
|
</ul>
|
|
|
|
<div class="tab-content">
|
|
<div class="tab-pane fade show active" id="services">
|
|
<div class="services-grid">
|
|
<div class="service-card">
|
|
<h5><i class="fas fa-piggy-bank text-primary me-2"></i>Вклады</h5>
|
|
<p class="text-muted">Откройте вклад под 1% ежедневно и получайте пассивный доход.</p>
|
|
</div>
|
|
<div class="service-card">
|
|
<h5><i class="fas fa-credit-card text-success me-2"></i>Кредиты</h5>
|
|
<p class="text-muted">Получите кредит на любые цели. Ставка 5% ежедневно.</p>
|
|
</div>
|
|
<div class="service-card">
|
|
<h5><i class="fas fa-exchange-alt text-info me-2"></i>Переводы</h5>
|
|
<p class="text-muted">Быстрые переводы между клиентами банка без комиссий.</p>
|
|
</div>
|
|
<div class="service-card">
|
|
<h5><i class="fas fa-store text-danger me-2"></i>Магазин</h5>
|
|
<p class="text-muted">Покупайте и продавайте товары, размещайте объявления.</p>
|
|
</div>
|
|
<div class="service-card">
|
|
<h5><i class="fas fa-credit-card text-secondary me-2"></i>Платежи</h5>
|
|
<p class="text-muted">Принимайте платежи от клиентов через API или ссылки.</p>
|
|
</div>
|
|
{crypto_service_card}
|
|
<div class="service-card">
|
|
<h5><i class="fas fa-code text-dark me-2"></i>API Доступ</h5>
|
|
<p class="text-muted">Интегрируйте банковские услуги в ваши приложения через API.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{shop_tab_content}
|
|
{crypto_tab_block}
|
|
|
|
<div class="tab-pane fade" id="history">
|
|
<div class="table-responsive">
|
|
<table class="table table-custom table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th><i class="fas fa-clock"></i> Время</th>
|
|
<th><i class="fas fa-user"></i> Пользователь</th>
|
|
<th><i class="fas fa-tasks"></i> Операция</th>
|
|
<th><i class="fas fa-ruble-sign"></i> Сумма</th>
|
|
<th><i class="fas fa-user-friends"></i>Получатель</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{history_html}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-pane fade" id="api">
|
|
<div class="api-docs">
|
|
<h4><i class="fas fa-code me-2"></i>Документация API</h4>
|
|
<p>Используйте ваш API ключ для доступа к банковским операциям через REST API.</p>
|
|
|
|
<h5 class="mt-4">Базовый URL</h5>
|
|
<div class="code-block">
|
|
http://ваш-домен/api/v1/
|
|
</div>
|
|
|
|
<h5 class="mt-4">Аутентификация</h5>
|
|
<p>Добавьте заголовок с вашим API ключом:</p>
|
|
<div class="code-block">
|
|
X-API-Key: ваш_api_ключ
|
|
</div>
|
|
<p>Или передайте как параметр:</p>
|
|
<div class="code-block">
|
|
?api_key=ваш_api_ключ
|
|
</div>
|
|
|
|
<h5 class="mt-4">Доступные методы</h5>
|
|
<div class="table-responsive">
|
|
<table class="table table-bordered">
|
|
<thead>
|
|
<tr>
|
|
<th>Метод</th>
|
|
<th>Эндпоинт</th>
|
|
<th>Описание</th>
|
|
<th>Параметры</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><span class="badge bg-info">GET</span></td>
|
|
<td>/api/v1/balance</td>
|
|
<td>Получить информацию о балансе</td>
|
|
<td>-</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="badge bg-info">GET</span></td>
|
|
<td>/api/v1/history</td>
|
|
<td>Получить историю операций</td>
|
|
<td>limit (опционально)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="badge bg-success">POST</span></td>
|
|
<td>/api/v1/deposit</td>
|
|
<td>Открыть вклад</td>
|
|
<td>amount (int)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="badge bg-success">POST</span></td>
|
|
<td>/api/v1/credit</td>
|
|
<td>Взять кредит</td>
|
|
<td>amount (int)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="badge bg-success">POST</span></td>
|
|
<td>/api/v1/transfer</td>
|
|
<td>Сделать перевод</td>
|
|
<td>target (str), amount (int)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="badge bg-info">GET</span></td>
|
|
<td>/api/v1/shop/items</td>
|
|
<td>Получить список товаров</td>
|
|
<td>-</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="badge bg-info">GET</span></td>
|
|
<td>/api/v1/shop/ads</td>
|
|
<td>Получить список объявлений</td>
|
|
<td>-</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="badge bg-info">GET</span></td>
|
|
<td>/api/v1/crypto</td>
|
|
<td>Текущая цена и кошелек SerKripto</td>
|
|
<td>-</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="badge bg-success">POST</span></td>
|
|
<td>/api/v1/crypto/buy</td>
|
|
<td>Купить SerKripto за рубли</td>
|
|
<td>amount (int)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="badge bg-success">POST</span></td>
|
|
<td>/api/v1/crypto/sell</td>
|
|
<td>Продать SerKripto</td>
|
|
<td>quantity (float)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="badge bg-success">POST</span></td>
|
|
<td>/api/v1/payment/create</td>
|
|
<td>Создать платеж</td>
|
|
<td>receiver, amount, description</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<h5 class="mt-4">Примеры использования</h5>
|
|
<p>Python с requests:</p>
|
|
<div class="code-block">
|
|
import requests<br>
|
|
<br>
|
|
api_key = "ваш_api_ключ"<br>
|
|
base_url = "http://ваш-домен/api/v1"<br>
|
|
<br>
|
|
# Получить баланс<br>
|
|
headers = {{"X-API-Key": api_key}}<br>
|
|
response = requests.get(f"{{base_url}}/balance", headers=headers)<br>
|
|
print(response.json())<br>
|
|
<br>
|
|
# Сделать перевод<br>
|
|
data = {{"target": "получатель", "amount": 100}}<br>
|
|
response = requests.post(f"{{base_url}}/transfer", headers=headers, json=data)<br>
|
|
print(response.json())
|
|
</div>
|
|
|
|
<p>cURL:</p>
|
|
<div class="code-block">
|
|
# Получить баланс<br>
|
|
curl -H "X-API-Key: ваш_api_ключ" http://ваш-домен/api/v1/balance<br>
|
|
<br>
|
|
# Сделать перевод<br>
|
|
curl -X POST -H "X-API-Key: ваш_api_ключ" \\<br>
|
|
-H "Content-Type: application/json" \\<br>
|
|
-d '{{"target": "получатель", "amount": 100}}' \\<br>
|
|
http://ваш-домен/api/v1/transfer
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Модальные окна -->
|
|
<div class="modal fade" id="depositModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="fas fa-piggy-bank me-2"></i>Вклад</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form action="/deposit" method="post">
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Сумма вклада (₽)</label>
|
|
<input type="number" name="amount" class="form-control" min="1" required>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
|
<button type="submit" class="btn btn-primary">Подтвердить</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="creditModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="fas fa-credit-card me-2"></i>Кредит</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form action="/credit" method="post">
|
|
<div class="modal-body">
|
|
<div class="alert alert-warning">
|
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
|
Кредит выдается под 5% ежедневно
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Сумма кредита (₽)</label>
|
|
<input type="number" name="amount" class="form-control" min="1" required>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
|
<button type="submit" class="btn btn-primary">Взять кредит</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="transferModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="fas fa-exchange-alt me-2"></i>Перевод</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form action="/transfer" method="post">
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Получатель</label>
|
|
<input type="text" name="target" class="form-control" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Сумма (₽)</label>
|
|
<input type="number" name="amount" class="form-control" min="1" required>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
|
<button type="submit" class="btn btn-primary">Отправить</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Модальные окна для магазина -->
|
|
<div class="modal fade" id="addItemModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="fas fa-plus me-2"></i>Добавить товар</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form action="/create_shop_item" method="post">
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Название товара</label>
|
|
<input type="text" name="title" class="form-control" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Описание</label>
|
|
<textarea name="description" class="form-control" rows="3" required></textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Цена (₽)</label>
|
|
<input type="number" name="price" class="form-control" min="1" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Категория</label>
|
|
<select name="category" class="form-select">
|
|
<option value="electronics">Электроника</option>
|
|
<option value="clothing">Одежда</option>
|
|
<option value="books">Книги</option>
|
|
<option value="other">Другое</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
|
<button type="submit" class="btn btn-primary">Добавить товар</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="addAdModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="fas fa-bullhorn me-2"></i>Добавить объявление</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form action="/create_advertisement" method="post">
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Заголовок</label>
|
|
<input type="text" name="title" class="form-control" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Содержание</label>
|
|
<textarea name="content" class="form-control" rows="3" required></textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Контактная информация</label>
|
|
<input type="text" name="contact_info" class="form-control" placeholder="Email или телефон" required>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
|
<button type="submit" class="btn btn-primary">Добавить объявление</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
// Автообновление каждые 60 секунд
|
|
setInterval(() => {{
|
|
window.location.reload();
|
|
}}, 60000);
|
|
|
|
// Восстановление активной вкладки
|
|
(() => {{
|
|
const preferredTab = "{active_tab}";
|
|
const hashTab = window.location.hash ? window.location.hash.replace("#", "") : "";
|
|
const tabToOpen = hashTab || preferredTab;
|
|
if (!tabToOpen || !window.bootstrap) return;
|
|
const trigger = document.querySelector(`a[data-bs-toggle="tab"][href="#${{tabToOpen}}"]`);
|
|
if (trigger) {{
|
|
new bootstrap.Tab(trigger).show();
|
|
}}
|
|
}})();
|
|
|
|
// Кнопки "Максимум" для покупки/продажи SerKripto
|
|
(() => {{
|
|
const bindMaxButton = (inputId, buttonId, emptyText) => {{
|
|
const input = document.getElementById(inputId);
|
|
const button = document.getElementById(buttonId);
|
|
if (!input || !button) return;
|
|
button.addEventListener("click", () => {{
|
|
const maxValue = parseFloat(input.getAttribute("data-max-value") || "0");
|
|
if (!Number.isFinite(maxValue) || maxValue <= 0) {{
|
|
alert(emptyText);
|
|
return;
|
|
}}
|
|
input.value = input.step && input.step.includes(".") ? maxValue.toFixed(6) : Math.floor(maxValue);
|
|
input.dispatchEvent(new Event("input", {{ bubbles: true }}));
|
|
}});
|
|
}};
|
|
|
|
bindMaxButton("cryptoBuyAmount", "cryptoBuyMaxBtn", "Недостаточно средств для покупки");
|
|
bindMaxButton("cryptoSellQuantity", "cryptoSellMaxBtn", "Нет монет для продажи");
|
|
}})();
|
|
|
|
// Валидация форм
|
|
document.querySelectorAll("form").forEach(form => {{
|
|
form.addEventListener("submit", function(e) {{
|
|
const inputs = this.querySelectorAll("input[type=\"number\"]");
|
|
inputs.forEach(input => {{
|
|
if (input.value && parseFloat(input.value) <= 0) {{
|
|
e.preventDefault();
|
|
alert("Пожалуйста, введите корректное значение");
|
|
}}
|
|
}});
|
|
}});
|
|
}});
|
|
|
|
// Анимация карт
|
|
// Копирование API ключа
|
|
function copyApiKey() {{
|
|
const apiKeyInput = document.getElementById("apiKey");
|
|
if (!apiKeyInput) return;
|
|
apiKeyInput.select();
|
|
apiKeyInput.setSelectionRange(0, 99999);
|
|
document.execCommand("copy");
|
|
alert("API ключ скопирован в буфер обмена!");
|
|
}}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
def render_admin_panel(users):
|
|
"""Генерация HTML админ-панели - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
users_html = ""
|
|
current_user = session.get('user')
|
|
|
|
valid_users_count = 0
|
|
total_balance = 0
|
|
total_deposit = 0
|
|
total_credit = 0
|
|
|
|
for username, user in users.items():
|
|
# Пропускаем текущего пользователя
|
|
if username == current_user:
|
|
continue
|
|
|
|
# Проверяем, что пользователь - словарь (корректные данные)
|
|
if not isinstance(user, dict):
|
|
continue
|
|
|
|
# Безопасно получаем данные пользователя
|
|
role = get_user_role(user)
|
|
balance = user.get('balance', 0)
|
|
deposit = user.get('deposit', 0)
|
|
credit = user.get('credit', 0)
|
|
email = user.get('email', 'нет')
|
|
|
|
# Обновляем статистику
|
|
valid_users_count += 1
|
|
total_balance += balance
|
|
total_deposit += deposit
|
|
total_credit += credit
|
|
|
|
# Определяем цвет бейджа роли
|
|
badge_color_map = {
|
|
ROLE_ADMIN: "danger",
|
|
ROLE_MANAGER: "warning",
|
|
ROLE_TEACHER: "info",
|
|
ROLE_PARENT: "primary",
|
|
ROLE_STUDENT: "secondary",
|
|
ROLE_CLIENT: "dark"
|
|
}
|
|
badge_color = badge_color_map.get(role, "dark")
|
|
|
|
users_html += f'''
|
|
<div class="col-md-6 col-lg-4">
|
|
<div class="card h-100">
|
|
<div class="card-body">
|
|
<h5 class="card-title">{username}</h5>
|
|
<p class="card-text">
|
|
<small>Роль: <span class="badge bg-{badge_color}">{role}</span></small><br>
|
|
<small>Баланс: <strong>{balance:,} ₽</strong></small><br>
|
|
<small>Вклад: <strong>{deposit:,} ₽</strong></small><br>
|
|
<small>Кредит: <strong>{credit:,} ₽</strong></small><br>
|
|
<small>Email: {email}</small>
|
|
</p>
|
|
<form action="/admin/change_role" method="post" class="mb-2">
|
|
<input type="hidden" name="username" value="{username}">
|
|
<select name="role" class="form-select form-select-sm mb-2">
|
|
<option value="{ROLE_CLIENT}" {'selected' if role == ROLE_CLIENT else ''}>Клиент</option>
|
|
<option value="{ROLE_STUDENT}" {'selected' if role == ROLE_STUDENT else ''}>Ученик</option>
|
|
<option value="{ROLE_PARENT}" {'selected' if role == ROLE_PARENT else ''}>Родитель</option>
|
|
<option value="{ROLE_TEACHER}" {'selected' if role == ROLE_TEACHER else ''}>Учитель</option>
|
|
<option value="{ROLE_MANAGER}" {'selected' if role == ROLE_MANAGER else ''}>Менеджер</option>
|
|
<option value="{ROLE_ADMIN}" {'selected' if role == ROLE_ADMIN else ''}>Админ</option>
|
|
</select>
|
|
<button type="submit" class="btn btn-warning btn-sm w-100">Изменить роль</button>
|
|
</form>
|
|
<form action="/admin/add_money" method="post">
|
|
<input type="hidden" name="username" value="{username}">
|
|
<div class="input-group input-group-sm mb-2">
|
|
<input type="number" name="amount" class="form-control" placeholder="Сумма" min="1" required>
|
|
<button class="btn btn-success" type="submit">+</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
return f'''
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Админ-панель</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
<style>
|
|
:root {{
|
|
--primary: #4361ee;
|
|
--gradient: linear-gradient(135deg, #4361ee 0%, #3a0ca3 100%);
|
|
}}
|
|
body {{
|
|
background: #f5f7fb;
|
|
padding: 20px;
|
|
}}
|
|
.admin-header {{
|
|
background: var(--gradient);
|
|
color: white;
|
|
border-radius: 15px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
}}
|
|
.card {{
|
|
border-radius: 12px;
|
|
box-shadow: 0 3px 10px rgba(0,0,0,0.05);
|
|
}}
|
|
.stat-card {{
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 15px;
|
|
box-shadow: 0 3px 10px rgba(0,0,0,0.05);
|
|
border-left: 4px solid var(--primary);
|
|
margin-bottom: 15px;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="admin-header">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h2><i class="fas fa-cogs me-2"></i>Админ-панель</h2>
|
|
<p class="mb-0">Управление пользователями</p>
|
|
</div>
|
|
<a href="/dashboard" class="btn btn-light">
|
|
<i class="fas fa-arrow-left me-1"></i>Назад
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-4">
|
|
{users_html if users_html else '''
|
|
<div class="col-12">
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-info-circle me-2"></i>
|
|
Нет других пользователей для управления.
|
|
</div>
|
|
</div>
|
|
'''}
|
|
</div>
|
|
|
|
<div class="row mt-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title"><i class="fas fa-chart-bar me-2"></i>Статистика системы</h5>
|
|
<div class="row">
|
|
<div class="col-md-3">
|
|
<div class="stat-card">
|
|
<h6>Всего пользователей</h6>
|
|
<h4>{valid_users_count + 1}</h4>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="stat-card">
|
|
<h6>Общий баланс</h6>
|
|
<h4 class="text-success">{total_balance:,} ₽</h4>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="stat-card">
|
|
<h6>Общие вклады</h6>
|
|
<h4 class="text-info">{total_deposit:,} ₽</h4>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="stat-card">
|
|
<h6>Общие кредиты</h6>
|
|
<h4 class="text-danger">{total_credit:,} ₽</h4>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
// Валидация форм админ-панели
|
|
document.querySelectorAll("form").forEach(form => {{
|
|
form.addEventListener("submit", function(e) {{
|
|
const amountInput = this.querySelector('input[type="number"]');
|
|
if (amountInput && amountInput.value) {{
|
|
if (parseInt(amountInput.value) <= 0) {{
|
|
e.preventDefault();
|
|
alert("Сумма должна быть положительной!");
|
|
}}
|
|
}}
|
|
}});
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
# ---------- СТРАНИЦА ОПЛАТЫ ----------
|
|
def render_payment_page(created_payment=None, error_message=""):
|
|
"""Страница оплаты"""
|
|
users = load_users()
|
|
current_user = session.get("user")
|
|
user_data = users.get(current_user, {}) if isinstance(users.get(current_user), dict) else {}
|
|
api_key = user_data.get("api_key")
|
|
|
|
payment_rows = ""
|
|
payments = load_payments()
|
|
user_payments = []
|
|
for payment in payments:
|
|
if payment.get("created_by") == current_user or payment.get("payer") == current_user or payment.get("receiver") == current_user:
|
|
user_payments.append(payment)
|
|
|
|
user_payments.sort(key=lambda x: x.get("created", 0), reverse=True)
|
|
for payment in user_payments[:20]:
|
|
status = payment.get("status", "pending")
|
|
status_class = "warning"
|
|
if status == "completed":
|
|
status_class = "success"
|
|
elif status == "failed":
|
|
status_class = "danger"
|
|
elif status == "cancelled":
|
|
status_class = "secondary"
|
|
|
|
payer_label = payment.get("payer", "-")
|
|
if payment.get("allow_any_payer") and status == "pending":
|
|
payer_label = "любой"
|
|
|
|
payment_rows += f"""
|
|
<tr>
|
|
<td><small>{payment.get('id', '-')}</small></td>
|
|
<td><strong>{payment.get('amount', 0)} ₽</strong></td>
|
|
<td>{payment.get('receiver', '-')}</td>
|
|
<td>{payer_label}</td>
|
|
<td><span class="badge bg-{status_class}">{status}</span></td>
|
|
<td><small>{datetime.fromtimestamp(payment.get('created', 0)).strftime('%Y-%m-%d %H:%M:%S')}</small></td>
|
|
<td><a class="btn btn-sm btn-outline-primary" href="/pay/{payment.get('id', '')}" target="_blank">Открыть</a></td>
|
|
</tr>
|
|
"""
|
|
|
|
if not payment_rows:
|
|
payment_rows = "<tr><td colspan='7' class='text-center text-muted'>Пока нет платежей</td></tr>"
|
|
|
|
created_block = ""
|
|
if created_payment and isinstance(created_payment, dict):
|
|
payment_url = f"{request.host_url.rstrip('/')}/pay/{created_payment.get('id')}"
|
|
created_block = f"""
|
|
<div class="alert alert-success mt-3">
|
|
<h6><i class="fas fa-check-circle me-2"></i>Ссылка создана</h6>
|
|
<p class="mb-1">ID платежа: <strong>{created_payment.get('id')}</strong></p>
|
|
<div class="payment-link" id="generatedPaymentLink">{payment_url}</div>
|
|
<button class="btn btn-sm btn-outline-success" onclick="copyPaymentLink()">
|
|
<i class="fas fa-copy me-1"></i>Скопировать ссылку
|
|
</button>
|
|
</div>
|
|
"""
|
|
|
|
api_key_hint = ""
|
|
if api_key:
|
|
api_key_hint = f"""
|
|
<div class="alert alert-success">
|
|
<small><strong>Ваш API ключ:</strong> <code>{api_key}</code></small>
|
|
</div>
|
|
"""
|
|
else:
|
|
api_key_hint = """
|
|
<div class="alert alert-warning">
|
|
<small>API ключ не создан. Сначала сгенерируйте ключ в личном кабинете.</small>
|
|
</div>
|
|
"""
|
|
|
|
error_block = ""
|
|
if error_message:
|
|
error_block = f'<div class="alert alert-danger mb-3">{error_message}</div>'
|
|
|
|
return f'''
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Оплата | Банковская система</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
<style>
|
|
:root {{
|
|
--primary: #4361ee;
|
|
--secondary: #3a0ca3;
|
|
--gradient: linear-gradient(135deg, #4361ee 0%, #3a0ca3 100%);
|
|
}}
|
|
body {{
|
|
background: #f5f7fb;
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
}}
|
|
.payment-card {{
|
|
background: white;
|
|
border-radius: 15px;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
}}
|
|
.payment-header {{
|
|
background: var(--gradient);
|
|
color: white;
|
|
padding: 25px;
|
|
text-align: center;
|
|
}}
|
|
.payment-body {{
|
|
padding: 30px;
|
|
}}
|
|
.form-control {{
|
|
border: 2px solid #e9ecef;
|
|
border-radius: 10px;
|
|
padding: 12px 15px;
|
|
transition: all 0.3s;
|
|
}}
|
|
.form-control:focus {{
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25);
|
|
}}
|
|
.btn-primary {{
|
|
background: var(--gradient);
|
|
border: none;
|
|
border-radius: 10px;
|
|
padding: 12px;
|
|
font-weight: 600;
|
|
transition: all 0.3s;
|
|
}}
|
|
.btn-primary:hover {{
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 20px rgba(67, 97, 238, 0.3);
|
|
}}
|
|
.api-example {{
|
|
background: #f8f9fa;
|
|
border-radius: 10px;
|
|
padding: 15px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 14px;
|
|
margin: 10px 0;
|
|
}}
|
|
.payment-link {{
|
|
background: #e9ecef;
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
word-break: break-all;
|
|
font-family: 'Courier New', monospace;
|
|
margin: 10px 0;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="navbar navbar-expand-lg navbar-dark" style="background: var(--gradient);">
|
|
<div class="container">
|
|
<a class="navbar-brand" href="/dashboard">
|
|
<i class="fas fa-university me-2"></i>
|
|
Банк | Оплата
|
|
</a>
|
|
<div>
|
|
<a href="/dashboard" class="btn btn-outline-light btn-sm">
|
|
<i class="fas fa-arrow-left me-1"></i> Назад
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container mt-4 mb-5">
|
|
<div class="row justify-content-center">
|
|
<div class="col-lg-10">
|
|
<div class="payment-card">
|
|
<div class="payment-header">
|
|
<h3><i class="fas fa-credit-card me-2"></i>Платежная система</h3>
|
|
<p class="mb-0">Создавайте ссылки и принимайте оплату</p>
|
|
</div>
|
|
<div class="payment-body">
|
|
{error_block}
|
|
{created_block}
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<h4><i class="fas fa-link me-2"></i>Создать ссылку для оплаты</h4>
|
|
<form action="/create_payment_link" method="post" id="paymentForm">
|
|
<div class="mb-3">
|
|
<label class="form-label">Получатель (логин)</label>
|
|
<input type="text" name="receiver" class="form-control" required value="{current_user or ''}">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Плательщик (логин, необязательно)</label>
|
|
<input type="text" name="payer" class="form-control" placeholder="Пусто = оплатить сможет любой пользователь">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Сумма (₽)</label>
|
|
<input type="number" name="amount" class="form-control" min="1" required placeholder="Введите сумму">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Описание платежа</label>
|
|
<input type="text" name="description" class="form-control" placeholder="Оплата за товар/услугу">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary w-100">
|
|
<i class="fas fa-link me-2"></i>Создать ссылку
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<h4><i class="fas fa-code me-2"></i>API для оплаты</h4>
|
|
{api_key_hint}
|
|
<div class="api-example">
|
|
POST /api/v1/payment/create<br>
|
|
Header: X-API-Key: ваш_ключ<br>
|
|
Body: {{"receiver":"{current_user or 'username'}","amount":100,"description":"Оплата","allow_any_payer":true}}
|
|
</div>
|
|
<div class="api-example">
|
|
GET /api/v1/payment/status/payment_id<br>
|
|
Header: X-API-Key: ваш_ключ
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-5">
|
|
<h4><i class="fas fa-history me-2"></i>Последние платежи</h4>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Сумма</th>
|
|
<th>Получатель</th>
|
|
<th>Плательщик</th>
|
|
<th>Статус</th>
|
|
<th>Дата</th>
|
|
<th>Ссылка</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{payment_rows}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
function copyPaymentLink() {{
|
|
const linkElement = document.getElementById('generatedPaymentLink');
|
|
if (!linkElement) return;
|
|
const text = linkElement.textContent.trim();
|
|
if (navigator.clipboard) {{
|
|
navigator.clipboard.writeText(text);
|
|
}}
|
|
}}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
def render_pay_page(payment_id):
|
|
"""Страница оплаты для клиента"""
|
|
payments = load_payments()
|
|
error_code = request.args.get("error", "").strip()
|
|
error_map = {
|
|
"invalid_credentials": "Неверный логин или пароль",
|
|
"payment_not_found": "Платеж не найден",
|
|
"wrong_payer": "Вы не являетесь плательщиком по этому платежу",
|
|
"insufficient_funds": "Недостаточно средств на счете плательщика",
|
|
"payer_not_found": "Плательщик не найден",
|
|
"receiver_not_found": "Получатель не найден",
|
|
"role_denied": "Роль пользователя не имеет доступа к оплате",
|
|
"same_user": "Нельзя оплатить самому себе",
|
|
"payment_failed": "Ошибка при обработке платежа"
|
|
}
|
|
error_message = error_map.get(error_code, "")
|
|
|
|
payment = None
|
|
for p in payments:
|
|
if p["id"] == payment_id:
|
|
payment = p
|
|
break
|
|
|
|
if not payment:
|
|
return '''
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Платеж не найден</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
</head>
|
|
<body>
|
|
<div class="container mt-5">
|
|
<div class="alert alert-danger">
|
|
<h4>Платеж не найден</h4>
|
|
<p>Проверьте правильность ссылки или обратитесь к отправителю.</p>
|
|
<a href="/" class="btn btn-primary">На главную</a>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
if payment["status"] != "pending":
|
|
status_message = {
|
|
"completed": "Оплачен",
|
|
"failed": "Не удался",
|
|
"cancelled": "Отменен"
|
|
}.get(payment["status"], "Неизвестен")
|
|
|
|
transfer_block = ""
|
|
balances = payment.get("balances")
|
|
if payment["status"] == "completed" and isinstance(balances, dict):
|
|
transfer_block = f"""
|
|
<div class="alert alert-success text-start mt-3">
|
|
<h6 class="mb-2"><i class="fas fa-check me-2"></i>Перевод выполнен</h6>
|
|
<div>Плательщик: <strong>{payment.get('payer', '-')}</strong> | баланс: <strong>{balances.get('payer_before', 0)} ₽</strong> → <strong>{balances.get('payer_after', 0)} ₽</strong></div>
|
|
<div>Баланс получателя скрыт</div>
|
|
</div>
|
|
"""
|
|
|
|
return f'''
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Статус платежа</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
</head>
|
|
<body>
|
|
<div class="container mt-5">
|
|
<div class="row justify-content-center">
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-body text-center">
|
|
<h2>Платеж {status_message}</h2>
|
|
<div class="my-4">
|
|
<i class="fas fa-{ 'check-circle text-success' if payment['status'] == 'completed' else 'times-circle text-danger' } fa-5x"></i>
|
|
</div>
|
|
<p>ID платежа: <strong>{payment_id}</strong></p>
|
|
<p>Сумма: <strong>{payment['amount']} ₽</strong></p>
|
|
<p>Получатель: <strong>{payment['receiver']}</strong></p>
|
|
<p>Плательщик: <strong>{payment.get('payer', '-')}</strong></p>
|
|
<p>Описание: {payment['description']}</p>
|
|
{transfer_block}
|
|
<a href="/" class="btn btn-primary mt-3">На главную</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
allow_any_payer = bool(payment.get("allow_any_payer", False))
|
|
payer_label = "Любой пользователь" if allow_any_payer else payment.get("payer", "-")
|
|
username_value = "" if allow_any_payer else payment.get("payer", "")
|
|
username_readonly = "" if allow_any_payer else "readonly"
|
|
error_block = f'<div class="alert alert-danger mb-3">{error_message}</div>' if error_message else ""
|
|
|
|
return f'''
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Оплата #{payment_id}</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
<style>
|
|
:root {{
|
|
--primary: #4361ee;
|
|
--gradient: linear-gradient(135deg, #4361ee 0%, #3a0ca3 100%);
|
|
}}
|
|
body {{
|
|
background: #f5f7fb;
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
}}
|
|
.payment-container {{
|
|
max-width: 500px;
|
|
margin: 50px auto;
|
|
}}
|
|
.payment-card {{
|
|
background: white;
|
|
border-radius: 15px;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
}}
|
|
.payment-header {{
|
|
background: var(--gradient);
|
|
color: white;
|
|
padding: 25px;
|
|
text-align: center;
|
|
}}
|
|
.payment-body {{
|
|
padding: 30px;
|
|
}}
|
|
.amount-display {{
|
|
font-size: 48px;
|
|
font-weight: bold;
|
|
color: var(--primary);
|
|
text-align: center;
|
|
margin: 20px 0;
|
|
}}
|
|
.btn-pay {{
|
|
background: var(--gradient);
|
|
border: none;
|
|
border-radius: 10px;
|
|
padding: 15px;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
width: 100%;
|
|
margin-top: 20px;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="payment-container">
|
|
<div class="payment-card">
|
|
<div class="payment-header">
|
|
<h3><i class="fas fa-credit-card me-2"></i>Оплата</h3>
|
|
<p class="mb-0">ID: {payment_id}</p>
|
|
</div>
|
|
<div class="payment-body">
|
|
{error_block}
|
|
<div class="text-center mb-4">
|
|
<i class="fas fa-shopping-cart fa-4x text-primary"></i>
|
|
</div>
|
|
|
|
<div class="amount-display">
|
|
{payment['amount']} ₽
|
|
</div>
|
|
|
|
<div class="payment-details">
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span>Получатель:</span>
|
|
<strong>{payment['receiver']}</strong>
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span>Плательщик:</span>
|
|
<strong>{payer_label}</strong>
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span>Описание:</span>
|
|
<strong>{payment['description'] or 'Нет описания'}</strong>
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-4">
|
|
<span>Статус:</span>
|
|
<span class="badge bg-warning">Ожидает оплаты</span>
|
|
</div>
|
|
</div>
|
|
<div class="alert alert-secondary small">
|
|
Перед списанием система проверит баланс аккаунта плательщика. Если денег недостаточно, платеж не пройдет.
|
|
</div>
|
|
|
|
<form action="/process_payment/{payment_id}" method="post" id="paymentForm">
|
|
<div class="mb-3">
|
|
<label class="form-label">Ваш логин</label>
|
|
<input type="text" name="username" class="form-control" placeholder="Введите ваш логин" required value="{username_value}" {username_readonly}>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Пароль</label>
|
|
<input type="password" name="password" class="form-control" placeholder="Введите пароль" required>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-pay">
|
|
<i class="fas fa-lock me-2"></i>Оплатить {payment['amount']} ₽
|
|
</button>
|
|
|
|
<div class="text-center mt-3">
|
|
<small class="text-muted">После оплаты средства будут переведены получателю</small>
|
|
</div>
|
|
</form>
|
|
|
|
<div class="alert alert-info mt-4">
|
|
<h6><i class="fas fa-info-circle me-2"></i>Безопасная оплата</h6>
|
|
<p class="mb-0 small">Все платежи защищены банковской системой. Для оплаты требуется авторизация.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
document.getElementById('paymentForm').addEventListener('submit', function(e) {{
|
|
const submitBtn = this.querySelector('button[type="submit"]');
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Обработка...';
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
# ---------- ИСПРАВЛЕННЫЕ API МАРШРУТЫ ----------
|
|
|
|
@app.route('/api/v1/balance', methods=['GET'])
|
|
@api_key_required
|
|
@api_roles_required(*FINANCE_ROLES)
|
|
def api_balance():
|
|
"""API: Получить информацию о балансе - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
try:
|
|
user_data = request.user_data
|
|
market = load_crypto_market()
|
|
market, decayed = apply_serkripto_idle_decay(market)
|
|
if decayed:
|
|
save_crypto_market(market)
|
|
ws_emit_crypto_market_event(market, "idle_decay")
|
|
serkripto_qty = get_user_serkripto_amount(user_data)
|
|
serkripto_value = round(serkripto_qty * float(market.get("price", 0.0)), 2)
|
|
return jsonify({
|
|
'success': True,
|
|
'username': request.username,
|
|
'balance': user_data.get('balance', 0),
|
|
'deposit': user_data.get('deposit', 0),
|
|
'credit': user_data.get('credit', 0),
|
|
'serkripto': {
|
|
'coin': market.get('name', SERKRIPTO_NAME),
|
|
'symbol': market.get('symbol', SERKRIPTO_SYMBOL),
|
|
'quantity': serkripto_qty,
|
|
'price_rub': market.get('price', 0),
|
|
'value_rub': serkripto_value
|
|
},
|
|
'total': user_data.get('balance', 0) + user_data.get('deposit', 0) - user_data.get('credit', 0)
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/history', methods=['GET'])
|
|
@api_key_required
|
|
@api_roles_required(*FINANCE_ROLES)
|
|
def api_history():
|
|
"""API: Получить историю операций - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
try:
|
|
history = load_history()
|
|
user_history = [h for h in history if h['user'] == request.username]
|
|
|
|
limit = request.args.get('limit', type=int, default=50)
|
|
if limit > 0 and limit < len(user_history):
|
|
user_history = user_history[-limit:]
|
|
|
|
for h in user_history:
|
|
h['time_formatted'] = datetime.fromtimestamp(h['time']).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'count': len(user_history),
|
|
'history': user_history[::-1]
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/deposit', methods=['POST'])
|
|
@api_key_required
|
|
@api_roles_required(*FINANCE_ROLES)
|
|
def api_deposit():
|
|
"""API: Открыть вклад - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
try:
|
|
if not request.is_json:
|
|
return jsonify({'success': False, 'error': 'JSON data required'}), 400
|
|
|
|
data = request.get_json()
|
|
amount = data.get('amount')
|
|
|
|
if not amount:
|
|
return jsonify({'success': False, 'error': 'Amount required'}), 400
|
|
|
|
amount = int(amount)
|
|
if amount <= 0:
|
|
return jsonify({'success': False, 'error': 'Amount must be positive'}), 400
|
|
|
|
users = load_users()
|
|
user = users.get(request.username)
|
|
|
|
if not user or user['balance'] < amount:
|
|
return jsonify({'success': False, 'error': 'Insufficient funds'}), 400
|
|
|
|
user['balance'] -= amount
|
|
user['deposit'] += amount
|
|
save_users(users)
|
|
|
|
add_history(request.username, 'api_deposit', amount)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Deposit of {amount} ₽ created successfully',
|
|
'new_balance': user['balance'],
|
|
'new_deposit': user['deposit']
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/credit', methods=['POST'])
|
|
@api_key_required
|
|
@api_roles_required(*CREDIT_ROLES)
|
|
def api_credit():
|
|
"""API: Взять кредит - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
try:
|
|
if not request.is_json:
|
|
return jsonify({'success': False, 'error': 'JSON data required'}), 400
|
|
|
|
data = request.get_json()
|
|
amount = data.get('amount')
|
|
|
|
if not amount:
|
|
return jsonify({'success': False, 'error': 'Amount required'}), 400
|
|
|
|
amount = int(amount)
|
|
if amount <= 0:
|
|
return jsonify({'success': False, 'error': 'Amount must be positive'}), 400
|
|
|
|
users = load_users()
|
|
user = users.get(request.username)
|
|
|
|
if not user:
|
|
return jsonify({'success': False, 'error': 'User not found'}), 404
|
|
|
|
user['balance'] += amount
|
|
user['credit'] += amount
|
|
save_users(users)
|
|
|
|
add_history(request.username, 'api_credit', amount)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Credit of {amount} ₽ taken successfully',
|
|
'new_balance': user['balance'],
|
|
'new_credit': user['credit']
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/transfer', methods=['POST'])
|
|
@api_key_required
|
|
@api_roles_required(*FINANCE_ROLES)
|
|
def api_transfer():
|
|
"""API: Сделать перевод - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
try:
|
|
if not request.is_json:
|
|
return jsonify({'success': False, 'error': 'JSON data required'}), 400
|
|
|
|
data = request.get_json()
|
|
target = data.get('target', '').strip()
|
|
amount = data.get('amount')
|
|
|
|
if not target:
|
|
return jsonify({'success': False, 'error': 'Target username required'}), 400
|
|
|
|
if not amount:
|
|
return jsonify({'success': False, 'error': 'Amount required'}), 400
|
|
|
|
amount = int(amount)
|
|
if amount <= 0:
|
|
return jsonify({'success': False, 'error': 'Amount must be positive'}), 400
|
|
|
|
users = load_users()
|
|
|
|
if target not in users:
|
|
return jsonify({'success': False, 'error': 'Target user not found'}), 404
|
|
|
|
if target == request.username:
|
|
return jsonify({'success': False, 'error': 'Cannot transfer to yourself'}), 400
|
|
|
|
sender = users.get(request.username)
|
|
if not sender:
|
|
return jsonify({'success': False, 'error': 'Sender not found'}), 404
|
|
|
|
if sender['balance'] < amount:
|
|
return jsonify({'success': False, 'error': 'Insufficient funds'}), 400
|
|
|
|
sender['balance'] -= amount
|
|
users[target]['balance'] += amount
|
|
save_users(users)
|
|
|
|
add_history(request.username, 'api_transfer', amount, target)
|
|
add_history(target, 'transfer_received', amount, request.username)
|
|
ws_emit_balance_update(target)
|
|
ws_emit_user(target, "transfer.received", {
|
|
"from": request.username,
|
|
"amount": amount
|
|
})
|
|
ws_emit_system("transfer.completed", {
|
|
"from": request.username,
|
|
"to": target,
|
|
"amount": amount,
|
|
"source": "api"
|
|
})
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Transfer of {amount} ₽ to {target} successful',
|
|
'new_balance': sender['balance']
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ---------- API ДЛЯ ПЛАТЕЖЕЙ ----------
|
|
|
|
@app.route('/api/v1/payment/create', methods=['POST'])
|
|
@api_key_required
|
|
@api_roles_required(*FINANCE_ROLES)
|
|
def api_payment_create():
|
|
"""API: Создать платеж"""
|
|
try:
|
|
if not request.is_json:
|
|
return jsonify({'success': False, 'error': 'JSON data required'}), 400
|
|
|
|
data = request.get_json()
|
|
receiver = str(data.get('receiver', '')).strip()
|
|
payer_from_request = str(data.get('payer', '')).strip()
|
|
allow_any_payer = bool(data.get('allow_any_payer', False))
|
|
amount = data.get('amount')
|
|
description = data.get('description', '')
|
|
external_id = data.get('external_id', '')
|
|
|
|
if not receiver:
|
|
return jsonify({'success': False, 'error': 'Receiver required'}), 400
|
|
|
|
if not amount:
|
|
return jsonify({'success': False, 'error': 'Amount required'}), 400
|
|
|
|
try:
|
|
amount_int = int(amount)
|
|
if amount_int <= 0:
|
|
return jsonify({'success': False, 'error': 'Amount must be positive'}), 400
|
|
except:
|
|
return jsonify({'success': False, 'error': 'Invalid amount'}), 400
|
|
|
|
# Проверяем существование получателя
|
|
users = load_users()
|
|
if receiver not in users:
|
|
return jsonify({'success': False, 'error': 'Receiver not found'}), 404
|
|
|
|
payer = request.username
|
|
if payer_from_request:
|
|
if payer_from_request in ["*", "any", "ANY"]:
|
|
allow_any_payer = True
|
|
payer = "*"
|
|
else:
|
|
payer = payer_from_request
|
|
allow_any_payer = False
|
|
elif allow_any_payer:
|
|
payer = "*"
|
|
|
|
if not allow_any_payer:
|
|
if payer not in users:
|
|
return jsonify({'success': False, 'error': 'Payer not found'}), 404
|
|
if payer == receiver:
|
|
return jsonify({'success': False, 'error': 'Payer and receiver must be different'}), 400
|
|
|
|
# Создаем платеж
|
|
payment_id, payment_data = create_payment(
|
|
payer=payer,
|
|
receiver=receiver,
|
|
amount=amount_int,
|
|
description=description,
|
|
external_id=external_id,
|
|
allow_any_payer=allow_any_payer,
|
|
created_by=request.username
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Payment created successfully',
|
|
'payment_id': payment_id,
|
|
'payment': payment_data,
|
|
'payment_url': f'{request.host_url.rstrip("/")}/pay/{payment_id}'
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/payment/status/<payment_id>', methods=['GET'])
|
|
@api_key_required
|
|
@api_roles_required(*FINANCE_ROLES)
|
|
def api_payment_status(payment_id):
|
|
"""API: Получить статус платежа"""
|
|
try:
|
|
payments = load_payments()
|
|
|
|
payment = None
|
|
for p in payments:
|
|
if p["id"] == payment_id:
|
|
payment = p
|
|
break
|
|
|
|
if not payment:
|
|
return jsonify({'success': False, 'error': 'Payment not found'}), 404
|
|
|
|
# Проверяем, имеет ли пользователь доступ к этому платежу
|
|
if (payment["payer"] != request.username and
|
|
payment["receiver"] != request.username and
|
|
payment.get("created_by") != request.username):
|
|
if getattr(request, "user_role", ROLE_CLIENT) != ROLE_ADMIN:
|
|
return jsonify({'success': False, 'error': 'Access denied'}), 403
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'payment': sanitize_payment_output(payment)
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/payment/list', methods=['GET'])
|
|
@api_key_required
|
|
@api_roles_required(*FINANCE_ROLES)
|
|
def api_payment_list():
|
|
"""API: Получить список платежей пользователя"""
|
|
try:
|
|
payments = load_payments()
|
|
|
|
# Фильтруем платежи по пользователю
|
|
user_payments = []
|
|
for payment in payments:
|
|
if (payment["payer"] == request.username or
|
|
payment["receiver"] == request.username or
|
|
payment.get("created_by") == request.username):
|
|
user_payments.append(payment)
|
|
|
|
# Сортируем по дате (новые сверху)
|
|
user_payments.sort(key=lambda x: x['created'], reverse=True)
|
|
|
|
# Ограничиваем количество
|
|
limit = min(20, len(user_payments))
|
|
user_payments = user_payments[:limit]
|
|
safe_payments = [sanitize_payment_output(payment) for payment in user_payments]
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'count': len(safe_payments),
|
|
'payments': safe_payments
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/shop/items', methods=['GET'])
|
|
@api_key_required
|
|
@api_roles_required(*FINANCE_ROLES)
|
|
def api_shop_items():
|
|
"""API: Получить список товаров - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
try:
|
|
shop_data = load_shop()
|
|
available_items = [item for item in shop_data["items"] if item["status"] == "available"]
|
|
|
|
for item in available_items:
|
|
item['created_formatted'] = datetime.fromtimestamp(item['created']).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'count': len(available_items),
|
|
'items': available_items[::-1]
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/shop/ads', methods=['GET'])
|
|
@api_key_required
|
|
@api_roles_required(*FINANCE_ROLES)
|
|
def api_shop_ads():
|
|
"""API: Получить список объявлений - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
try:
|
|
shop_data = load_shop()
|
|
active_ads = [ad for ad in shop_data["ads"] if ad["status"] == "active"]
|
|
|
|
for ad in active_ads:
|
|
ad['created_formatted'] = datetime.fromtimestamp(ad['created']).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'count': len(active_ads),
|
|
'ads': active_ads[::-1]
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/crypto', methods=['GET'])
|
|
@api_key_required
|
|
@api_roles_required(*CRYPTO_ROLES)
|
|
def api_crypto_market():
|
|
"""API: Получить состояние рынка SerKripto"""
|
|
try:
|
|
users = load_users()
|
|
user_data = users.get(request.username, {})
|
|
market = load_crypto_market()
|
|
market, decayed = apply_serkripto_idle_decay(market)
|
|
if decayed:
|
|
save_crypto_market(market)
|
|
ws_emit_crypto_market_event(market, "idle_decay")
|
|
|
|
qty = get_user_serkripto_amount(user_data)
|
|
price = float(market.get("price", 0.0))
|
|
value = round(qty * price, 2)
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"coin": {
|
|
"name": market.get("name", SERKRIPTO_NAME),
|
|
"symbol": market.get("symbol", SERKRIPTO_SYMBOL),
|
|
"price_rub": round(price, 4),
|
|
"min_price_rub": round(float(market.get("min_price", 1.0)), 4),
|
|
"max_price_rub": round(float(market.get("max_price", 1000000.0)), 4),
|
|
"decay_rate_pct": round(float(market.get("idle_decay_rate", 0.0)) * 100, 3),
|
|
"decay_interval_sec": int(market.get("idle_decay_interval_sec", 30)),
|
|
"stats": market.get("stats", {})
|
|
},
|
|
"wallet": {
|
|
"quantity": qty,
|
|
"value_rub": value,
|
|
"balance_rub": int(user_data.get("balance", 0)) if isinstance(user_data, dict) else 0
|
|
},
|
|
"updated_at": int(time.time())
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/crypto/buy', methods=['POST'])
|
|
@api_key_required
|
|
@api_roles_required(*CRYPTO_ROLES)
|
|
def api_crypto_buy():
|
|
"""API: Купить SerKripto за рубли"""
|
|
try:
|
|
if not request.is_json:
|
|
return jsonify({'success': False, 'error': 'JSON data required'}), 400
|
|
|
|
data = request.get_json()
|
|
amount = data.get("amount")
|
|
success, result = buy_serkripto(request.username, amount)
|
|
if not success:
|
|
error_map = {
|
|
"invalid_amount": "Amount must be a positive integer",
|
|
"user_not_found": "User not found",
|
|
"market_unavailable": "Crypto market unavailable",
|
|
"insufficient_funds": "Insufficient funds",
|
|
"amount_too_small": "Amount is too small"
|
|
}
|
|
status_map = {
|
|
"user_not_found": 404,
|
|
"market_unavailable": 503
|
|
}
|
|
return jsonify({
|
|
"success": False,
|
|
"error": error_map.get(result, "Buy operation failed"),
|
|
"code": result
|
|
}), status_map.get(result, 400)
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"message": f"{SERKRIPTO_NAME} purchased successfully",
|
|
"trade": result
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/crypto/sell', methods=['POST'])
|
|
@api_key_required
|
|
@api_roles_required(*CRYPTO_ROLES)
|
|
def api_crypto_sell():
|
|
"""API: Продать SerKripto"""
|
|
try:
|
|
if not request.is_json:
|
|
return jsonify({'success': False, 'error': 'JSON data required'}), 400
|
|
|
|
data = request.get_json()
|
|
quantity = data.get("quantity")
|
|
success, result = sell_serkripto(request.username, quantity)
|
|
if not success:
|
|
error_map = {
|
|
"invalid_quantity": "Quantity must be a positive number",
|
|
"user_not_found": "User not found",
|
|
"market_unavailable": "Crypto market unavailable",
|
|
"insufficient_crypto": "Insufficient crypto balance",
|
|
"amount_too_small": "Quantity is too small"
|
|
}
|
|
status_map = {
|
|
"user_not_found": 404,
|
|
"market_unavailable": 503
|
|
}
|
|
return jsonify({
|
|
"success": False,
|
|
"error": error_map.get(result, "Sell operation failed"),
|
|
"code": result
|
|
}), status_map.get(result, 400)
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"message": f"{SERKRIPTO_NAME} sold successfully",
|
|
"trade": result
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/system/status', methods=['GET'])
|
|
@api_key_required
|
|
@api_roles_required(ROLE_ADMIN, ROLE_MANAGER)
|
|
def api_system_status():
|
|
"""API: Получить статус системы - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
|
try:
|
|
users = load_users()
|
|
valid_users = [u for u in users.values() if isinstance(u, dict)]
|
|
total_users = len(valid_users)
|
|
total_balance = sum(u.get('balance', 0) for u in valid_users)
|
|
total_deposit = sum(u.get('deposit', 0) for u in valid_users)
|
|
total_credit = sum(u.get('credit', 0) for u in valid_users)
|
|
total_serkripto = round(sum(get_user_serkripto_amount(u) for u in valid_users), 6)
|
|
crypto_market = load_crypto_market()
|
|
crypto_market, decayed = apply_serkripto_idle_decay(crypto_market)
|
|
if decayed:
|
|
save_crypto_market(crypto_market)
|
|
ws_emit_crypto_market_event(crypto_market, "idle_decay")
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'system_status': 'operational',
|
|
'statistics': {
|
|
'total_users': total_users,
|
|
'total_balance': total_balance,
|
|
'total_deposit': total_deposit,
|
|
'total_credit': total_credit,
|
|
'net_worth': total_balance + total_deposit - total_credit,
|
|
'serkripto_user_holdings': total_serkripto
|
|
},
|
|
'crypto': {
|
|
'name': crypto_market.get('name', SERKRIPTO_NAME),
|
|
'symbol': crypto_market.get('symbol', SERKRIPTO_SYMBOL),
|
|
'price_rub': crypto_market.get('price', 0)
|
|
},
|
|
'timestamp': int(time.time())
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ДОБАВЛЕННЫЕ API ЭНДПОИНТЫ
|
|
|
|
@app.route('/api/v1/users', methods=['GET'])
|
|
@api_key_required
|
|
@api_roles_required(ROLE_ADMIN)
|
|
def api_users():
|
|
"""API: Получить список пользователей (только для админов)"""
|
|
try:
|
|
users = load_users()
|
|
|
|
safe_users = {}
|
|
for username, user in users.items():
|
|
if isinstance(user, dict):
|
|
safe_user = user.copy()
|
|
safe_user.pop('password', None)
|
|
safe_users[username] = safe_user
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'count': len(safe_users),
|
|
'users': safe_users
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/health', methods=['GET'])
|
|
def api_health():
|
|
"""API: Проверка здоровья системы (не требует API ключа)"""
|
|
try:
|
|
files = [USERS_FILE, HISTORY_FILE, SHOP_FILE, PAYMENTS_FILE, JOURNAL_FILE, CLASSES_FILE, SUBJECTS_FILE, PARENT_LINKS_FILE, CRYPTO_FILE]
|
|
file_status = {}
|
|
|
|
for file in files:
|
|
file_status[file] = os.path.exists(file)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'status': 'healthy',
|
|
'timestamp': int(time.time()),
|
|
'files': file_status,
|
|
'message': 'System is operational'
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'status': 'unhealthy', 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/shop/create_item', methods=['POST'])
|
|
@api_key_required
|
|
@api_roles_required(*SHOP_SELL_ROLES)
|
|
def api_create_shop_item():
|
|
"""API: Создать товар в магазине"""
|
|
try:
|
|
if not request.is_json:
|
|
return jsonify({'success': False, 'error': 'JSON data required'}), 400
|
|
|
|
data = request.get_json()
|
|
title = data.get('title')
|
|
description = data.get('description')
|
|
price = data.get('price')
|
|
category = data.get('category', 'other')
|
|
|
|
if not title or not description or not price:
|
|
return jsonify({'success': False, 'error': 'Title, description and price required'}), 400
|
|
|
|
try:
|
|
price_int = int(price)
|
|
if price_int <= 0:
|
|
return jsonify({'success': False, 'error': 'Price must be positive'}), 400
|
|
except:
|
|
return jsonify({'success': False, 'error': 'Invalid price'}), 400
|
|
|
|
item_id = create_shop_item(request.username, title, description, price_int, category)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Item "{title}" created successfully',
|
|
'item_id': item_id
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/v1/shop/buy_item', methods=['POST'])
|
|
@api_key_required
|
|
@api_roles_required(*FINANCE_ROLES)
|
|
def api_buy_item():
|
|
"""API: Купить товар"""
|
|
try:
|
|
if not request.is_json:
|
|
return jsonify({'success': False, 'error': 'JSON data required'}), 400
|
|
|
|
data = request.get_json()
|
|
item_id = data.get('item_id')
|
|
|
|
if not item_id:
|
|
return jsonify({'success': False, 'error': 'Item ID required'}), 400
|
|
|
|
success, message = buy_item(item_id, request.username)
|
|
|
|
return jsonify({
|
|
'success': success,
|
|
'message': message
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ---------- МАГАЗИН МАРШРУТЫ ----------
|
|
@app.route('/create_shop_item', methods=['POST'])
|
|
@login_required
|
|
@web_roles_required(*SHOP_SELL_ROLES)
|
|
def create_shop_item_route():
|
|
"""Создание товара в магазине"""
|
|
title = request.form.get('title')
|
|
description = request.form.get('description')
|
|
price = request.form.get('price')
|
|
category = request.form.get('category', 'other')
|
|
|
|
if not title or not description or not price:
|
|
return redirect('/dashboard')
|
|
|
|
try:
|
|
price_int = int(price)
|
|
if price_int <= 0:
|
|
return redirect('/dashboard')
|
|
except:
|
|
return redirect('/dashboard')
|
|
|
|
item_id = create_shop_item(session['user'], title, description, price_int, category)
|
|
|
|
return redirect('/dashboard')
|
|
|
|
@app.route('/create_advertisement', methods=['POST'])
|
|
@login_required
|
|
@web_roles_required(*SHOP_SELL_ROLES)
|
|
def create_advertisement_route():
|
|
"""Создание объявления"""
|
|
title = request.form.get('title')
|
|
content = request.form.get('content')
|
|
contact_info = request.form.get('contact_info')
|
|
|
|
if not title or not content or not contact_info:
|
|
return redirect('/dashboard')
|
|
|
|
ad_id = create_advertisement(session['user'], title, content, contact_info)
|
|
|
|
return redirect('/dashboard')
|
|
|
|
@app.route('/buy_item/<item_id>', methods=['POST'])
|
|
@login_required
|
|
@web_roles_required(*FINANCE_ROLES)
|
|
def buy_item_route(item_id):
|
|
"""Покупка товара"""
|
|
success, message = buy_item(item_id, session['user'])
|
|
|
|
return redirect('/dashboard')
|
|
|
|
@app.route('/crypto/buy', methods=['POST'])
|
|
@login_required
|
|
@web_roles_required(*CRYPTO_ROLES)
|
|
def crypto_buy_route():
|
|
amount = request.form.get('amount', '').strip()
|
|
success, result = buy_serkripto(session['user'], amount)
|
|
if success:
|
|
return redirect('/dashboard?tab=crypto&crypto_status=buy_success')
|
|
return redirect(f'/dashboard?tab=crypto&crypto_error={result}')
|
|
|
|
@app.route('/crypto/sell', methods=['POST'])
|
|
@login_required
|
|
@web_roles_required(*CRYPTO_ROLES)
|
|
def crypto_sell_route():
|
|
quantity = request.form.get('quantity', '').strip()
|
|
success, result = sell_serkripto(session['user'], quantity)
|
|
if success:
|
|
return redirect('/dashboard?tab=crypto&crypto_status=sell_success')
|
|
return redirect(f'/dashboard?tab=crypto&crypto_error={result}')
|
|
|
|
# ---------- ПЛАТЕЖНЫЕ МАРШРУТЫ ----------
|
|
@app.route('/payment_page')
|
|
@login_required
|
|
@web_roles_required(*FINANCE_ROLES)
|
|
def payment_page():
|
|
"""Страница управления платежами"""
|
|
created_payment = None
|
|
created_payment_id = request.args.get('created', '').strip()
|
|
if created_payment_id:
|
|
created_payment = get_payment_by_id(created_payment_id)
|
|
|
|
error_map = {
|
|
"missing_fields": "Заполните получателя и сумму",
|
|
"invalid_amount": "Сумма должна быть положительным числом",
|
|
"receiver_not_found": "Получатель не найден",
|
|
"payer_not_found": "Плательщик не найден",
|
|
"payer_role_denied": "У выбранного плательщика нет доступа к оплате",
|
|
"same_users": "Плательщик и получатель не должны совпадать"
|
|
}
|
|
error_message = error_map.get(request.args.get('error', '').strip(), "")
|
|
return render_payment_page(created_payment=created_payment, error_message=error_message)
|
|
|
|
@app.route('/create_payment_link', methods=['POST'])
|
|
@login_required
|
|
@web_roles_required(*FINANCE_ROLES)
|
|
def create_payment_link():
|
|
"""Создание ссылки для оплаты"""
|
|
receiver = request.form.get('receiver', '').strip()
|
|
payer = request.form.get('payer', '').strip()
|
|
amount = request.form.get('amount', '').strip()
|
|
description = request.form.get('description', '').strip()
|
|
|
|
if not receiver or not amount:
|
|
return redirect('/payment_page?error=missing_fields')
|
|
|
|
try:
|
|
amount_int = int(amount)
|
|
if amount_int <= 0:
|
|
return redirect('/payment_page?error=invalid_amount')
|
|
except:
|
|
return redirect('/payment_page?error=invalid_amount')
|
|
|
|
users = load_users()
|
|
if receiver not in users:
|
|
return redirect('/payment_page?error=receiver_not_found')
|
|
|
|
allow_any_payer = False
|
|
payer_for_payment = payer
|
|
if payer:
|
|
if payer not in users:
|
|
return redirect('/payment_page?error=payer_not_found')
|
|
if not has_role(users.get(payer, {}), FINANCE_ROLES):
|
|
return redirect('/payment_page?error=payer_role_denied')
|
|
if payer == receiver:
|
|
return redirect('/payment_page?error=same_users')
|
|
else:
|
|
allow_any_payer = True
|
|
payer_for_payment = "*"
|
|
|
|
payment_id, payment_data = create_payment(
|
|
payer=payer_for_payment,
|
|
receiver=receiver,
|
|
amount=amount_int,
|
|
description=description,
|
|
allow_any_payer=allow_any_payer,
|
|
created_by=session['user']
|
|
)
|
|
|
|
return redirect(f'/payment_page?created={payment_id}')
|
|
|
|
@app.route('/pay/<payment_id>', methods=['GET'])
|
|
def pay_page(payment_id):
|
|
"""Страница оплаты для клиента"""
|
|
return render_pay_page(payment_id)
|
|
|
|
@app.route('/process_payment/<payment_id>', methods=['POST'])
|
|
def process_payment_route(payment_id):
|
|
"""Обработка платежа (для неавторизованных пользователей)"""
|
|
username = request.form.get('username', '').strip()
|
|
password = request.form.get('password', '').strip()
|
|
|
|
if not username or not password:
|
|
return redirect(f'/pay/{payment_id}?error=invalid_credentials')
|
|
|
|
# Проверяем логин и пароль
|
|
users = load_users()
|
|
user_data = users.get(username)
|
|
|
|
if not user_data or user_data['password'] != password:
|
|
return redirect(f'/pay/{payment_id}?error=invalid_credentials')
|
|
|
|
if not has_role(user_data, FINANCE_ROLES):
|
|
return redirect(f'/pay/{payment_id}?error=role_denied')
|
|
|
|
# Проверяем, что пользователь является плательщиком
|
|
payments = load_payments()
|
|
payment = None
|
|
for p in payments:
|
|
if p["id"] == payment_id:
|
|
payment = p
|
|
break
|
|
|
|
if not payment:
|
|
return redirect(f'/pay/{payment_id}?error=payment_not_found')
|
|
|
|
if not payment.get("allow_any_payer", False) and payment["payer"] != username:
|
|
return redirect(f'/pay/{payment_id}?error=wrong_payer')
|
|
|
|
# Выполняем платеж
|
|
success, message = process_payment(payment_id, username)
|
|
|
|
if success:
|
|
return redirect(f'/pay/{payment_id}?paid=1')
|
|
else:
|
|
ws_emit_user(username, "payment.failed", {
|
|
"payment_id": payment_id,
|
|
"reason": message
|
|
})
|
|
ws_emit_system("payment.failed", {
|
|
"payment_id": payment_id,
|
|
"user": username,
|
|
"reason": message,
|
|
"source": "web"
|
|
})
|
|
error_map = {
|
|
"Недостаточно средств": "insufficient_funds",
|
|
"Платеж не найден": "payment_not_found",
|
|
"Неверный плательщик": "wrong_payer",
|
|
"Плательщик не найден": "payer_not_found",
|
|
"Получатель не найден": "receiver_not_found",
|
|
"Нельзя оплатить самому себе": "same_user"
|
|
}
|
|
error_code = error_map.get(message, "payment_failed")
|
|
return redirect(f'/pay/{payment_id}?error={error_code}')
|
|
|
|
# ---------- ОСНОВНЫЕ МАРШРУТЫ ----------
|
|
@app.route('/', methods=['GET', 'POST'])
|
|
def login():
|
|
if request.args.get("reset_2fa", "").strip():
|
|
clear_pending_2fa_session()
|
|
return render_login()
|
|
|
|
if request.method == 'POST':
|
|
users = load_users()
|
|
login_step = request.form.get("login_step", "").strip().lower()
|
|
|
|
if login_step == "2fa":
|
|
pending_username = str(session.get("pending_2fa_user", "")).strip()
|
|
if not pending_username or not is_pending_2fa_alive():
|
|
clear_pending_2fa_session()
|
|
return render_login("Срок подтверждения 2FA истек. Выполните вход снова.")
|
|
|
|
user_data = users.get(pending_username)
|
|
if not isinstance(user_data, dict):
|
|
clear_pending_2fa_session()
|
|
return render_login("Пользователь не найден. Выполните вход снова.")
|
|
|
|
if not parse_bool(user_data.get("twofa_enabled", False)):
|
|
clear_pending_2fa_session()
|
|
return render_login("2FA уже отключена. Выполните вход по паролю.")
|
|
|
|
twofa_secret = user_data.get("twofa_secret", "")
|
|
if not twofa_secret:
|
|
clear_pending_2fa_session()
|
|
return render_login("Секрет 2FA отсутствует. Выполните вход снова.")
|
|
|
|
twofa_code = request.form.get("twofa_code", "").strip()
|
|
if not verify_totp_code(twofa_secret, twofa_code):
|
|
return render_login("Неверный код 2FA", twofa_mode=True, username_hint=pending_username)
|
|
|
|
session['user'] = pending_username
|
|
clear_pending_2fa_session()
|
|
ws_emit_user(pending_username, "auth.login", {"username": pending_username, "twofa": True})
|
|
ws_emit_system("auth.login", {"username": pending_username, "twofa": True})
|
|
ws_emit_balance_update(pending_username)
|
|
return redirect('/dashboard')
|
|
|
|
username = request.form.get('username', '').strip()
|
|
password = request.form.get('password', '').strip()
|
|
|
|
if not username or not password:
|
|
clear_pending_2fa_session()
|
|
return render_login("Введите логин и пароль")
|
|
|
|
user_data = users.get(username)
|
|
if user_data and isinstance(user_data, dict) and user_data['password'] == password:
|
|
if parse_bool(user_data.get("twofa_enabled", False)) and user_data.get("twofa_secret"):
|
|
session["pending_2fa_user"] = username
|
|
session["pending_2fa_started"] = int(time.time())
|
|
return render_login(twofa_mode=True, username_hint=username)
|
|
|
|
session['user'] = username
|
|
clear_pending_2fa_session()
|
|
ws_emit_user(username, "auth.login", {"username": username})
|
|
ws_emit_system("auth.login", {"username": username})
|
|
ws_emit_balance_update(username)
|
|
return redirect('/dashboard')
|
|
|
|
clear_pending_2fa_session()
|
|
return render_login("Неверный логин или пароль")
|
|
|
|
if session.get("pending_2fa_user"):
|
|
if is_pending_2fa_alive():
|
|
return render_login(twofa_mode=True, username_hint=session.get("pending_2fa_user"))
|
|
clear_pending_2fa_session()
|
|
|
|
return render_login()
|
|
|
|
@app.route('/register', methods=['GET', 'POST'])
|
|
def register():
|
|
if request.method == 'POST':
|
|
users = load_users()
|
|
username = request.form.get('username', '').strip()
|
|
|
|
if username in users:
|
|
return render_register().replace('</form>',
|
|
'<div class="alert alert-danger mt-3">Пользователь уже существует</div></form>')
|
|
|
|
user_data = {
|
|
'password': request.form.get('password'),
|
|
'balance': 1000,
|
|
'deposit': 0,
|
|
'credit': 0,
|
|
'serkripto': 0.0,
|
|
'role': ROLE_CLIENT,
|
|
'full_name': f"{request.form.get('first_name')} {request.form.get('last_name')}",
|
|
'email': request.form.get('email'),
|
|
'api_key': None,
|
|
'twofa_enabled': False,
|
|
'twofa_secret': ""
|
|
}
|
|
|
|
users[username] = user_data
|
|
save_users(users)
|
|
|
|
session['user'] = username
|
|
ws_emit_user(username, "auth.register", {"username": username})
|
|
ws_emit_system("auth.register", {"username": username})
|
|
ws_emit_balance_update(username)
|
|
return redirect('/dashboard')
|
|
|
|
return render_register()
|
|
|
|
@app.route('/dashboard')
|
|
@login_required
|
|
def dashboard():
|
|
users = load_users()
|
|
history = load_history()
|
|
|
|
if session['user'] not in users:
|
|
session.clear()
|
|
return redirect('/')
|
|
|
|
user_data = users[session['user']]
|
|
user_history = [h for h in history if h['user'] == session['user']]
|
|
shop_data = load_shop()
|
|
crypto_market = load_crypto_market()
|
|
crypto_flash = None
|
|
|
|
crypto_status = request.args.get('crypto_status', '').strip()
|
|
crypto_error = request.args.get('crypto_error', '').strip()
|
|
crypto_status_map = {
|
|
"buy_success": "Покупка SerKripto выполнена",
|
|
"sell_success": "Продажа SerKripto выполнена"
|
|
}
|
|
crypto_error_map = {
|
|
"invalid_amount": "Введите корректную сумму покупки",
|
|
"invalid_quantity": "Введите корректное количество монет",
|
|
"insufficient_funds": "Недостаточно рублей для покупки",
|
|
"insufficient_crypto": "Недостаточно SerKripto на кошельке",
|
|
"market_unavailable": "Рынок SerKripto временно недоступен",
|
|
"user_not_found": "Пользователь не найден",
|
|
"amount_too_small": "Слишком маленький объем операции"
|
|
}
|
|
|
|
if crypto_status in crypto_status_map:
|
|
crypto_flash = {"class": "success", "text": crypto_status_map[crypto_status]}
|
|
elif crypto_error in crypto_error_map:
|
|
crypto_flash = {"class": "danger", "text": crypto_error_map[crypto_error]}
|
|
|
|
requested_tab = request.args.get('tab', '').strip().lower()
|
|
|
|
return render_dashboard(
|
|
user_data,
|
|
user_history[-20:],
|
|
shop_data,
|
|
crypto_market=crypto_market,
|
|
crypto_flash=crypto_flash,
|
|
active_tab=requested_tab or "services"
|
|
)
|
|
|
|
@app.route('/generate_api_key', methods=['POST'])
|
|
@login_required
|
|
@web_roles_required(*FINANCE_ROLES)
|
|
def generate_api_key_route():
|
|
users = load_users()
|
|
user = users.get(session['user'])
|
|
if user and isinstance(user, dict):
|
|
user['api_key'] = generate_api_key()
|
|
save_users(users)
|
|
add_history(session['user'], 'generate_api_key', 0)
|
|
return redirect('/dashboard')
|
|
|
|
@app.route('/deposit', methods=['POST'])
|
|
@login_required
|
|
@web_roles_required(*FINANCE_ROLES)
|
|
def deposit():
|
|
users = load_users()
|
|
user = users.get(session['user'])
|
|
|
|
try:
|
|
amount = int(request.form.get('amount', 0))
|
|
if amount > 0 and user and user['balance'] >= amount:
|
|
user['balance'] -= amount
|
|
user['deposit'] += amount
|
|
save_users(users)
|
|
add_history(session['user'], 'deposit', amount)
|
|
except:
|
|
pass
|
|
|
|
return redirect('/dashboard')
|
|
|
|
@app.route('/credit', methods=['POST'])
|
|
@login_required
|
|
@web_roles_required(*CREDIT_ROLES)
|
|
def credit():
|
|
users = load_users()
|
|
user = users.get(session['user'])
|
|
|
|
try:
|
|
amount = int(request.form.get('amount', 0))
|
|
if amount > 0 and user:
|
|
user['balance'] += amount
|
|
user['credit'] += amount
|
|
save_users(users)
|
|
add_history(session['user'], 'credit', amount)
|
|
except:
|
|
pass
|
|
|
|
return redirect('/dashboard')
|
|
|
|
@app.route('/transfer', methods=['POST'])
|
|
@login_required
|
|
@web_roles_required(*FINANCE_ROLES)
|
|
def transfer():
|
|
users = load_users()
|
|
sender = session['user']
|
|
|
|
try:
|
|
target = request.form.get('target')
|
|
amount = int(request.form.get('amount', 0))
|
|
|
|
sender_data = users.get(sender)
|
|
target_data = users.get(target)
|
|
|
|
if (amount > 0 and target_data and target != sender and
|
|
sender_data and sender_data['balance'] >= amount):
|
|
sender_data['balance'] -= amount
|
|
target_data['balance'] += amount
|
|
save_users(users)
|
|
add_history(sender, 'transfer', amount, target)
|
|
add_history(target, 'transfer_received', amount, sender)
|
|
ws_emit_balance_update(target)
|
|
ws_emit_user(target, "transfer.received", {
|
|
"from": sender,
|
|
"amount": amount
|
|
})
|
|
ws_emit_system("transfer.completed", {
|
|
"from": sender,
|
|
"to": target,
|
|
"amount": amount,
|
|
"source": "web"
|
|
})
|
|
except Exception as e:
|
|
print(f"Transfer error: {e}")
|
|
pass
|
|
|
|
return redirect('/dashboard')
|
|
|
|
# ---------- АДМИН МАРШРУТЫ (ИСПРАВЛЕННЫЕ) ----------
|
|
@app.route('/admin')
|
|
@admin_required
|
|
def admin():
|
|
users = load_users()
|
|
return render_admin_panel(users)
|
|
|
|
@app.route('/admin/change_role', methods=['POST'])
|
|
@admin_required
|
|
def admin_change_role():
|
|
users = load_users()
|
|
username = request.form.get('username')
|
|
new_role = normalize_role(request.form.get('role'))
|
|
|
|
if username in users and username != session['user'] and isinstance(users[username], dict):
|
|
users[username]['role'] = new_role
|
|
save_users(users)
|
|
add_history(session['user'], 'change_role', 0, f"{username}->{new_role}")
|
|
ws_emit_user(username, "role.changed", {"role": new_role})
|
|
ws_emit_system("role.changed", {
|
|
"username": username,
|
|
"role": new_role,
|
|
"by": session.get("user")
|
|
})
|
|
|
|
return redirect('/admin')
|
|
|
|
@app.route('/admin/add_money', methods=['POST'])
|
|
@admin_required
|
|
def admin_add_money():
|
|
users = load_users()
|
|
username = request.form.get('username')
|
|
amount = int(request.form.get('amount', 0))
|
|
|
|
if username in users and amount > 0 and isinstance(users[username], dict):
|
|
users[username]['balance'] += amount
|
|
save_users(users)
|
|
add_history(session['user'], 'add_money', amount, username)
|
|
ws_emit_balance_update(username)
|
|
ws_emit_user(username, "admin.add_money", {
|
|
"amount": amount,
|
|
"by": session.get("user")
|
|
})
|
|
ws_emit_system("admin.add_money", {
|
|
"username": username,
|
|
"amount": amount,
|
|
"by": session.get("user")
|
|
})
|
|
|
|
return redirect('/admin')
|
|
|
|
@app.route('/logout')
|
|
def logout():
|
|
username = session.get('user')
|
|
if username:
|
|
ws_emit_user(username, "auth.logout", {"username": username})
|
|
ws_emit_system("auth.logout", {"username": username})
|
|
session.clear()
|
|
return redirect('/')
|
|
|
|
# ---------- API ДОКУМЕНТАЦИЯ ----------
|
|
@app.route('/api/docs')
|
|
def api_docs():
|
|
"""Страница с документацией API"""
|
|
return '''
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>API Документация</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
<style>
|
|
body {
|
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
|
background: #f5f7fb;
|
|
padding: 20px;
|
|
}
|
|
.code-block {
|
|
background: #1e1e1e;
|
|
color: #d4d4d4;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
font-family: "Courier New", monospace;
|
|
overflow-x: auto;
|
|
margin: 10px 0;
|
|
}
|
|
.endpoint {
|
|
background: white;
|
|
border-radius: 10px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 3px 10px rgba(0,0,0,0.05);
|
|
border-left: 4px solid #4361ee;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card mb-4">
|
|
<div class="card-body">
|
|
<h1 class="card-title"><i class="fas fa-code me-2"></i>Документация API</h1>
|
|
<p class="card-text">Используйте API для интеграции банковских услуг в ваши приложения.</p>
|
|
|
|
<h3 class="mt-4">Базовый URL</h3>
|
|
<div class="code-block">
|
|
http://localhost:5000/api/v1/
|
|
</div>
|
|
|
|
<h3 class="mt-4">Аутентификация</h3>
|
|
<p>Для использования API необходим API ключ. Получите его в личном кабинете.</p>
|
|
<p>Передавайте ключ одним из способов:</p>
|
|
|
|
<h5>Заголовок HTTP:</h5>
|
|
<div class="code-block">
|
|
X-API-Key: ваш_api_ключ
|
|
</div>
|
|
|
|
<h5>Параметр запроса:</h5>
|
|
<div class="code-block">
|
|
?api_key=ваш_api_ключ
|
|
</div>
|
|
|
|
<h5>JSON тело запроса:</h5>
|
|
<div class="code-block">
|
|
{
|
|
"api_key": "ваш_api_ключ",
|
|
...
|
|
}
|
|
</div>
|
|
|
|
<h3 class="mt-4">Доступные эндпоинты</h3>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-success">GET</span> /api/v1/health</h5>
|
|
<p>Проверка здоровья системы (не требует API ключа)</p>
|
|
<div class="code-block">
|
|
curl http://localhost:5000/api/v1/health
|
|
</div>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-info">GET</span> /api/v1/balance</h5>
|
|
<p>Получить информацию о балансе пользователя</p>
|
|
<div class="code-block">
|
|
curl -H "X-API-Key: ваш_ключ" http://localhost:5000/api/v1/balance
|
|
</div>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-info">GET</span> /api/v1/history?limit=50</h5>
|
|
<p>Получить историю операций (опциональный параметр limit)</p>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-success">POST</span> /api/v1/deposit</h5>
|
|
<p>Открыть вклад. Требуется JSON: {"amount": 100}</p>
|
|
<div class="code-block">
|
|
curl -X POST -H "X-API-Key: ваш_ключ" -H "Content-Type: application/json"<br>
|
|
-d "{\\"amount\\": 100}" http://localhost:5000/api/v1/deposit
|
|
</div>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-success">POST</span> /api/v1/credit</h5>
|
|
<p>Взять кредит. Требуется JSON: {"amount": 100}</p>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-success">POST</span> /api/v1/transfer</h5>
|
|
<p>Сделать перевод. Требуется JSON: {"target": "username", "amount": 100}</p>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-info">GET</span> /api/v1/shop/items</h5>
|
|
<p>Получить список товаров в магазине</p>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-info">GET</span> /api/v1/crypto</h5>
|
|
<p>Получить текущий курс SerKripto и баланс монет пользователя</p>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-success">POST</span> /api/v1/crypto/buy</h5>
|
|
<p>Купить SerKripto. Требуется JSON: {"amount": 1000}</p>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-success">POST</span> /api/v1/crypto/sell</h5>
|
|
<p>Продать SerKripto. Требуется JSON: {"quantity": 0.25}</p>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-success">POST</span> /api/v1/payment/create</h5>
|
|
<p>Создать платеж. Требуется JSON: {"receiver": "...", "amount": 100, "description": "..."}</p>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-info">GET</span> /api/v1/payment/status/{payment_id}</h5>
|
|
<p>Получить статус платежа</p>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<h5><span class="badge bg-info">GET</span> /api/v1/system/status</h5>
|
|
<p>Получить статус системы</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
'''
|
|
# ================= ДЕКОРАТОР УЧИТЕЛЯ =================
|
|
def teacher_required(f):
|
|
return web_roles_required(*JOURNAL_ROLES)(f)
|
|
|
|
def student_required(f):
|
|
return web_roles_required(ROLE_STUDENT, ROLE_ADMIN)(f)
|
|
|
|
def parent_required(f):
|
|
return web_roles_required(ROLE_PARENT, ROLE_ADMIN)(f)
|
|
|
|
def manager_required(f):
|
|
return web_roles_required(ROLE_MANAGER, ROLE_ADMIN)(f)
|
|
|
|
def get_student_class(student):
|
|
classes = load_classes()
|
|
for class_name, students in classes.items():
|
|
if student in students:
|
|
return class_name
|
|
return "-"
|
|
|
|
def get_student_stats(student):
|
|
journal = load_journal()
|
|
users = load_users()
|
|
subjects = journal.get(student, {})
|
|
all_grades = []
|
|
for grades in subjects.values():
|
|
all_grades.extend(grades)
|
|
|
|
avg = round(sum(all_grades) / len(all_grades), 2) if all_grades else 0
|
|
grade_count = len(all_grades)
|
|
finance_delta = sum(calculate_money_for_grade(g) for g in all_grades)
|
|
current_balance = users.get(student, {}).get("balance", 0)
|
|
|
|
return {
|
|
"avg": avg,
|
|
"grade_count": grade_count,
|
|
"finance_delta": finance_delta,
|
|
"current_balance": current_balance,
|
|
"subjects": subjects
|
|
}
|
|
|
|
def get_grade_events_from_history(student):
|
|
history = load_history()
|
|
events = []
|
|
|
|
pattern = re.compile(r"^(.*?):\s*оценка\s*([2-5])\s*\(([+-]?\d+)\)")
|
|
for item in history:
|
|
if item.get("user") != student or item.get("action") != "grade_update":
|
|
continue
|
|
|
|
target = str(item.get("target", ""))
|
|
match = pattern.match(target)
|
|
if match:
|
|
subject = match.group(1).strip() or "Предмет"
|
|
grade = int(match.group(2))
|
|
delta = int(match.group(3))
|
|
else:
|
|
subject = "Предмет"
|
|
grade = 0
|
|
delta = 0
|
|
|
|
events.append({
|
|
"time": int(item.get("time", 0)),
|
|
"subject": subject,
|
|
"grade": grade,
|
|
"delta": delta
|
|
})
|
|
|
|
events.sort(key=lambda x: x["time"])
|
|
return events
|
|
|
|
def render_student_panel(student_username):
|
|
users = load_users()
|
|
if student_username not in users:
|
|
return "Ученик не найден", 404
|
|
|
|
stats = get_student_stats(student_username)
|
|
class_name = get_student_class(student_username)
|
|
grade_events = get_grade_events_from_history(student_username)
|
|
|
|
subject_rows = ""
|
|
for subject, grades in stats["subjects"].items():
|
|
subject_avg = round(sum(grades) / len(grades), 2) if grades else 0
|
|
subject_rows += f"""
|
|
<tr>
|
|
<td>{subject}</td>
|
|
<td>{', '.join(map(str, grades)) if grades else '-'}</td>
|
|
<td>{subject_avg}</td>
|
|
</tr>
|
|
"""
|
|
|
|
if not subject_rows:
|
|
subject_rows = "<tr><td colspan='3'>Нет оценок</td></tr>"
|
|
|
|
timeline_rows = ""
|
|
labels = []
|
|
grades_series = []
|
|
avg_series = []
|
|
money_series = []
|
|
positive_total = 0
|
|
negative_total = 0
|
|
cumulative_money = 0
|
|
running_grades = []
|
|
|
|
for index, event in enumerate(grade_events, start=1):
|
|
event_time = datetime.fromtimestamp(event["time"]).strftime("%d.%m.%Y %H:%M") if event["time"] else "-"
|
|
delta = int(event["delta"])
|
|
cumulative_money += delta
|
|
if delta >= 0:
|
|
positive_total += delta
|
|
delta_color = "text-success"
|
|
delta_text = f"+{delta}"
|
|
else:
|
|
negative_total += abs(delta)
|
|
delta_color = "text-danger"
|
|
delta_text = str(delta)
|
|
|
|
timeline_rows += f"""
|
|
<tr>
|
|
<td>{index}</td>
|
|
<td>{event_time}</td>
|
|
<td>{event['subject']}</td>
|
|
<td>{event['grade'] if event['grade'] else '-'}</td>
|
|
<td class="{delta_color}"><b>{delta_text}</b></td>
|
|
<td><b>{cumulative_money}</b></td>
|
|
</tr>
|
|
"""
|
|
|
|
labels.append(index)
|
|
if event["grade"]:
|
|
running_grades.append(event["grade"])
|
|
grades_series.append(event["grade"])
|
|
avg_series.append(round(sum(running_grades) / len(running_grades), 2))
|
|
else:
|
|
grades_series.append(None)
|
|
avg_series.append(avg_series[-1] if avg_series else 0)
|
|
money_series.append(cumulative_money)
|
|
|
|
if not timeline_rows:
|
|
timeline_rows = "<tr><td colspan='6'>Пока нет оценок и начислений</td></tr>"
|
|
|
|
if not labels:
|
|
labels = [1]
|
|
grades_series = [None]
|
|
avg_series = [0]
|
|
money_series = [0]
|
|
|
|
net_money = positive_total - negative_total
|
|
stats_net = stats["finance_delta"]
|
|
if not grade_events and stats_net != 0:
|
|
net_money = stats_net
|
|
if stats_net > 0:
|
|
positive_total = stats_net
|
|
else:
|
|
negative_total = abs(stats_net)
|
|
|
|
net_color = "text-success" if net_money >= 0 else "text-danger"
|
|
current_role = get_user_role(users.get(session.get("user"), {}))
|
|
role_view = "Панель ученика"
|
|
back_link = "/dashboard"
|
|
if current_role == ROLE_ADMIN:
|
|
role_view = "Панель ученика (режим администратора)"
|
|
elif current_role == ROLE_PARENT:
|
|
role_view = "Панель ученика (просмотр родителя)"
|
|
back_link = "/parent_panel"
|
|
elif current_role == ROLE_MANAGER:
|
|
role_view = "Панель ученика (просмотр менеджера)"
|
|
back_link = "/manager_panel"
|
|
|
|
return f"""
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Панель ученика</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
<style>
|
|
body {{
|
|
background: linear-gradient(180deg, #f6f9ff 0%, #eef3ff 100%);
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
}}
|
|
.top-card {{
|
|
background: linear-gradient(135deg, #1d4ed8, #2563eb);
|
|
color: white;
|
|
border-radius: 18px;
|
|
padding: 24px;
|
|
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.28);
|
|
}}
|
|
.metric {{
|
|
background: white;
|
|
border-radius: 14px;
|
|
padding: 16px;
|
|
border: 1px solid #e6ecff;
|
|
box-shadow: 0 5px 14px rgba(60, 90, 170, 0.1);
|
|
height: 100%;
|
|
}}
|
|
.table-card {{
|
|
background: white;
|
|
border-radius: 14px;
|
|
padding: 16px;
|
|
border: 1px solid #e6ecff;
|
|
box-shadow: 0 5px 14px rgba(60, 90, 170, 0.1);
|
|
margin-top: 16px;
|
|
}}
|
|
.badge-soft {{
|
|
background: #e6f0ff;
|
|
color: #1d4ed8;
|
|
border-radius: 999px;
|
|
padding: 6px 10px;
|
|
font-weight: 700;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container py-4">
|
|
<div class="top-card mb-4">
|
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
<div>
|
|
<h3 class="mb-1"><i class="fas fa-user-graduate me-2"></i>{role_view}</h3>
|
|
<div class="opacity-75">Ученик: <b>{student_username}</b> | Класс: <b>{class_name}</b></div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<a href="{back_link}" class="btn btn-light btn-sm"><i class="fas fa-arrow-left me-1"></i>Назад</a>
|
|
<a href="/logout" class="btn btn-outline-light btn-sm">Выход</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-md-4">
|
|
<div class="metric">
|
|
<div class="text-muted">Средний балл</div>
|
|
<h3 class="mb-0">{stats['avg']}</h3>
|
|
<small class="text-muted">Оценок: {stats['grade_count']}</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="metric">
|
|
<div class="text-muted">Деньги за оценки (всего)</div>
|
|
<h3 class="mb-0 {net_color}">{'+' if net_money >= 0 else ''}{net_money}</h3>
|
|
<small class="text-success me-2">Получено: +{positive_total}</small>
|
|
<small class="text-danger">Списано: -{negative_total}</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="metric">
|
|
<div class="text-muted">Текущий баланс счета</div>
|
|
<h3 class="mb-0">{stats['current_balance']}</h3>
|
|
<small class="text-muted">Банковский баланс аккаунта</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-card">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="mb-0"><i class="fas fa-chart-line me-2"></i>Графики</h5>
|
|
<span class="badge-soft">Динамика оценок и денег</span>
|
|
</div>
|
|
<div class="row g-4">
|
|
<div class="col-lg-6"><canvas id="gradesChart" height="180"></canvas></div>
|
|
<div class="col-lg-6"><canvas id="moneyChart" height="180"></canvas></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-lg-5">
|
|
<div class="table-card">
|
|
<h5><i class="fas fa-book me-2"></i>Оценки по предметам</h5>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle">
|
|
<thead><tr><th>Предмет</th><th>Оценки</th><th>Средний</th></tr></thead>
|
|
<tbody>{subject_rows}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-7">
|
|
<div class="table-card">
|
|
<h5><i class="fas fa-history me-2"></i>История начислений за оценки</h5>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle">
|
|
<thead>
|
|
<tr><th>#</th><th>Дата</th><th>Предмет</th><th>Оценка</th><th>Изменение</th><th>Итог</th></tr>
|
|
</thead>
|
|
<tbody>{timeline_rows}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script>
|
|
const labels = {json.dumps(labels)};
|
|
const grades = {json.dumps(grades_series)};
|
|
const avgGrades = {json.dumps(avg_series)};
|
|
const money = {json.dumps(money_series)};
|
|
|
|
new Chart(document.getElementById('gradesChart'), {{
|
|
type: 'line',
|
|
data: {{
|
|
labels,
|
|
datasets: [
|
|
{{ label: 'Оценка', data: grades, borderColor: '#2563eb', tension: 0.25 }},
|
|
{{ label: 'Средний балл', data: avgGrades, borderColor: '#16a34a', tension: 0.25 }}
|
|
]
|
|
}},
|
|
options: {{ scales: {{ y: {{ min: 2, max: 5, ticks: {{ stepSize: 1 }} }} }} }}
|
|
}});
|
|
|
|
new Chart(document.getElementById('moneyChart'), {{
|
|
type: 'line',
|
|
data: {{
|
|
labels,
|
|
datasets: [{{ label: 'Деньги за оценки', data: money, borderColor: '#f59e0b', tension: 0.25 }}]
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
def render_parent_panel(parent_username):
|
|
users = load_users()
|
|
if parent_username not in users:
|
|
return "Родитель не найден", 404
|
|
|
|
children = get_parent_children(parent_username)
|
|
class_map = load_classes()
|
|
journal = load_journal()
|
|
|
|
children_rows = ""
|
|
for child in children:
|
|
if child not in users:
|
|
continue
|
|
stats = get_student_stats(child)
|
|
class_name = get_student_class(child)
|
|
net = stats["finance_delta"]
|
|
net_text = f"+{net}" if net >= 0 else str(net)
|
|
net_class = "text-success" if net >= 0 else "text-danger"
|
|
children_rows += f"""
|
|
<tr>
|
|
<td><b>{child}</b></td>
|
|
<td>{class_name}</td>
|
|
<td>{stats['avg']}</td>
|
|
<td>{stats['grade_count']}</td>
|
|
<td class="{net_class}"><b>{net_text}</b></td>
|
|
<td>
|
|
<a href="/parent/student/{child}" class="btn btn-sm btn-outline-primary">Открыть</a>
|
|
<form action="/parent/remove_child/{child}" method="post" style="display:inline;">
|
|
<button class="btn btn-sm btn-outline-danger">Удалить</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
"""
|
|
if not children_rows:
|
|
children_rows = "<tr><td colspan='6' class='text-center text-muted'>Пока не добавлены дети</td></tr>"
|
|
|
|
# Кандидаты для быстрого добавления ребенка
|
|
linked = set(children)
|
|
student_candidates = []
|
|
for username, user_data in users.items():
|
|
if not isinstance(user_data, dict):
|
|
continue
|
|
role = get_user_role(user_data)
|
|
in_classes = any(username in students for students in class_map.values())
|
|
in_journal = username in journal
|
|
if role == ROLE_STUDENT or in_classes or in_journal:
|
|
if username not in linked and username != parent_username:
|
|
student_candidates.append(username)
|
|
student_candidates = sorted(set(student_candidates))
|
|
|
|
options = '<option value="" selected disabled>Выберите ученика</option>'
|
|
for student_name in student_candidates:
|
|
options += f'<option value="{student_name}">{student_name}</option>'
|
|
if not student_candidates:
|
|
options = '<option value="" selected disabled>Нет доступных учеников</option>'
|
|
|
|
return f"""
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Панель родителя</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<style>
|
|
body {{ background: #f5f8ff; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }}
|
|
.hero {{ background: linear-gradient(135deg, #0f766e, #0ea5e9); color: #fff; border-radius: 16px; padding: 22px; }}
|
|
.cardx {{ background: #fff; border-radius: 14px; border: 1px solid #e5ecff; box-shadow: 0 6px 16px rgba(41,72,150,0.12); }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container py-4">
|
|
<div class="hero mb-3">
|
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
<div>
|
|
<h3 class="mb-1">Панель родителя</h3>
|
|
<div class="opacity-75">Профиль: <b>{parent_username}</b></div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<a href="/dashboard" class="btn btn-light btn-sm">Назад</a>
|
|
<a href="/logout" class="btn btn-outline-light btn-sm">Выход</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cardx p-3 mb-3">
|
|
<h5>Добавить ребенка по логину</h5>
|
|
<form method="post" action="/parent/add_child" class="row g-2">
|
|
<div class="col-md-8">
|
|
<select name="student" class="form-select" required>
|
|
{options}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<button class="btn btn-primary w-100">Добавить</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="cardx p-3">
|
|
<h5>Дети и их успеваемость</h5>
|
|
<div class="table-responsive">
|
|
<table class="table align-middle">
|
|
<thead>
|
|
<tr>
|
|
<th>Ученик</th><th>Класс</th><th>Средний</th><th>Оценок</th><th>Деньги за оценки</th><th>Действия</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{children_rows}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
def render_manager_panel(manager_username):
|
|
users = load_users()
|
|
payments = load_payments()
|
|
journal = load_journal()
|
|
classes = load_classes()
|
|
history = load_history()
|
|
shop_data = load_shop()
|
|
|
|
valid_users = {u: d for u, d in users.items() if isinstance(d, dict)}
|
|
role_counts = {role: 0 for role in sorted(VALID_ROLES)}
|
|
total_balance = 0
|
|
for _, user_data in valid_users.items():
|
|
role_counts[get_user_role(user_data)] += 1
|
|
total_balance += int(user_data.get("balance", 0))
|
|
|
|
payment_total = len(payments)
|
|
payment_pending = len([p for p in payments if p.get("status") == "pending"])
|
|
payment_completed = len([p for p in payments if p.get("status") == "completed"])
|
|
|
|
recent_payments_rows = ""
|
|
for payment in sorted(payments, key=lambda x: x.get("created", 0), reverse=True)[:20]:
|
|
created = datetime.fromtimestamp(payment.get("created", 0)).strftime("%d.%m.%Y %H:%M")
|
|
recent_payments_rows += f"""
|
|
<tr>
|
|
<td>{payment.get('id')}</td>
|
|
<td>{payment.get('payer')}</td>
|
|
<td>{payment.get('receiver')}</td>
|
|
<td>{payment.get('amount')}</td>
|
|
<td>{payment.get('status')}</td>
|
|
<td>{created}</td>
|
|
</tr>
|
|
"""
|
|
if not recent_payments_rows:
|
|
recent_payments_rows = "<tr><td colspan='6' class='text-center text-muted'>Нет платежей</td></tr>"
|
|
|
|
students_set = set(journal.keys())
|
|
for _, students in classes.items():
|
|
students_set.update(students)
|
|
for username, user_data in valid_users.items():
|
|
if get_user_role(user_data) == ROLE_STUDENT:
|
|
students_set.add(username)
|
|
|
|
top_students = []
|
|
for student in students_set:
|
|
stats = get_student_stats(student)
|
|
top_students.append({
|
|
"student": student,
|
|
"avg": stats["avg"],
|
|
"grades": stats["grade_count"],
|
|
"finance": stats["finance_delta"],
|
|
"class_name": get_student_class(student)
|
|
})
|
|
top_students.sort(key=lambda x: (x["avg"], x["grades"], x["finance"]), reverse=True)
|
|
|
|
top_students_rows = ""
|
|
for i, item in enumerate(top_students[:10], start=1):
|
|
top_students_rows += f"""
|
|
<tr>
|
|
<td>{i}</td>
|
|
<td><a href="/manager/student/{item['student']}">{item['student']}</a></td>
|
|
<td>{item['class_name']}</td>
|
|
<td>{item['avg']}</td>
|
|
<td>{item['grades']}</td>
|
|
<td>{item['finance']}</td>
|
|
</tr>
|
|
"""
|
|
if not top_students_rows:
|
|
top_students_rows = "<tr><td colspan='6' class='text-center text-muted'>Нет данных об учениках</td></tr>"
|
|
|
|
role_rows = ""
|
|
for role_name in sorted(role_counts.keys()):
|
|
role_rows += f"<tr><td>{role_name}</td><td>{role_counts[role_name]}</td></tr>"
|
|
|
|
recent_ops_rows = ""
|
|
for op in sorted(history, key=lambda x: x.get("time", 0), reverse=True)[:15]:
|
|
op_time = datetime.fromtimestamp(op.get("time", 0)).strftime("%d.%m.%Y %H:%M")
|
|
recent_ops_rows += f"""
|
|
<tr>
|
|
<td>{op_time}</td>
|
|
<td>{op.get('user')}</td>
|
|
<td>{op.get('action')}</td>
|
|
<td>{op.get('amount')}</td>
|
|
<td>{op.get('target', '-')}</td>
|
|
</tr>
|
|
"""
|
|
if not recent_ops_rows:
|
|
recent_ops_rows = "<tr><td colspan='5' class='text-center text-muted'>Нет операций</td></tr>"
|
|
|
|
return f"""
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Панель менеджера</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<style>
|
|
body {{ background: #f5f7fb; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }}
|
|
.hero {{ background: linear-gradient(135deg, #7c3aed, #2563eb); color: #fff; border-radius: 16px; padding: 22px; }}
|
|
.metric {{ background: #fff; border-radius: 14px; border: 1px solid #e6ebff; box-shadow: 0 6px 16px rgba(50,70,150,0.12); padding: 16px; height: 100%; }}
|
|
.cardx {{ background: #fff; border-radius: 14px; border: 1px solid #e6ebff; box-shadow: 0 6px 16px rgba(50,70,150,0.12); padding: 16px; margin-top: 14px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container py-4">
|
|
<div class="hero mb-3">
|
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
<div>
|
|
<h3 class="mb-1">Панель менеджера</h3>
|
|
<div class="opacity-75">Пользователь: <b>{manager_username}</b></div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<a href="/dashboard" class="btn btn-light btn-sm">Назад</a>
|
|
<a href="/logout" class="btn btn-outline-light btn-sm">Выход</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-md-3"><div class="metric"><div class="text-muted">Пользователи</div><h3>{len(valid_users)}</h3></div></div>
|
|
<div class="col-md-3"><div class="metric"><div class="text-muted">Общий баланс</div><h3>{total_balance}</h3></div></div>
|
|
<div class="col-md-3"><div class="metric"><div class="text-muted">Платежей всего</div><h3>{payment_total}</h3></div></div>
|
|
<div class="col-md-3"><div class="metric"><div class="text-muted">Платежей pending/completed</div><h3>{payment_pending}/{payment_completed}</h3></div></div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-lg-4">
|
|
<div class="cardx">
|
|
<h5>Роли в системе</h5>
|
|
<table class="table table-sm">
|
|
<thead><tr><th>Роль</th><th>Количество</th></tr></thead>
|
|
<tbody>{role_rows}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-8">
|
|
<div class="cardx">
|
|
<h5>Топ учеников</h5>
|
|
<table class="table table-sm align-middle">
|
|
<thead><tr><th>#</th><th>Ученик</th><th>Класс</th><th>Средний</th><th>Оценок</th><th>Деньги</th></tr></thead>
|
|
<tbody>{top_students_rows}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cardx">
|
|
<h5>Последние платежи</h5>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle">
|
|
<thead><tr><th>ID</th><th>Плательщик</th><th>Получатель</th><th>Сумма</th><th>Статус</th><th>Дата</th></tr></thead>
|
|
<tbody>{recent_payments_rows}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cardx">
|
|
<h5>Последние операции</h5>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle">
|
|
<thead><tr><th>Дата</th><th>Пользователь</th><th>Действие</th><th>Сумма</th><th>Цель</th></tr></thead>
|
|
<tbody>{recent_ops_rows}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-muted small mt-2">
|
|
Товаров в магазине: {len(shop_data.get('items', []))} | Объявлений: {len(shop_data.get('ads', []))}
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
@app.route("/teacher")
|
|
@login_required
|
|
@teacher_required
|
|
def teacher_panel():
|
|
return redirect("/journal")
|
|
|
|
@app.route("/student_panel")
|
|
@login_required
|
|
@student_required
|
|
def student_panel():
|
|
current_role = get_user_role(load_users().get(session.get("user"), {}))
|
|
student_username = session.get("user")
|
|
|
|
if current_role == ROLE_ADMIN:
|
|
requested_student = request.args.get("student", "").strip()
|
|
if requested_student:
|
|
student_username = requested_student
|
|
|
|
return render_student_panel(student_username)
|
|
|
|
@app.route("/parent_panel")
|
|
@login_required
|
|
@parent_required
|
|
def parent_panel():
|
|
users = load_users()
|
|
current_user = session.get("user")
|
|
current_role = get_user_role(users.get(current_user, {}))
|
|
|
|
parent_username = current_user
|
|
if current_role == ROLE_ADMIN:
|
|
requested_parent = request.args.get("parent", "").strip()
|
|
if requested_parent in users:
|
|
parent_username = requested_parent
|
|
|
|
return render_parent_panel(parent_username)
|
|
|
|
@app.route("/parent/add_child", methods=['POST'])
|
|
@login_required
|
|
@parent_required
|
|
def parent_add_child():
|
|
users = load_users()
|
|
links = load_parent_links()
|
|
classes = load_classes()
|
|
journal = load_journal()
|
|
|
|
current_user = session.get("user")
|
|
current_role = get_user_role(users.get(current_user, {}))
|
|
parent_username = current_user
|
|
|
|
if current_role == ROLE_ADMIN:
|
|
requested_parent = request.form.get("parent", "").strip()
|
|
if requested_parent in users:
|
|
parent_username = requested_parent
|
|
|
|
student = request.form.get("student", "").strip()
|
|
if not student:
|
|
return redirect("/parent_panel")
|
|
if student not in users:
|
|
return redirect("/parent_panel")
|
|
|
|
student_role = get_user_role(users.get(student, {}))
|
|
in_classes = any(student in students for students in classes.values())
|
|
in_journal = student in journal
|
|
if student_role != ROLE_STUDENT and not in_classes and not in_journal:
|
|
return redirect("/parent_panel")
|
|
|
|
links.setdefault(parent_username, [])
|
|
if student not in links[parent_username]:
|
|
links[parent_username].append(student)
|
|
save_parent_links(links)
|
|
ws_emit_user(parent_username, "parent.child_added", {"student": student})
|
|
ws_emit_user(student, "parent.linked", {"parent": parent_username})
|
|
ws_emit_system("parent.child_added", {
|
|
"parent": parent_username,
|
|
"student": student
|
|
})
|
|
|
|
return redirect("/parent_panel")
|
|
|
|
@app.route("/parent/remove_child/<student>", methods=['POST'])
|
|
@login_required
|
|
@parent_required
|
|
def parent_remove_child(student):
|
|
links = load_parent_links()
|
|
parent_username = session.get("user")
|
|
if parent_username in links and student in links[parent_username]:
|
|
links[parent_username] = [s for s in links[parent_username] if s != student]
|
|
save_parent_links(links)
|
|
ws_emit_user(parent_username, "parent.child_removed", {"student": student})
|
|
ws_emit_user(student, "parent.unlinked", {"parent": parent_username})
|
|
ws_emit_system("parent.child_removed", {
|
|
"parent": parent_username,
|
|
"student": student
|
|
})
|
|
return redirect("/parent_panel")
|
|
|
|
@app.route("/parent/student/<student>")
|
|
@login_required
|
|
@parent_required
|
|
def parent_student_view(student):
|
|
users = load_users()
|
|
if student not in users:
|
|
return "Ученик не найден", 404
|
|
|
|
current_user = session.get("user")
|
|
current_role = get_user_role(users.get(current_user, {}))
|
|
if current_role != ROLE_ADMIN:
|
|
children = get_parent_children(current_user)
|
|
if student not in children:
|
|
return redirect("/parent_panel")
|
|
|
|
return render_student_panel(student)
|
|
|
|
@app.route("/manager_panel")
|
|
@login_required
|
|
@manager_required
|
|
def manager_panel():
|
|
return render_manager_panel(session.get("user"))
|
|
|
|
@app.route("/manager/student/<student>")
|
|
@login_required
|
|
@manager_required
|
|
def manager_student_view(student):
|
|
users = load_users()
|
|
if student not in users:
|
|
return "Ученик не найден", 404
|
|
return render_student_panel(student)
|
|
|
|
@app.route("/add_subject", methods=['POST'])
|
|
@login_required
|
|
@teacher_required
|
|
def add_subject():
|
|
subject = request.form.get("subject", "").strip()
|
|
if not subject:
|
|
return redirect("/journal")
|
|
|
|
subjects = load_subjects()
|
|
if subject not in subjects:
|
|
subjects.append(subject)
|
|
subjects.sort()
|
|
save_subjects(subjects)
|
|
ws_emit_system("journal.subject_added", {"subject": subject, "by": session.get("user")})
|
|
return redirect("/journal")
|
|
|
|
@app.route("/add_class", methods=['POST'])
|
|
@login_required
|
|
@teacher_required
|
|
def add_class():
|
|
class_name = request.form.get("class_name", "").strip()
|
|
if not class_name:
|
|
return redirect("/journal")
|
|
|
|
classes = load_classes()
|
|
is_new_class = class_name not in classes
|
|
classes.setdefault(class_name, [])
|
|
save_classes(classes)
|
|
if is_new_class:
|
|
ws_emit_system("journal.class_added", {"class_name": class_name, "by": session.get("user")})
|
|
return redirect("/journal")
|
|
|
|
@app.route("/add_student_to_class", methods=['POST'])
|
|
@login_required
|
|
@teacher_required
|
|
def add_student_to_class():
|
|
student = request.form.get("student", "").strip()
|
|
class_name = request.form.get("class_name", "").strip()
|
|
if not student or not class_name:
|
|
return redirect("/journal")
|
|
|
|
users = load_users()
|
|
if student not in users:
|
|
return "Ученик не найден", 404
|
|
|
|
classes = load_classes()
|
|
classes.setdefault(class_name, [])
|
|
if student not in classes[class_name]:
|
|
classes[class_name].append(student)
|
|
save_classes(classes)
|
|
ws_emit_system("journal.student_added_to_class", {
|
|
"student": student,
|
|
"class_name": class_name,
|
|
"by": session.get("user")
|
|
})
|
|
return redirect("/journal")
|
|
|
|
@app.route("/add_grade", methods=['POST'])
|
|
@login_required
|
|
@teacher_required
|
|
def add_grade():
|
|
student = request.form.get("student", "").strip()
|
|
subject = request.form.get("subject", "").strip()
|
|
grade_raw = request.form.get("grade", "").strip()
|
|
|
|
try:
|
|
grade = int(grade_raw)
|
|
except ValueError:
|
|
return "Оценка должна быть числом от 2 до 5", 400
|
|
|
|
if grade not in [2, 3, 4, 5]:
|
|
return "Оценка должна быть от 2 до 5", 400
|
|
|
|
users = load_users()
|
|
if student not in users:
|
|
return "Ученик не найден", 404
|
|
if not subject:
|
|
return "Укажите предмет", 400
|
|
|
|
journal = load_journal()
|
|
journal.setdefault(student, {})
|
|
journal[student].setdefault(subject, [])
|
|
journal[student][subject].append(grade)
|
|
save_journal(journal)
|
|
|
|
delta = calculate_money_for_grade(grade)
|
|
users[student]["balance"] = users[student].get("balance", 0) + delta
|
|
save_users(users)
|
|
|
|
sign = "+" if delta >= 0 else ""
|
|
add_history(student, "grade_update", abs(delta), f"{subject}: оценка {grade} ({sign}{delta})")
|
|
ws_emit_system("journal.grade_added", {
|
|
"student": student,
|
|
"subject": subject,
|
|
"grade": grade,
|
|
"delta": delta,
|
|
"by": session.get("user")
|
|
})
|
|
ws_emit_balance_update(student)
|
|
return redirect("/journal")
|
|
|
|
@app.route("/student_graph/<student>")
|
|
@login_required
|
|
@teacher_required
|
|
def student_graph(student):
|
|
stats = get_student_stats(student)
|
|
all_grades = []
|
|
for grades in stats["subjects"].values():
|
|
all_grades.extend(grades)
|
|
|
|
labels = list(range(1, len(all_grades) + 1))
|
|
return f"""
|
|
<h2>График успеваемости: {student}</h2>
|
|
<a href="/journal">Назад</a>
|
|
<canvas id="chart" width="900" height="320"></canvas>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script>
|
|
const labels = {json.dumps(labels)};
|
|
const grades = {json.dumps(all_grades)};
|
|
new Chart(document.getElementById('chart'), {{
|
|
type: 'line',
|
|
data: {{
|
|
labels: labels,
|
|
datasets: [{{ label: 'Оценки', data: grades, borderColor: '#0d6efd', tension: 0.25 }}]
|
|
}},
|
|
options: {{ scales: {{ y: {{ min: 2, max: 5, ticks: {{ stepSize: 1 }} }} }} }}
|
|
}});
|
|
</script>
|
|
"""
|
|
|
|
@app.route("/student_avg_graph/<student>")
|
|
@login_required
|
|
@teacher_required
|
|
def student_avg_graph(student):
|
|
stats = get_student_stats(student)
|
|
all_grades = []
|
|
for grades in stats["subjects"].values():
|
|
all_grades.extend(grades)
|
|
|
|
averages = []
|
|
current = []
|
|
for g in all_grades:
|
|
current.append(g)
|
|
averages.append(round(sum(current) / len(current), 2))
|
|
|
|
labels = list(range(1, len(averages) + 1))
|
|
return f"""
|
|
<h2>Динамика среднего балла: {student}</h2>
|
|
<a href="/journal">Назад</a>
|
|
<canvas id="chart" width="900" height="320"></canvas>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script>
|
|
const labels = {json.dumps(labels)};
|
|
const dataPoints = {json.dumps(averages)};
|
|
new Chart(document.getElementById('chart'), {{
|
|
type: 'line',
|
|
data: {{
|
|
labels: labels,
|
|
datasets: [{{ label: 'Средний балл', data: dataPoints, borderColor: '#198754', tension: 0.25 }}]
|
|
}},
|
|
options: {{ scales: {{ y: {{ min: 2, max: 5 }} }} }}
|
|
}});
|
|
</script>
|
|
"""
|
|
|
|
@app.route("/student_finance_graph/<student>")
|
|
@login_required
|
|
@teacher_required
|
|
def student_finance_graph(student):
|
|
stats = get_student_stats(student)
|
|
all_grades = []
|
|
for grades in stats["subjects"].values():
|
|
all_grades.extend(grades)
|
|
|
|
balance_history = []
|
|
balance = 0
|
|
for g in all_grades:
|
|
balance += calculate_money_for_grade(g)
|
|
balance_history.append(balance)
|
|
|
|
labels = list(range(1, len(balance_history) + 1))
|
|
return f"""
|
|
<h2>Финансовый график ученика: {student}</h2>
|
|
<a href="/journal">Назад</a>
|
|
<canvas id="chart" width="900" height="320"></canvas>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script>
|
|
const labels = {json.dumps(labels)};
|
|
const dataPoints = {json.dumps(balance_history)};
|
|
new Chart(document.getElementById('chart'), {{
|
|
type: 'line',
|
|
data: {{
|
|
labels: labels,
|
|
datasets: [{{ label: 'Баланс от оценок', data: dataPoints, borderColor: '#fd7e14', tension: 0.25 }}]
|
|
}}
|
|
}});
|
|
</script>
|
|
"""
|
|
|
|
@app.route("/student_stats/<student>")
|
|
@login_required
|
|
@teacher_required
|
|
def student_stats(student):
|
|
users = load_users()
|
|
if student not in users:
|
|
return "Ученик не найден", 404
|
|
|
|
stats = get_student_stats(student)
|
|
class_name = get_student_class(student)
|
|
|
|
subjects_rows = ""
|
|
for subject, grades in stats["subjects"].items():
|
|
sub_avg = round(sum(grades) / len(grades), 2) if grades else 0
|
|
subjects_rows += f"<tr><td>{subject}</td><td>{', '.join(map(str, grades))}</td><td>{sub_avg}</td></tr>"
|
|
|
|
if not subjects_rows:
|
|
subjects_rows = "<tr><td colspan='3'>Нет данных</td></tr>"
|
|
|
|
return f"""
|
|
<h2>Статистика ученика: {student}</h2>
|
|
<a href="/journal">Назад</a>
|
|
<p>Класс: <b>{class_name}</b></p>
|
|
<p>Средний балл: <b>{stats['avg']}</b></p>
|
|
<p>Количество оценок: <b>{stats['grade_count']}</b></p>
|
|
<p>Финансовый итог по оценкам: <b>{stats['finance_delta']}</b></p>
|
|
<p>Текущий баланс: <b>{stats['current_balance']}</b></p>
|
|
<table border="1" cellpadding="8">
|
|
<tr><th>Предмет</th><th>Оценки</th><th>Средний</th></tr>
|
|
{subjects_rows}
|
|
</table>
|
|
"""
|
|
|
|
@app.route("/rating")
|
|
@login_required
|
|
@teacher_required
|
|
def rating():
|
|
journal = load_journal()
|
|
users = load_users()
|
|
result = []
|
|
|
|
for student in journal.keys():
|
|
stats = get_student_stats(student)
|
|
result.append({
|
|
"student": student,
|
|
"avg": stats["avg"],
|
|
"balance": users.get(student, {}).get("balance", 0),
|
|
"class_name": get_student_class(student)
|
|
})
|
|
|
|
result.sort(key=lambda x: (x["avg"], x["balance"]), reverse=True)
|
|
rows = ""
|
|
place = 1
|
|
for item in result:
|
|
rows += f"""
|
|
<tr>
|
|
<td>{place}</td>
|
|
<td>{item['student']}</td>
|
|
<td>{item['class_name']}</td>
|
|
<td>{item['avg']}</td>
|
|
<td>{item['balance']}</td>
|
|
</tr>
|
|
"""
|
|
place += 1
|
|
|
|
if not rows:
|
|
rows = "<tr><td colspan='5'>Нет данных для рейтинга</td></tr>"
|
|
|
|
return f"""
|
|
<h2>Рейтинг учеников</h2>
|
|
<a href="/journal">Назад</a>
|
|
<table border="1" cellpadding="8">
|
|
<tr>
|
|
<th>Место</th>
|
|
<th>Ученик</th>
|
|
<th>Класс</th>
|
|
<th>Средний балл</th>
|
|
<th>Баланс</th>
|
|
</tr>
|
|
{rows}
|
|
</table>
|
|
"""
|
|
|
|
@app.route("/journal")
|
|
@login_required
|
|
@teacher_required
|
|
def journal_page():
|
|
journal = load_journal()
|
|
classes = load_classes()
|
|
subjects = load_subjects()
|
|
users = load_users()
|
|
|
|
students_rows = ""
|
|
for student in sorted(journal.keys()):
|
|
stats = get_student_stats(student)
|
|
class_name = get_student_class(student)
|
|
balance_class = "pos" if stats["current_balance"] >= 0 else "neg"
|
|
students_rows += f"""
|
|
<tr>
|
|
<td><b>{student}</b></td>
|
|
<td><span class="class-pill">{class_name}</span></td>
|
|
<td>{stats['avg']}</td>
|
|
<td><span class="balance {balance_class}">{stats['current_balance']}</span></td>
|
|
<td class="actions">
|
|
<a class="link-btn" href="/student_graph/{student}">Успеваемость</a>
|
|
<a class="link-btn" href="/student_avg_graph/{student}">Средний</a>
|
|
<a class="link-btn" href="/student_finance_graph/{student}">Финансы</a>
|
|
<a class="link-btn" href="/student_stats/{student}">Статистика</a>
|
|
</td>
|
|
</tr>
|
|
"""
|
|
|
|
if not students_rows:
|
|
students_rows = "<tr><td colspan='5' class='empty'>Пока нет данных по ученикам</td></tr>"
|
|
|
|
class_rows = ""
|
|
for class_name, students in sorted(classes.items()):
|
|
student_chips = ", ".join([f"<span class='student-chip'>{s}</span>" for s in students]) if students else "<span class='muted'>—</span>"
|
|
class_rows += f"<tr><td><span class='class-pill'>{class_name}</span></td><td>{student_chips}</td></tr>"
|
|
if not class_rows:
|
|
class_rows = "<tr><td colspan='2' class='empty'>Классы не добавлены</td></tr>"
|
|
|
|
subjects_text = "".join([f"<span class='subject-chip'>{s}</span>" for s in subjects]) if subjects else "<span class='muted'>Предметы не добавлены</span>"
|
|
|
|
student_candidates = set(journal.keys())
|
|
for class_students in classes.values():
|
|
student_candidates.update(class_students)
|
|
for username, user_data in users.items():
|
|
if not isinstance(user_data, dict):
|
|
continue
|
|
role = user_data.get("role", "client")
|
|
if role in [ROLE_ADMIN, ROLE_TEACHER, ROLE_MANAGER, ROLE_PARENT]:
|
|
continue
|
|
student_candidates.add(username)
|
|
grade_students = sorted(student_candidates)
|
|
|
|
subject_candidates = set(subjects)
|
|
for student_subjects in journal.values():
|
|
if isinstance(student_subjects, dict):
|
|
subject_candidates.update(student_subjects.keys())
|
|
grade_subjects = sorted(subject_candidates)
|
|
|
|
if grade_students:
|
|
student_options = '<option value="" selected disabled>Выберите ученика</option>' + "".join(
|
|
[f'<option value="{student}">{student}</option>' for student in grade_students]
|
|
)
|
|
else:
|
|
student_options = '<option value="" selected disabled>Сначала добавьте учеников</option>'
|
|
|
|
if grade_subjects:
|
|
subject_options = '<option value="" selected disabled>Выберите предмет</option>' + "".join(
|
|
[f'<option value="{subject}">{subject}</option>' for subject in grade_subjects]
|
|
)
|
|
else:
|
|
subject_options = '<option value="" selected disabled>Сначала добавьте предметы</option>'
|
|
|
|
grade_options = """
|
|
<option value="" selected disabled>Выберите оценку</option>
|
|
<option value="5">5</option>
|
|
<option value="4">4</option>
|
|
<option value="3">3</option>
|
|
<option value="2">2</option>
|
|
"""
|
|
grade_submit_disabled = "disabled" if not grade_students or not grade_subjects else ""
|
|
|
|
return f"""
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Учительская панель</title>
|
|
<style>
|
|
:root {{
|
|
--primary: #4361ee;
|
|
--secondary: #3a0ca3;
|
|
--bg: #f4f7fc;
|
|
--surface: #ffffff;
|
|
--line: #e6ebf5;
|
|
--text: #1f2a44;
|
|
--muted: #6b7894;
|
|
--success: #168a5a;
|
|
--danger: #c63b3b;
|
|
--shadow: 0 12px 28px rgba(37, 72, 160, 0.12);
|
|
}}
|
|
* {{
|
|
box-sizing: border-box;
|
|
}}
|
|
body {{
|
|
margin: 0;
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: radial-gradient(circle at top left, #e9efff 0%, var(--bg) 45%);
|
|
color: var(--text);
|
|
}}
|
|
.journal-shell {{
|
|
max-width: 1220px;
|
|
margin: 28px auto;
|
|
padding: 0 16px 28px;
|
|
}}
|
|
.header-card {{
|
|
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
|
color: #fff;
|
|
border-radius: 18px;
|
|
padding: 20px;
|
|
box-shadow: var(--shadow);
|
|
margin-bottom: 18px;
|
|
}}
|
|
.top-links {{
|
|
margin-top: 12px;
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}}
|
|
.top-links a {{
|
|
color: #fff;
|
|
background: rgba(255, 255, 255, 0.14);
|
|
text-decoration: none;
|
|
padding: 8px 14px;
|
|
border-radius: 10px;
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
}}
|
|
.forms-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 14px;
|
|
margin-bottom: 16px;
|
|
}}
|
|
.panel {{
|
|
background: var(--surface);
|
|
border-radius: 14px;
|
|
border: 1px solid var(--line);
|
|
box-shadow: 0 6px 20px rgba(30, 60, 135, 0.08);
|
|
padding: 16px;
|
|
}}
|
|
.panel h3 {{
|
|
margin: 0 0 10px;
|
|
font-size: 18px;
|
|
}}
|
|
.panel form {{
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}}
|
|
.field {{
|
|
flex: 1 1 160px;
|
|
border: 1px solid #d7deee;
|
|
border-radius: 10px;
|
|
padding: 10px 12px;
|
|
font-size: 14px;
|
|
}}
|
|
.btn {{
|
|
border: none;
|
|
border-radius: 10px;
|
|
padding: 10px 14px;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
|
font-weight: 600;
|
|
}}
|
|
.chips {{
|
|
margin-top: 10px;
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}}
|
|
.subject-chip {{
|
|
background: #e8eeff;
|
|
color: #2e4cae;
|
|
border-radius: 999px;
|
|
padding: 5px 10px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
}}
|
|
.rule {{
|
|
background: #fff8e8;
|
|
border: 1px solid #fde1aa;
|
|
color: #7a570d;
|
|
border-radius: 12px;
|
|
padding: 10px 12px;
|
|
margin: 0 0 16px;
|
|
font-weight: 600;
|
|
}}
|
|
.table-card {{
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
box-shadow: 0 6px 20px rgba(30, 60, 135, 0.08);
|
|
padding: 16px;
|
|
margin-bottom: 14px;
|
|
overflow: auto;
|
|
}}
|
|
.table-card h3 {{
|
|
margin: 0 0 12px;
|
|
}}
|
|
table {{
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
min-width: 690px;
|
|
}}
|
|
th {{
|
|
text-align: left;
|
|
background: #f0f4ff;
|
|
color: #304478;
|
|
font-size: 13px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.4px;
|
|
}}
|
|
th, td {{
|
|
border-bottom: 1px solid var(--line);
|
|
padding: 10px 12px;
|
|
vertical-align: top;
|
|
}}
|
|
.class-pill {{
|
|
background: #eef2ff;
|
|
color: #2a4597;
|
|
border: 1px solid #d9e2ff;
|
|
border-radius: 999px;
|
|
padding: 4px 10px;
|
|
font-weight: 700;
|
|
font-size: 13px;
|
|
}}
|
|
.student-chip {{
|
|
background: #f2f5fd;
|
|
border: 1px solid #dbe3f7;
|
|
border-radius: 999px;
|
|
padding: 4px 9px;
|
|
font-size: 13px;
|
|
white-space: nowrap;
|
|
}}
|
|
.muted {{
|
|
color: var(--muted);
|
|
}}
|
|
.balance.pos {{
|
|
color: var(--success);
|
|
font-weight: 700;
|
|
}}
|
|
.balance.neg {{
|
|
color: var(--danger);
|
|
font-weight: 700;
|
|
}}
|
|
.actions {{
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}}
|
|
.link-btn {{
|
|
text-decoration: none;
|
|
color: #2949a1;
|
|
background: #edf2ff;
|
|
border: 1px solid #d8e2ff;
|
|
padding: 5px 10px;
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
}}
|
|
.empty {{
|
|
color: var(--muted);
|
|
text-align: center;
|
|
padding: 16px;
|
|
}}
|
|
@media (max-width: 768px) {{
|
|
.header-card {{
|
|
border-radius: 14px;
|
|
}}
|
|
.panel form {{
|
|
flex-direction: column;
|
|
}}
|
|
.btn {{
|
|
width: 100%;
|
|
}}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="journal-shell">
|
|
<div class="header-card">
|
|
<h2>Учительская панель: электронный дневник</h2>
|
|
<div class="top-links">
|
|
<a href="/dashboard">Назад в банк</a>
|
|
<a href="/rating">Рейтинг учеников</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="forms-grid">
|
|
<div class="panel">
|
|
<h3>Добавить предмет</h3>
|
|
<form method="post" action="/add_subject">
|
|
<input class="field" name="subject" placeholder="Математика" required>
|
|
<button class="btn">Добавить предмет</button>
|
|
</form>
|
|
<div class="chips">{subjects_text}</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h3>Добавить класс</h3>
|
|
<form method="post" action="/add_class">
|
|
<input class="field" name="class_name" placeholder="5А" required>
|
|
<button class="btn">Создать класс</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h3>Добавить ученика в класс</h3>
|
|
<form method="post" action="/add_student_to_class">
|
|
<input class="field" name="student" placeholder="Логин ученика" required>
|
|
<input class="field" name="class_name" placeholder="Класс" required>
|
|
<button class="btn">Добавить</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel" style="margin-bottom: 14px;">
|
|
<h3>Поставить оценку</h3>
|
|
<p class="rule">Финансовая логика: 5 => +20, 4 => +10, 3 => -10, 2 => -20</p>
|
|
<form method="post" action="/add_grade">
|
|
<select class="field" name="student" required>
|
|
{student_options}
|
|
</select>
|
|
<select class="field" name="subject" required>
|
|
{subject_options}
|
|
</select>
|
|
<select class="field" name="grade" required>
|
|
{grade_options}
|
|
</select>
|
|
<button class="btn" {grade_submit_disabled}>Поставить оценку</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="table-card">
|
|
<h3>Классы</h3>
|
|
<table>
|
|
<tr><th>Класс</th><th>Ученики</th></tr>
|
|
{class_rows}
|
|
</table>
|
|
</div>
|
|
|
|
<div class="table-card">
|
|
<h3>Ученики</h3>
|
|
<table>
|
|
<tr>
|
|
<th>Ученик</th>
|
|
<th>Класс</th>
|
|
<th>Средний балл</th>
|
|
<th>Баланс</th>
|
|
<th>Инструменты</th>
|
|
</tr>
|
|
{students_rows}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import threading
|
|
|
|
def daily_update_thread():
|
|
while True:
|
|
time.sleep(86400)
|
|
daily_update()
|
|
|
|
thread = threading.Thread(target=daily_update_thread, daemon=True)
|
|
thread.start()
|
|
requested_debug = parse_bool(os.environ.get("BANK_DEBUG", "1"))
|
|
app.run(host='0.0.0.0', port=5000, debug=requested_debug, use_reloader=requested_debug)
|