feat(panel): gestión de múltiples administradores en MySQL y simplificación de UI
This commit is contained in:
@@ -117,6 +117,13 @@ def init_db():
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY idx_ui_lang (original_text(255), target_lang)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4''')
|
UNIQUE KEY idx_ui_lang (original_text(255), target_lang)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4''')
|
||||||
|
|
||||||
|
# Tabla para administradores del panel web
|
||||||
|
cursor.execute('''CREATE TABLE IF NOT EXISTS admins
|
||||||
|
(id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4''')
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
else:
|
else:
|
||||||
@@ -148,6 +155,22 @@ def init_db():
|
|||||||
translated_text TEXT NOT NULL,
|
translated_text TEXT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(message_id, target_lang))''')
|
UNIQUE(message_id, target_lang))''')
|
||||||
|
|
||||||
|
# SQLite equivalent for UI translations
|
||||||
|
c.execute('''CREATE TABLE IF NOT EXISTS ui_translations
|
||||||
|
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
original_text TEXT NOT NULL,
|
||||||
|
target_lang TEXT NOT NULL,
|
||||||
|
translated_text TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(original_text, target_lang))''')
|
||||||
|
|
||||||
|
# SQLite equivalent for admins
|
||||||
|
c.execute('''CREATE TABLE IF NOT EXISTS admins
|
||||||
|
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -420,3 +443,85 @@ def save_ui_translation(text: str, target_lang: str, translated_text: str):
|
|||||||
c.execute("INSERT OR REPLACE INTO ui_translations (original_text, target_lang, translated_text) VALUES (?, ?, ?)", (text, target_lang, translated_text))
|
c.execute("INSERT OR REPLACE INTO ui_translations (original_text, target_lang, translated_text) VALUES (?, ?, ?)", (text, target_lang, translated_text))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# Funciones para administradores
|
||||||
|
def get_admins():
|
||||||
|
db_type = get_db_type()
|
||||||
|
if db_type == "mysql":
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT id, username, created_at FROM admins ORDER BY username")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
cursor.close()
|
||||||
|
return rows
|
||||||
|
else:
|
||||||
|
conn = get_connection()
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT id, username, created_at FROM admins ORDER BY username")
|
||||||
|
rows = [{"id": r[0], "username": r[1], "created_at": r[2]} for r in c.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def get_admin_by_username(username: str):
|
||||||
|
db_type = get_db_type()
|
||||||
|
if db_type == "mysql":
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT * FROM admins WHERE username = %s", (username,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
cursor.close()
|
||||||
|
return row
|
||||||
|
else:
|
||||||
|
conn = get_connection()
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT id, username, password_hash, created_at FROM admins WHERE username = ?", (username,))
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
return {"id": row[0], "username": row[1], "password_hash": row[2], "created_at": row[3]}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_admin(username: str, password_hash: str):
|
||||||
|
db_type = get_db_type()
|
||||||
|
if db_type == "mysql":
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("INSERT INTO admins (username, password_hash) VALUES (%s, %s)", (username, password_hash))
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
else:
|
||||||
|
conn = get_connection()
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("INSERT INTO admins (username, password_hash) VALUES (?, ?)", (username, password_hash))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete_admin(admin_id: int):
|
||||||
|
db_type = get_db_type()
|
||||||
|
if db_type == "mysql":
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM admins WHERE id = %s", (admin_id,))
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
else:
|
||||||
|
conn = get_connection()
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("DELETE FROM admins WHERE id = ?", (admin_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_admin_password(admin_id: int, password_hash: str):
|
||||||
|
db_type = get_db_type()
|
||||||
|
if db_type == "mysql":
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE admins SET password_hash = %s WHERE id = %s", (password_hash, admin_id))
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
else:
|
||||||
|
conn = get_connection()
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("UPDATE admins SET password_hash = ? WHERE id = ?", (password_hash, admin_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ from fastapi import Request
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from passlib.hash import pbkdf2_sha256 as hasher
|
||||||
from botdiscord.config import load_config, get_web_config, get_libretranslate_url, get_db_type
|
from botdiscord.config import load_config, get_web_config, get_libretranslate_url, get_db_type
|
||||||
from botdiscord.database import get_ui_translation, save_ui_translation
|
from botdiscord.database import (
|
||||||
|
get_ui_translation, save_ui_translation,
|
||||||
|
get_admins, get_admin_by_username, add_admin, delete_admin
|
||||||
|
)
|
||||||
from botdiscord.translate import translate_text
|
from botdiscord.translate import translate_text
|
||||||
|
|
||||||
app = FastAPI(title="Panel de Configuración - Bots de Traducción")
|
app = FastAPI(title="Panel de Configuración - Bots de Traducción")
|
||||||
@@ -74,9 +78,21 @@ def get_config():
|
|||||||
}
|
}
|
||||||
|
|
||||||
def verify_admin(username: str, password: str) -> bool:
|
def verify_admin(username: str, password: str) -> bool:
|
||||||
|
# 1. Primero intentamos con el SuperAdmin del .env (backup)
|
||||||
web_config = get_web_config()
|
web_config = get_web_config()
|
||||||
return (username == web_config.get("admin_username", "") and
|
if username == web_config.get("admin_username", "") and \
|
||||||
password == web_config.get("admin_password", ""))
|
password == web_config.get("admin_password", ""):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 2. Si no es el SuperAdmin, buscamos en la base de datos MySQL
|
||||||
|
try:
|
||||||
|
admin = get_admin_by_username(username)
|
||||||
|
if admin and hasher.verify(password, admin['password_hash']):
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error verifying admin: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root(request: Request):
|
async def root(request: Request):
|
||||||
@@ -217,6 +233,72 @@ async def logout():
|
|||||||
response.delete_cookie("auth")
|
response.delete_cookie("auth")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@app.get("/admins")
|
||||||
|
async def admins_page(request: Request):
|
||||||
|
if request.cookies.get("auth") != "ok":
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
|
admins = get_admins()
|
||||||
|
return templates.TemplateResponse("admins.html", {
|
||||||
|
"request": request,
|
||||||
|
"admins": admins
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.post("/admins/add")
|
||||||
|
async def add_admin_post(request: Request):
|
||||||
|
if request.cookies.get("auth") != "ok":
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
username = form.get("username", "")
|
||||||
|
password = form.get("password", "")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return RedirectResponse(url="/admins?error=missing_fields", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
try:
|
||||||
|
password_hash = hasher.hash(password)
|
||||||
|
add_admin(username, password_hash)
|
||||||
|
return RedirectResponse(url="/admins?success=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CRITICAL ERROR adding admin: {e}")
|
||||||
|
# Redirigimos con error a la misma página
|
||||||
|
return RedirectResponse(url="/admins?error=" + str(e), status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
@app.post("/admins/delete")
|
||||||
|
async def delete_admin_post(request: Request):
|
||||||
|
if request.cookies.get("auth") != "ok":
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
admin_id = form.get("admin_id")
|
||||||
|
if admin_id:
|
||||||
|
try:
|
||||||
|
delete_admin(int(admin_id))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting admin: {e}")
|
||||||
|
|
||||||
|
return RedirectResponse(url="/admins", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
@app.post("/admins/update")
|
||||||
|
async def update_admin_post(request: Request):
|
||||||
|
if request.cookies.get("auth") != "ok":
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
admin_id = form.get("admin_id")
|
||||||
|
new_password = form.get("new_password")
|
||||||
|
|
||||||
|
if admin_id and new_password:
|
||||||
|
from botdiscord.database import update_admin_password
|
||||||
|
password_hash = hasher.hash(new_password)
|
||||||
|
try:
|
||||||
|
update_admin_password(int(admin_id), password_hash)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error updating admin password: {e}")
|
||||||
|
|
||||||
|
return RedirectResponse(url="/admins?success=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
web_config = get_web_config()
|
web_config = get_web_config()
|
||||||
|
|||||||
145
panel/templates/admins.html
Normal file
145
panel/templates/admins.html
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
{% set lang = request.cookies.get('panel_lang', 'es') %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ lang }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ "Administradores - Bots de Traducción" | translate(lang) }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="/dashboard">
|
||||||
|
<i class="bi bi-translate"></i> {{ "Bots de Traducción" | translate(lang) }}
|
||||||
|
</a>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="dropdown me-3">
|
||||||
|
<button class="btn btn-outline-light btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-translate"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><a class="dropdown-item {{ 'active' if lang == 'es' }}" href="/set-lang/es">Español</a></li>
|
||||||
|
<li><a class="dropdown-item {{ 'active' if lang == 'en' }}" href="/set-lang/en">English</a></li>
|
||||||
|
<li><a class="dropdown-item {{ 'active' if lang == 'pt' }}" href="/set-lang/pt">Português</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard" class="btn btn-outline-light btn-sm me-2">{{ "Dashboard" | translate(lang) }}</a>
|
||||||
|
<a href="/logout" class="btn btn-outline-light btn-sm">{{ "Cerrar Sesión" | translate(lang) }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h2 class="mb-4"><i class="bi bi-people"></i> {{ "Gestión de Administradores" | translate(lang) }}</h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{{ "Añadir Administrador" | translate(lang) }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admins/add">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{{ "Usuario" | translate(lang) }}</label>
|
||||||
|
<input type="text" name="username" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{{ "Contraseña" | translate(lang) }}</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">{{ "Añadir" | translate(lang) }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{{ "Lista de Administradores" | translate(lang) }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ "ID" | translate(lang) }}</th>
|
||||||
|
<th>{{ "Usuario" | translate(lang) }}</th>
|
||||||
|
<th>{{ "Fecha Creación" | translate(lang) }}</th>
|
||||||
|
<th>{{ "Acciones" | translate(lang) }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for admin in admins %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ admin.id }}</td>
|
||||||
|
<td>{{ admin.username }}</td>
|
||||||
|
<td>{{ admin.created_at }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-warning btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#updateModal"
|
||||||
|
data-admin-id="{{ admin.id }}"
|
||||||
|
data-admin-user="{{ admin.username }}">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<form method="post" action="/admins/delete" style="display: inline;">
|
||||||
|
<input type="hidden" name="admin_id" value="{{ admin.id }}">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm"
|
||||||
|
onclick="return confirm('{{ "¿Estás seguro?" | translate(lang) }}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal para actualización -->
|
||||||
|
<div class="modal fade" id="updateModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="post" action="/admins/update">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ "Actualizar Contraseña" | translate(lang) }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{{ "Usuario:" | translate(lang) }} <strong id="modalUser"></strong></p>
|
||||||
|
<input type="hidden" name="admin_id" id="modalAdminId">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{{ "Nueva Contraseña" | translate(lang) }}</label>
|
||||||
|
<input type="password" name="new_password" class="form-control" required minlength="6">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ "Cerrar" | translate(lang) }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{{ "Guardar Cambios" | translate(lang) }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const updateModal = document.getElementById('updateModal');
|
||||||
|
updateModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
const button = event.relatedTarget;
|
||||||
|
const adminId = button.getAttribute('data-admin-id');
|
||||||
|
const adminUser = button.getAttribute('data-admin-user');
|
||||||
|
|
||||||
|
document.getElementById('modalAdminId').value = adminId;
|
||||||
|
document.getElementById('modalUser').textContent = adminUser;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -38,18 +38,8 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card text-white bg-primary mb-3">
|
<div class="card text-white bg-primary mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title"><i class="bi bi-discord"></i> Discord</h5>
|
<h5 class="card-title"><i class="bi bi-gear"></i> {{ "Configuración" | translate(lang) }}</h5>
|
||||||
<p class="card-text">{{ "Bot de traducción para servidores de Discord" | translate(lang) }}</p>
|
<p class="card-text">{{ "Configurar tokens de bots y parámetros del sistema" | translate(lang) }}</p>
|
||||||
<a href="/config" class="btn btn-light btn-sm">{{ "Configurar" | translate(lang) }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card text-white bg-info mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title"><i class="bi bi-telegram"></i> Telegram</h5>
|
|
||||||
<p class="card-text">{{ "Bot de traducción para grupos de Telegram" | translate(lang) }}</p>
|
|
||||||
<a href="/config" class="btn btn-light btn-sm">{{ "Configurar" | translate(lang) }}</a>
|
<a href="/config" class="btn btn-light btn-sm">{{ "Configurar" | translate(lang) }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +54,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card text-white bg-dark mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title"><i class="bi bi-people"></i> {{ "Administradores" | translate(lang) }}</h5>
|
||||||
|
<p class="card-text">{{ "Gestionar usuarios del panel web" | translate(lang) }}</p>
|
||||||
|
<a href="/admins" class="btn btn-light btn-sm">{{ "Gestionar" | translate(lang) }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ python-dotenv>=1.0.0
|
|||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
mysql-connector-python>=8.0.0
|
mysql-connector-python>=8.0.0
|
||||||
nest-asyncio
|
nest-asyncio
|
||||||
|
bcrypt
|
||||||
|
passlib
|
||||||
|
|||||||
Reference in New Issue
Block a user