Jubilar Django: borrar el portal antiguo, la web ya la sirve Next
El cutover se hizo hoy: Caddy manda www.nightspire.gg a :3001 (Next), así que Django se quedó sin dominio y sin uso. Se comprobó antes de borrar que nada dependía de él: ninguna referencia a :8001 en Caddy, el timer de reconciliación llama a Next, DJANGO_API_BASE no lo leía ni una línea del código (se quita también de la unidad de systemd), web-next/public es autónomo (736 ficheros reales, 0 symlinks) y no hay ni una referencia a /static/. Con Django parado la web siguió dando 200 en todas las rutas. Se va: home/, novawow/, forum/, wotlk_db/, frontend/ (las islas React que Next sustituyó), static/, staticfiles/, manage.py, requirements.txt, db.sqlite3 y el Docker de Django. Se quedan docs/ y sql/: no son código Django sino documentación y esquemas, y sql/forum_schema.sql describe la BD acore_web que Next usa hoy. La BASE DE DATOS no se toca. django_wow y sus 41 tablas home_* son ahora de Next, que las consulta con SQL directo. El nombre se queda por historia. README reescrito: describía cómo montar un Django que ya no existe (venv, manage.py migrate, gunicorn, Docker). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
-32
@@ -1,32 +0,0 @@
|
||||
# Nova WoW - imagen del portal Django
|
||||
FROM python:3.14-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Dependencias de sistema para mysqlclient (compilación + runtime) y Pillow
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
default-libmysqlclient-dev \
|
||||
libgmp-dev libmpfr-dev libmpc-dev \
|
||||
libjpeg-dev zlib1g-dev \
|
||||
netcat-openbsd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar dependencias Python primero (mejor cache)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copiar el código
|
||||
COPY . .
|
||||
|
||||
# Entrypoint
|
||||
RUN chmod +x /app/docker/entrypoint.sh
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||
CMD ["gunicorn", "novawow.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
|
||||
@@ -1,115 +1,82 @@
|
||||
# Nova WoW
|
||||
|
||||
Portal web (Django) para un servidor de World of Warcraft basado en AzerothCore.
|
||||
Portal web para un servidor de World of Warcraft 3.4.3 basado en AzerothCore.
|
||||
|
||||
- **Python:** 3.14
|
||||
- **Framework:** Django 6.0
|
||||
- **Base de datos:** MySQL (4 bases: la del portal `django_wow` + las de AzerothCore `acore_auth`, `acore_characters`, `acore_world`)
|
||||
- **Framework:** Next.js 16 (App Router) + Tailwind, en `web-next/`
|
||||
- **Idiomas:** ES/EN con next-intl
|
||||
- **Bases de datos (MySQL):** `django_wow` (la del portal), `acore_auth`, `acore_characters`,
|
||||
`acore_world` y `acore_web` (el foro)
|
||||
|
||||
> La base del portal se sigue llamando `django_wow` y sus tablas `home_*` por motivos históricos: el
|
||||
> portal era una app Django, jubilada el 2026-07-15. El código se borró (está en el historial de git),
|
||||
> pero **las tablas son las mismas y siguen en uso**: Next las consulta con SQL directo, sin ORM.
|
||||
|
||||
---
|
||||
|
||||
## 1. Requisitos
|
||||
|
||||
- Python 3.14 y `venv`
|
||||
- MySQL/MariaDB accesible
|
||||
- Librerías de sistema para compilar algunas dependencias:
|
||||
|
||||
```bash
|
||||
sudo apt install python3.14-venv python3.14-dev build-essential pkg-config \
|
||||
default-libmysqlclient-dev libgmp-dev libmpfr-dev libmpc-dev libjpeg-dev zlib1g-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Puesta en marcha (desarrollo)
|
||||
## 1. Puesta en marcha (desarrollo)
|
||||
|
||||
```bash
|
||||
git clone https://git.nightspire.gg/Inna/NovaWoW.git
|
||||
cd NovaWoW
|
||||
|
||||
# Entorno virtual
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Variables de entorno
|
||||
cp .env.example .env
|
||||
# edita .env con tus credenciales reales (ver sección 3)
|
||||
|
||||
# Migraciones y arranque
|
||||
python manage.py migrate
|
||||
python manage.py createsuperuser # opcional
|
||||
python manage.py runserver
|
||||
cd NovaWoW/web-next
|
||||
npm install
|
||||
# crear .env.local con las variables de la sección 2
|
||||
npm run dev # http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
La app queda en http://127.0.0.1:8000
|
||||
## 2. Configuración (`web-next/.env.local`)
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuración (`.env`)
|
||||
|
||||
Toda la configuración sensible se lee de variables de entorno mediante
|
||||
[python-dotenv](https://pypi.org/project/python-dotenv/). Copia `.env.example`
|
||||
a `.env` y rellena los valores. **El `.env` no se versiona.**
|
||||
**No se versiona** (contiene secretos).
|
||||
|
||||
| Variable | Descripción |
|
||||
|---|---|
|
||||
| `DJANGO_SECRET_KEY` | Clave secreta de Django. Genera una nueva (ver abajo). |
|
||||
| `DJANGO_DEBUG` | `True` en desarrollo, `False` en producción. |
|
||||
| `DB_USER` / `DB_PASSWORD` | Credenciales MySQL (compartidas por las 4 BBDD). |
|
||||
| `DB_HOST` / `DB_PORT` | Host y puerto de MySQL. |
|
||||
| `DB_NAME_DEFAULT` | BD del portal (por defecto `django_wow`). |
|
||||
| `DB_NAME_AUTH` / `DB_NAME_CHARACTERS` / `DB_NAME_WORLD` | BBDD de AzerothCore. |
|
||||
| `AC_SOAP_*` | Conexión SOAP a AzerothCore (crear cuentas, etc.). |
|
||||
| `SUMUP_*` / `STRIPE_*` | Pasarelas de pago. |
|
||||
| `EMAIL_HOST_USER` / `EMAIL_HOST_PASSWORD` | SMTP de Gmail (App Password). |
|
||||
| `DB_USER` / `DB_PASSWORD` / `DB_HOST` / `DB_PORT` | Credenciales MySQL (compartidas por las 5 BBDD) |
|
||||
| `DB_NAME_DEFAULT` | BD del portal (`django_wow`) |
|
||||
| `DB_NAME_AUTH` / `DB_NAME_CHARACTERS` / `DB_NAME_WORLD` / `DB_NAME_WEB` | BBDD de AzerothCore y del foro |
|
||||
| `SESSION_SECRET` | Firma de la sesión |
|
||||
| `SITE_URL` | Raíz pública del sitio. **De aquí cuelgan todos los enlaces e imágenes de los correos**, así que cambiar de dominio es tocar solo esta variable |
|
||||
| `EMAIL_*` | SMTP (hoy Proton Mail, `smtp.protonmail.ch:587` con STARTTLS). `EMAIL_HOST_USER` es además el remitente, y Proton exige que coincida con la dirección del token |
|
||||
| `AC_SOAP_*` | Conexión SOAP a AzerothCore (envío de ítems, etc.) |
|
||||
| `STRIPE_*` / `SUMUP_*` | Pasarelas de pago |
|
||||
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Captcha de Cloudflare |
|
||||
| `ADMIN_EMAILS` / `ADMIN_GMLEVEL` | Acceso al panel de administración |
|
||||
| `CRON_SECRET` | Protege el endpoint de reconciliación de SumUp |
|
||||
|
||||
Generar una `SECRET_KEY` nueva:
|
||||
## 3. Producción
|
||||
|
||||
- **Servicio:** `novawow-next.service` → `next start -p 3001`
|
||||
- **Proxy:** Caddy. `nightspire.gg` redirige a `www.nightspire.gg`, que va a `127.0.0.1:3001`;
|
||||
`next.nightspire.gg` apunta al mismo sitio.
|
||||
- **Timer:** `novawow-dpoints-reconcile.timer` llama cada 2 min a `/api/dpoints/reconcile`
|
||||
(SumUp no manda webhooks, así que se reconcilia sondeando).
|
||||
|
||||
```bash
|
||||
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||
cd web-next && npm run build && systemctl restart novawow-next
|
||||
```
|
||||
|
||||
---
|
||||
## 4. Estructura
|
||||
|
||||
## 4. Producción
|
||||
|
||||
```bash
|
||||
# Recopilar estáticos
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Servir con gunicorn (4 workers)
|
||||
gunicorn novawow.wsgi:application --bind 0.0.0.0:8000 --workers 4
|
||||
```
|
||||
web-next/
|
||||
app/[locale]/ páginas (una carpeta por ruta)
|
||||
components/ componentes React
|
||||
lib/ acceso a datos y lógica (SQL directo, sin ORM)
|
||||
messages/ traducciones ES/EN
|
||||
public/ estáticos, incluido el tema nw-ryu
|
||||
sql/db2/ utilidades para leer los DB2 del cliente 3.4.3
|
||||
docs/ documentación (foro, migración a 3.4.3)
|
||||
sql/ esquemas y semillas (foro, precios, etc.)
|
||||
```
|
||||
|
||||
Recomendaciones:
|
||||
## 5. Notas
|
||||
|
||||
- `DJANGO_DEBUG=False` y una `DJANGO_SECRET_KEY` propia y secreta.
|
||||
- Poner **Nginx** delante como proxy inverso y para servir `staticfiles/`.
|
||||
- Revisar `ALLOWED_HOSTS` y `CSRF_TRUSTED_ORIGINS` en `novawow/settings.py`.
|
||||
- En 3.4.3 **no existe `item_template`**: los datos de ítems salen de los `.db2` del cliente
|
||||
(ver `web-next/sql/db2/`).
|
||||
- La tienda vive en `/store-<realm>` en minúsculas (p. ej. `/store-trinity`); `/store` a secas da 404.
|
||||
- Los correos se construyen en `web-next/lib/emails.ts`: una sola maqueta compartida por las 8
|
||||
plantillas, con todas las URLs derivadas de `SITE_URL`.
|
||||
|
||||
---
|
||||
## 6. Seguridad
|
||||
|
||||
## 5. Docker
|
||||
|
||||
Alternativamente, se puede levantar todo con Docker (web + MySQL):
|
||||
|
||||
```bash
|
||||
cp .env.example .env # ajusta los secretos (no las variables DB_*, las fija compose)
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- La web queda en http://127.0.0.1:8000
|
||||
- El servicio `db` crea automáticamente las 4 bases de datos.
|
||||
- El `entrypoint` espera a MySQL, aplica migraciones y recopila estáticos antes de arrancar gunicorn.
|
||||
|
||||
Ver `docker-compose.yml` y `Dockerfile` para los detalles.
|
||||
|
||||
---
|
||||
|
||||
## 6. Notas de seguridad
|
||||
|
||||
- Los secretos que estuvieron hardcodeados en el historial de git deben **rotarse**
|
||||
(contraseña MySQL/SOAP, App Password de Gmail, claves de Stripe/SumUp).
|
||||
- Nunca subas el archivo `.env` al repositorio.
|
||||
- Los secretos que estuvieron hardcodeados en el historial de git deben **rotarse**: contraseña de
|
||||
MySQL/SOAP, credenciales SMTP y claves de Stripe/SumUp.
|
||||
- Ni `.env.local` ni ningún fichero con secretos se versionan.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpass
|
||||
MYSQL_DATABASE: django_wow
|
||||
MYSQL_USER: novawow
|
||||
MYSQL_PASSWORD: novawow_pass
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
- ./docker/mysql-init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-prootpass"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
web:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
# Secretos de la app (Stripe, SumUp, SOAP, email, SECRET_KEY...) desde .env
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
# Las variables de BD las fija compose para apuntar al servicio 'db'
|
||||
environment:
|
||||
DJANGO_DEBUG: "False"
|
||||
DB_HOST: db
|
||||
DB_PORT: "3306"
|
||||
DB_USER: novawow
|
||||
DB_PASSWORD: novawow_pass
|
||||
DB_NAME_DEFAULT: django_wow
|
||||
DB_NAME_AUTH: acore_auth
|
||||
DB_NAME_CHARACTERS: acore_characters
|
||||
DB_NAME_WORLD: acore_world
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# Espera a que MySQL esté disponible
|
||||
if [ -n "$DB_HOST" ]; then
|
||||
echo "Esperando a MySQL en $DB_HOST:${DB_PORT:-3306}..."
|
||||
until nc -z "$DB_HOST" "${DB_PORT:-3306}"; do
|
||||
sleep 1
|
||||
done
|
||||
echo "MySQL disponible."
|
||||
fi
|
||||
|
||||
# Migraciones (solo la base 'default' del portal)
|
||||
echo "Aplicando migraciones..."
|
||||
python manage.py migrate --noinput
|
||||
|
||||
# Estáticos
|
||||
echo "Recopilando archivos estáticos..."
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Ejecuta el comando indicado (gunicorn por defecto)
|
||||
exec "$@"
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Crea las bases de datos que necesita el portal.
|
||||
-- La del portal (django_wow) y las de AzerothCore.
|
||||
-- Las de AzerothCore quedan vacías; en un entorno real las provee el core del juego.
|
||||
CREATE DATABASE IF NOT EXISTS `django_wow` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE DATABASE IF NOT EXISTS `acore_auth` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE DATABASE IF NOT EXISTS `acore_characters` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE DATABASE IF NOT EXISTS `acore_world` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Concede permisos al usuario de la aplicación sobre las 4 bases
|
||||
GRANT ALL PRIVILEGES ON `django_wow`.* TO 'novawow'@'%';
|
||||
GRANT ALL PRIVILEGES ON `acore_auth`.* TO 'novawow'@'%';
|
||||
GRANT ALL PRIVILEGES ON `acore_characters`.* TO 'novawow'@'%';
|
||||
GRANT ALL PRIVILEGES ON `acore_world`.* TO 'novawow'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ForumConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'forum'
|
||||
verbose_name = 'Foro'
|
||||
-414
@@ -1,414 +0,0 @@
|
||||
"""
|
||||
Acceso a datos del foro (BD ``acore_web``), con SQL directo — mismo estilo que
|
||||
el resto del proyecto (``connections['acore_web']``).
|
||||
|
||||
Tablas: forum_categories, forums, forum_topics, forum_posts, forum_reads.
|
||||
"""
|
||||
|
||||
from django.db import connections
|
||||
|
||||
|
||||
def _dictfetchall(cursor):
|
||||
cols = [c[0] for c in cursor.description]
|
||||
return [dict(zip(cols, row)) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def _dictfetchone(cursor):
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
cols = [c[0] for c in cursor.description]
|
||||
return dict(zip(cols, row))
|
||||
|
||||
|
||||
def _conn():
|
||||
return connections['acore_web']
|
||||
|
||||
|
||||
# Detección perezosa de la columna opcional forum_topics.views (contador de visitas)
|
||||
_views_supported = None
|
||||
|
||||
|
||||
def has_views_column():
|
||||
global _views_supported
|
||||
if _views_supported is None:
|
||||
try:
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute("SELECT views FROM forum_topics LIMIT 1")
|
||||
_views_supported = True
|
||||
except Exception:
|
||||
_views_supported = False
|
||||
return _views_supported
|
||||
|
||||
|
||||
def increment_views(topic_id):
|
||||
"""Suma 1 a las visitas del tema (si existe la columna)."""
|
||||
if not has_views_column():
|
||||
return
|
||||
try:
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE forum_topics SET views = views + 1 WHERE id = %s", [topic_id]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Portada: categorías + foros (con estadísticas)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def get_index(include_hidden=False):
|
||||
"""
|
||||
Devuelve las categorías ordenadas, cada una con sus foros (visibles y no
|
||||
borrados salvo include_hidden) y estadísticas: nº de temas, nº de posts y
|
||||
último tema.
|
||||
"""
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id, `order`, name FROM forum_categories ORDER BY `order`, id"
|
||||
)
|
||||
categories = _dictfetchall(cursor)
|
||||
|
||||
vis = "" if include_hidden else "AND f.visibility = 1 AND f.deleted = 0"
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT f.id, f.category_id, f.`order`, f.name, f.description, f.icon,
|
||||
f.colortitle, f.type, f.visibility, f.deleted,
|
||||
(SELECT COUNT(*) FROM forum_topics t
|
||||
WHERE t.forum_id = f.id AND t.deleted = 0) AS topic_count,
|
||||
(SELECT COUNT(*) FROM forum_posts p
|
||||
JOIN forum_topics t ON p.topic_id = t.id
|
||||
WHERE t.forum_id = f.id AND p.deleted = 0 AND t.deleted = 0) AS post_count,
|
||||
(SELECT t2.id FROM forum_topics t2
|
||||
WHERE t2.forum_id = f.id AND t2.deleted = 0
|
||||
ORDER BY t2.updated_at DESC, t2.id DESC LIMIT 1) AS last_topic_id,
|
||||
(SELECT t3.name FROM forum_topics t3
|
||||
WHERE t3.forum_id = f.id AND t3.deleted = 0
|
||||
ORDER BY t3.updated_at DESC, t3.id DESC LIMIT 1) AS last_topic_name,
|
||||
(SELECT t4.poster FROM forum_topics t4
|
||||
WHERE t4.forum_id = f.id AND t4.deleted = 0
|
||||
ORDER BY t4.updated_at DESC, t4.id DESC LIMIT 1) AS last_topic_poster,
|
||||
(SELECT t5.updated_at FROM forum_topics t5
|
||||
WHERE t5.forum_id = f.id AND t5.deleted = 0
|
||||
ORDER BY t5.updated_at DESC, t5.id DESC LIMIT 1) AS last_topic_time
|
||||
FROM forums f
|
||||
WHERE 1=1 {vis}
|
||||
ORDER BY f.`order`, f.id
|
||||
"""
|
||||
)
|
||||
forums = _dictfetchall(cursor)
|
||||
|
||||
by_cat = {}
|
||||
for f in forums:
|
||||
by_cat.setdefault(f['category_id'], []).append(f)
|
||||
for c in categories:
|
||||
c['forums'] = by_cat.get(c['id'], [])
|
||||
# Solo categorías con foros visibles (salvo include_hidden)
|
||||
if not include_hidden:
|
||||
categories = [c for c in categories if c['forums']]
|
||||
return categories
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Foro individual y sus temas (paginado)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def get_forum(forum_id, include_hidden=False):
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id, category_id, `order`, name, description, icon, colortitle, "
|
||||
"type, visibility, deleted FROM forums WHERE id = %s",
|
||||
[forum_id],
|
||||
)
|
||||
forum = _dictfetchone(cursor)
|
||||
if not forum:
|
||||
return None
|
||||
if not include_hidden and (forum['deleted'] or not forum['visibility']):
|
||||
return None
|
||||
return forum
|
||||
|
||||
|
||||
def count_topics(forum_id):
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM forum_topics WHERE forum_id = %s AND deleted = 0",
|
||||
[forum_id],
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
|
||||
def get_topics(forum_id, offset, limit):
|
||||
views_sel = "t.views," if has_views_column() else "0 AS views,"
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT t.id, t.name, t.poster, t.poster_id, t.created, t.sticky,
|
||||
t.locked, t.moved, t.updated_at, {views_sel}
|
||||
(SELECT COUNT(*) FROM forum_posts p
|
||||
WHERE p.topic_id = t.id AND p.deleted = 0) AS post_count,
|
||||
(SELECT p2.poster FROM forum_posts p2
|
||||
WHERE p2.topic_id = t.id AND p2.deleted = 0
|
||||
ORDER BY p2.time DESC, p2.id DESC LIMIT 1) AS last_poster,
|
||||
(SELECT p3.time FROM forum_posts p3
|
||||
WHERE p3.topic_id = t.id AND p3.deleted = 0
|
||||
ORDER BY p3.time DESC, p3.id DESC LIMIT 1) AS last_time
|
||||
FROM forum_topics t
|
||||
WHERE t.forum_id = %s AND t.deleted = 0
|
||||
ORDER BY t.sticky DESC, t.updated_at DESC, t.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
[forum_id, limit, offset],
|
||||
)
|
||||
return _dictfetchall(cursor)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tema individual y sus posts (paginado)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def get_topic(topic_id, include_hidden=False):
|
||||
views_sel = ", views" if has_views_column() else ", 0 AS views"
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id, forum_id, name, poster, poster_id, created, sticky, "
|
||||
f"locked, deleted, moved, updated_at{views_sel} FROM forum_topics WHERE id = %s",
|
||||
[topic_id],
|
||||
)
|
||||
topic = _dictfetchone(cursor)
|
||||
if not topic:
|
||||
return None
|
||||
if not include_hidden and topic['deleted']:
|
||||
return None
|
||||
return topic
|
||||
|
||||
|
||||
def count_posts(topic_id, include_deleted=False):
|
||||
delf = "" if include_deleted else "AND deleted = 0"
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"SELECT COUNT(*) FROM forum_posts WHERE topic_id = %s {delf}",
|
||||
[topic_id],
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
|
||||
def get_posts(topic_id, offset, limit, include_deleted=False):
|
||||
delf = "" if include_deleted else "AND deleted = 0"
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT id, topic_id, poster, poster_id, text, time, deleted, updated_at
|
||||
FROM forum_posts
|
||||
WHERE topic_id = %s {delf}
|
||||
ORDER BY time ASC, id ASC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
[topic_id, limit, offset],
|
||||
)
|
||||
return _dictfetchall(cursor)
|
||||
|
||||
|
||||
def get_post(post_id):
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id, topic_id, poster, poster_id, text, time, deleted, updated_at "
|
||||
"FROM forum_posts WHERE id = %s",
|
||||
[post_id],
|
||||
)
|
||||
return _dictfetchone(cursor)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Escritura: crear tema, responder, editar, borrar/restaurar
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def create_topic(forum_id, name, poster, poster_id, text):
|
||||
"""Crea un tema y su primer post. Devuelve el id del tema."""
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"INSERT INTO forum_topics "
|
||||
"(forum_id, name, poster, poster_id, created, created_at, updated_at, locked, sticky, deleted) "
|
||||
"VALUES (%s, %s, %s, %s, NOW(), NOW(), NOW(), 0, 0, 0)",
|
||||
[forum_id, name, poster, poster_id],
|
||||
)
|
||||
cursor.execute("SELECT LAST_INSERT_ID()")
|
||||
topic_id = cursor.fetchone()[0]
|
||||
cursor.execute(
|
||||
"INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) "
|
||||
"VALUES (%s, %s, %s, %s, NOW(), NOW(), 0)",
|
||||
[topic_id, poster, poster_id, text],
|
||||
)
|
||||
return topic_id
|
||||
|
||||
|
||||
def create_post(topic_id, poster, poster_id, text):
|
||||
"""Añade una respuesta y actualiza la marca de tiempo del tema."""
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) "
|
||||
"VALUES (%s, %s, %s, %s, NOW(), NOW(), 0)",
|
||||
[topic_id, poster, poster_id, text],
|
||||
)
|
||||
cursor.execute("SELECT LAST_INSERT_ID()")
|
||||
post_id = cursor.fetchone()[0]
|
||||
cursor.execute(
|
||||
"UPDATE forum_topics SET updated_at = NOW() WHERE id = %s", [topic_id]
|
||||
)
|
||||
return post_id
|
||||
|
||||
|
||||
def get_author_info(poster_id):
|
||||
"""
|
||||
Datos del autor para mostrar en los posts: nivel GM, fecha de registro y
|
||||
nº de mensajes. Cruza acore_auth (account/account_access) y acore_web.
|
||||
"""
|
||||
info = {'gmlevel': 0, 'joindate': None, 'post_count': 0, 'status': 'Miembro',
|
||||
'display_name': None}
|
||||
if not poster_id:
|
||||
return info
|
||||
info['display_name'] = get_display_name(poster_id)
|
||||
try:
|
||||
with connections['acore_auth'].cursor() as cursor:
|
||||
cursor.execute("SELECT MAX(gmlevel) FROM account_access WHERE id = %s", [poster_id])
|
||||
row = cursor.fetchone()
|
||||
info['gmlevel'] = int(row[0]) if row and row[0] is not None else 0
|
||||
cursor.execute("SELECT joindate FROM account WHERE id = %s", [poster_id])
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
info['joindate'] = row[0]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM forum_posts WHERE poster_id = %s AND deleted = 0",
|
||||
[poster_id],
|
||||
)
|
||||
info['post_count'] = cursor.fetchone()[0]
|
||||
except Exception:
|
||||
pass
|
||||
if info['gmlevel'] >= 16:
|
||||
info['status'] = 'Administrador'
|
||||
elif info['gmlevel'] >= 1:
|
||||
info['status'] = 'GM'
|
||||
return info
|
||||
|
||||
|
||||
def update_post(post_id, text):
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE forum_posts SET text = %s, updated_at = NOW() WHERE id = %s",
|
||||
[text, post_id],
|
||||
)
|
||||
|
||||
|
||||
def set_post_deleted(post_id, deleted):
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE forum_posts SET deleted = %s WHERE id = %s",
|
||||
[1 if deleted else 0, post_id],
|
||||
)
|
||||
|
||||
|
||||
def set_topic_flag(topic_id, field, value):
|
||||
"""field ∈ {sticky, locked, deleted, moved}."""
|
||||
if field not in ('sticky', 'locked', 'deleted', 'moved'):
|
||||
raise ValueError('campo no permitido')
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"UPDATE forum_topics SET {field} = %s WHERE id = %s",
|
||||
[1 if value else 0, topic_id],
|
||||
)
|
||||
|
||||
|
||||
def move_topic(topic_id, new_forum_id):
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE forum_topics SET forum_id = %s, moved = 1 WHERE id = %s",
|
||||
[new_forum_id, topic_id],
|
||||
)
|
||||
|
||||
|
||||
def mark_read(user_id, topic_id):
|
||||
if not user_id:
|
||||
return
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"INSERT INTO forum_reads (user_id, topic_id, read_at) "
|
||||
"VALUES (%s, %s, NOW()) "
|
||||
"ON DUPLICATE KEY UPDATE read_at = NOW()",
|
||||
[user_id, topic_id],
|
||||
)
|
||||
|
||||
|
||||
def get_display_name(poster_id):
|
||||
"""
|
||||
Nombre legible del autor a partir de su cuenta: parte local del email
|
||||
(antes de la @). Si no hay email, devuelve None (la plantilla usa el poster).
|
||||
"""
|
||||
if not poster_id:
|
||||
return None
|
||||
try:
|
||||
with connections['acore_auth'].cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT email, reg_mail FROM account WHERE id = %s", [poster_id]
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
except Exception:
|
||||
return None
|
||||
if not row:
|
||||
return None
|
||||
email = (row[0] or row[1] or '').strip()
|
||||
if '@' in email:
|
||||
return email.split('@', 1)[0]
|
||||
return None
|
||||
|
||||
|
||||
def count_search_topics(query):
|
||||
like = f"%{query}%"
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT t.id)
|
||||
FROM forum_topics t
|
||||
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
|
||||
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
|
||||
WHERE t.deleted = 0 AND (t.name LIKE %s OR p.text LIKE %s)
|
||||
""",
|
||||
[like, like],
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
|
||||
def search_topics(query, offset=0, limit=20):
|
||||
"""Busca temas por título o por contenido de sus posts (foros visibles)."""
|
||||
like = f"%{query}%"
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT DISTINCT t.id, t.name, t.poster, t.poster_id, t.forum_id,
|
||||
t.created, t.updated_at, f.name AS forum_name
|
||||
FROM forum_topics t
|
||||
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
|
||||
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
|
||||
WHERE t.deleted = 0 AND (t.name LIKE %s OR p.text LIKE %s)
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
[like, like, limit, offset],
|
||||
)
|
||||
return _dictfetchall(cursor)
|
||||
|
||||
|
||||
def list_forums_for_move(exclude_forum_id=None):
|
||||
"""Lista de foros visibles para el desplegable de 'mover tema'."""
|
||||
with _conn().cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id, name FROM forums WHERE visibility = 1 AND deleted = 0 "
|
||||
"ORDER BY `order`, id"
|
||||
)
|
||||
forums = _dictfetchall(cursor)
|
||||
if exclude_forum_id:
|
||||
forums = [f for f in forums if f['id'] != exclude_forum_id]
|
||||
return forums
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
Formularios del foro con el editor enriquecido CKEditor 5 (el mismo que usa el
|
||||
resto de NovaWoW). El HTML resultante se sanea con nh3 al guardar (ver views).
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||
|
||||
|
||||
class TopicForm(forms.Form):
|
||||
name = forms.CharField(
|
||||
max_length=45,
|
||||
widget=forms.TextInput(attrs={'placeholder': 'Título del tema', 'maxlength': 45}),
|
||||
)
|
||||
text = forms.CharField(widget=CKEditor5Widget(config_name='default'))
|
||||
|
||||
|
||||
class ReplyForm(forms.Form):
|
||||
text = forms.CharField(widget=CKEditor5Widget(config_name='default'))
|
||||
|
||||
|
||||
class EditPostForm(forms.Form):
|
||||
text = forms.CharField(widget=CKEditor5Widget(config_name='default'))
|
||||
@@ -1,71 +0,0 @@
|
||||
"""
|
||||
Modelo de permisos del foro (versión simplificada y mantenible).
|
||||
|
||||
En lugar de la matriz group_level×permission del proyecto original, usamos
|
||||
reglas claras basadas en:
|
||||
* si el usuario ha iniciado sesión y tiene una cuenta de juego seleccionada
|
||||
(``request.session['account_id']``),
|
||||
* su nivel GM (``acore_auth.account_access.gmlevel``),
|
||||
* y la propiedad del contenido (autor == usuario).
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connections
|
||||
|
||||
|
||||
def get_account_id(request):
|
||||
"""Id de la cuenta de juego seleccionada en sesión, o None."""
|
||||
return request.session.get('account_id')
|
||||
|
||||
|
||||
def is_authenticated(request):
|
||||
return bool(request.session.get('account_id'))
|
||||
|
||||
|
||||
def get_gmlevel(account_id):
|
||||
"""Nivel GM máximo de una cuenta (acore_auth.account_access). 0 si no tiene."""
|
||||
if not account_id:
|
||||
return 0
|
||||
try:
|
||||
with connections['acore_auth'].cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT MAX(gmlevel) FROM account_access WHERE id = %s",
|
||||
[account_id],
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return int(row[0]) if row and row[0] is not None else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def is_moderator(request):
|
||||
"""¿El usuario puede moderar el foro?"""
|
||||
account_id = get_account_id(request)
|
||||
if not account_id:
|
||||
return False
|
||||
return get_gmlevel(account_id) >= settings.FORUM_MOD_GMLEVEL
|
||||
|
||||
|
||||
def can_post(request):
|
||||
"""¿Puede crear temas / responder? Cualquier usuario autenticado."""
|
||||
return is_authenticated(request)
|
||||
|
||||
|
||||
def can_edit_post(request, post):
|
||||
"""Puede editar/borrar un post: su autor o un moderador."""
|
||||
account_id = get_account_id(request)
|
||||
if not account_id:
|
||||
return False
|
||||
if post and int(post.get('poster_id') or 0) == int(account_id):
|
||||
return True
|
||||
return is_moderator(request)
|
||||
|
||||
|
||||
def forum_context(request):
|
||||
"""Datos comunes de permisos para las plantillas."""
|
||||
return {
|
||||
'forum_is_authenticated': is_authenticated(request),
|
||||
'forum_is_moderator': is_moderator(request),
|
||||
'forum_account_id': get_account_id(request),
|
||||
'forum_username': request.session.get('username'),
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
"""
|
||||
Saneado del HTML de los posts del foro (el editor del cliente puede enviar HTML).
|
||||
|
||||
El proyecto original guardaba el HTML sin sanear y lo renderizaba con |raw
|
||||
(XSS almacenado). Aquí lo saneamos con nh3 (allowlist) al guardar, de modo que
|
||||
la plantilla puede renderizarlo con |safe sin riesgo.
|
||||
"""
|
||||
|
||||
import nh3
|
||||
|
||||
# Etiquetas permitidas (formato básico de foro)
|
||||
_ALLOWED_TAGS = {
|
||||
"p", "br", "hr", "span", "div",
|
||||
"strong", "b", "em", "i", "u", "s", "strike", "sub", "sup",
|
||||
"ul", "ol", "li", "blockquote", "code", "pre",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"a", "img",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
}
|
||||
|
||||
_ALLOWED_ATTRIBUTES = {
|
||||
"a": {"href", "title", "target"},
|
||||
"img": {"src", "alt", "title", "width", "height"},
|
||||
"span": {"style"},
|
||||
"div": {"style"},
|
||||
"td": {"colspan", "rowspan"},
|
||||
"th": {"colspan", "rowspan"},
|
||||
}
|
||||
|
||||
# Solo esquemas de URL seguros
|
||||
_ALLOWED_URL_SCHEMES = {"http", "https", "mailto"}
|
||||
|
||||
|
||||
def clean_post_html(html):
|
||||
"""Devuelve el HTML saneado y seguro para renderizar con |safe."""
|
||||
if not html:
|
||||
return ""
|
||||
return nh3.clean(
|
||||
html,
|
||||
tags=_ALLOWED_TAGS,
|
||||
attributes=_ALLOWED_ATTRIBUTES,
|
||||
url_schemes=_ALLOWED_URL_SCHEMES,
|
||||
link_rel="noopener noreferrer nofollow",
|
||||
)
|
||||
|
||||
|
||||
def plain_length(html):
|
||||
"""Longitud del texto plano (sin etiquetas), para validar longitud mínima."""
|
||||
if not html:
|
||||
return 0
|
||||
text = nh3.clean(html, tags=set(), attributes={})
|
||||
return len(text.strip())
|
||||
@@ -1,45 +0,0 @@
|
||||
<style>
|
||||
.forum-wrap { max-width: 1000px; margin: 0 auto; }
|
||||
.forum-cat { margin-bottom: 24px; }
|
||||
.forum-cat-title { font-size: 1.2rem; color: #d79602; border-bottom: 1px solid rgba(215,150,2,.4); padding: 6px 4px; margin-bottom: 8px; }
|
||||
.forum-row, .topic-row { display: flex; align-items: center; gap: 12px; padding: 12px 10px; border-bottom: 1px solid rgba(255,255,255,.08); }
|
||||
.forum-row:hover, .topic-row:hover { background: rgba(255,255,255,.04); }
|
||||
.forum-row .main, .topic-row .main { flex: 1; min-width: 0; }
|
||||
.forum-row .name, .topic-row .name { color: #e0c07a; font-weight: bold; text-decoration: none; }
|
||||
.forum-row .desc { color: #b1997f; font-size: .85rem; }
|
||||
.forum-row .stats, .topic-row .meta { text-align: right; font-size: .8rem; color: #b1997f; white-space: nowrap; }
|
||||
.forum-badge { display: inline-block; font-size: .7rem; padding: 1px 6px; border-radius: 3px; background: #7a5c1e; color: #fff; margin-right: 4px; }
|
||||
.forum-post { display: flex; gap: 0; border: 1px solid rgba(255,255,255,.1); margin-bottom: 16px; background: rgba(0,0,0,.15); }
|
||||
.forum-post .left { width: 180px; padding: 14px; background: rgba(0,0,0,.25); text-align: center; }
|
||||
.forum-post .left .pname { color: #e0c07a; font-weight: bold; }
|
||||
.forum-post .left .prole { font-size: .8rem; color: #d79602; }
|
||||
.forum-post .left .pmeta { font-size: .75rem; color: #b1997f; margin-top: 6px; }
|
||||
.forum-post .right { flex: 1; padding: 14px; min-width: 0; }
|
||||
.forum-post .pbody { white-space: pre-wrap; word-wrap: break-word; color: #e8e0d0; }
|
||||
.forum-post .pbody img { max-width: 100%; height: auto; }
|
||||
.forum-post .pfoot { margin-top: 12px; font-size: .75rem; color: #8a7a63; display: flex; justify-content: space-between; }
|
||||
.forum-actions { margin: 14px 0; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.forum-btn { display: inline-block; padding: 6px 14px; background: #7a5c1e; color: #fff !important; border: none; border-radius: 3px; cursor: pointer; text-decoration: none; font-size: .85rem; }
|
||||
.forum-btn:hover { background: #d79602; }
|
||||
.forum-btn.danger { background: #7a2020; }
|
||||
.forum-btn.danger:hover { background: #c41e3a; }
|
||||
.forum-pagination { margin: 16px 0; text-align: center; }
|
||||
.forum-pagination a, .forum-pagination span { display: inline-block; padding: 4px 9px; margin: 0 2px; border: 1px solid rgba(215,150,2,.4); color: #d79602; text-decoration: none; border-radius: 3px; }
|
||||
.forum-pagination .current { background: #7a5c1e; color: #fff; }
|
||||
.forum-form textarea { width: 100%; min-height: 180px; background: rgba(0,0,0,.3); color: #e8e0d0; border: 1px solid rgba(215,150,2,.4); padding: 10px; border-radius: 3px; }
|
||||
.forum-form input[type=text] { width: 100%; background: rgba(0,0,0,.3); color: #e8e0d0; border: 1px solid rgba(215,150,2,.4); padding: 8px; border-radius: 3px; margin-bottom: 10px; }
|
||||
.forum-msg { padding: 8px 12px; border-radius: 3px; margin-bottom: 10px; }
|
||||
.forum-msg.error { background: rgba(196,30,58,.2); color: #ff9a9a; }
|
||||
.forum-msg.success { background: rgba(46,160,67,.2); color: #9affb0; }
|
||||
.forum-deleted { opacity: .55; }
|
||||
.forum-inline { display: inline; }
|
||||
.link-btn { background: none; border: none; color: #d79602; cursor: pointer; padding: 0; font: inherit; text-decoration: underline; }
|
||||
.link-btn.danger { color: #ff9a9a; }
|
||||
</style>
|
||||
{% if messages %}
|
||||
<div class="forum-messages forum-wrap">
|
||||
{% for message in messages %}
|
||||
<div class="forum-msg {{ message.tags }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,35 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
{% include 'partials/head.html' %}
|
||||
{% include 'partials/header.html' %}
|
||||
{% include 'partials/video.html' %}
|
||||
|
||||
<div class="main-page">
|
||||
<div class="middle-content">
|
||||
<div class="body-content">
|
||||
<div class="title-content"><h1>Nuevo tema</h1></div>
|
||||
<div class="box-content">
|
||||
<div class="body-box-content">
|
||||
{% include 'forum/_style.html' %}
|
||||
<div class="forum-wrap forum-form">
|
||||
<p style="color:#b1997f">Foro: <strong>{{ forum.name }}</strong></p>
|
||||
<form action="{% url 'forum_create_topic_submit' forum_id=forum.id %}" method="POST">
|
||||
{% csrf_token %}
|
||||
{{ form.name }}
|
||||
{{ form.text }}
|
||||
<div class="forum-actions">
|
||||
<button type="submit" class="forum-btn">Publicar tema</button>
|
||||
<a class="forum-btn" href="{% url 'forum_view_p1' forum_id=forum.id %}">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ form.media }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'partials/social.html' %}
|
||||
{% include 'partials/footer.html' %}
|
||||
{% include 'partials/final.html' %}
|
||||
@@ -1,33 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
{% include 'partials/head.html' %}
|
||||
{% include 'partials/header.html' %}
|
||||
{% include 'partials/video.html' %}
|
||||
|
||||
<div class="main-page">
|
||||
<div class="middle-content">
|
||||
<div class="body-content">
|
||||
<div class="title-content"><h1>Editar mensaje</h1></div>
|
||||
<div class="box-content">
|
||||
<div class="body-box-content">
|
||||
{% include 'forum/_style.html' %}
|
||||
<div class="forum-wrap forum-form">
|
||||
<form action="{% url 'forum_update_post' post_id=post.id %}" method="POST">
|
||||
{% csrf_token %}
|
||||
{{ form.text }}
|
||||
<div class="forum-actions">
|
||||
<button type="submit" class="forum-btn">Guardar cambios</button>
|
||||
<a class="forum-btn" href="{% url 'forum_topic_p1' topic_id=post.topic_id %}">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ form.media }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'partials/social.html' %}
|
||||
{% include 'partials/footer.html' %}
|
||||
{% include 'partials/final.html' %}
|
||||
@@ -1,63 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
{% include 'partials/head.html' %}
|
||||
{% include 'partials/header.html' %}
|
||||
{% include 'partials/video.html' %}
|
||||
|
||||
<div class="main-page">
|
||||
<div class="middle-content">
|
||||
<div class="body-content">
|
||||
<div class="title-content"><h1>{{ forum.name }}</h1></div>
|
||||
<div class="box-content">
|
||||
<div class="body-box-content">
|
||||
{% include 'forum/_style.html' %}
|
||||
<div class="forum-wrap">
|
||||
<p class="desc" style="color:#b1997f">{{ forum.description }}</p>
|
||||
<div class="forum-actions">
|
||||
<a class="forum-btn" href="{% url 'app_forum' %}">← Volver al foro</a>
|
||||
{% if forum_is_authenticated %}
|
||||
<a class="forum-btn" href="{% url 'forum_create_topic' forum_id=forum.id %}">Crear nuevo tema</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% for topic in topics %}
|
||||
<div class="topic-row">
|
||||
<div class="main">
|
||||
<a class="name" href="{% url 'forum_topic_p1' topic_id=topic.id %}">
|
||||
{% if topic.sticky %}<span class="forum-badge">Fijado</span>{% endif %}
|
||||
{% if topic.locked %}<span class="forum-badge">Bloqueado</span>{% endif %}
|
||||
{% if topic.moved %}<span class="forum-badge">Movido</span>{% endif %}
|
||||
{{ topic.name }}
|
||||
</a>
|
||||
<div class="desc">por {{ topic.display_poster }} · {{ topic.created }}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>{{ topic.post_count }} respuestas{% if has_views %} · {{ topic.views }} vistas{% endif %}</div>
|
||||
{% if topic.last_poster %}<div>último: {{ topic.last_poster }}</div>{% endif %}
|
||||
{% if topic.last_time %}<div>{{ topic.last_time }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>No hay temas todavía. ¡Sé el primero en publicar!</p>
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.total_pages > 1 %}
|
||||
<div class="forum-pagination">
|
||||
{% if pagination.has_prev %}<a href="{% url 'forum_view' forum_id=forum.id page=pagination.prev_page %}">« Anterior</a>{% endif %}
|
||||
{% for p in pagination.page_range %}
|
||||
{% if p == pagination.page %}<span class="current">{{ p }}</span>
|
||||
{% else %}<a href="{% url 'forum_view' forum_id=forum.id page=p %}">{{ p }}</a>{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination.has_next %}<a href="{% url 'forum_view' forum_id=forum.id page=pagination.next_page %}">Siguiente »</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'partials/social.html' %}
|
||||
{% include 'partials/footer.html' %}
|
||||
{% include 'partials/final.html' %}
|
||||
@@ -1,55 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
{% include 'partials/head.html' %}
|
||||
{% include 'partials/header.html' %}
|
||||
{% include 'partials/video.html' %}
|
||||
|
||||
<div class="main-page">
|
||||
<div class="middle-content">
|
||||
<div class="body-content">
|
||||
<div class="title-content"><h1>Foro de la comunidad</h1></div>
|
||||
<div class="box-content">
|
||||
<div class="body-box-content">
|
||||
{% include 'forum/_style.html' %}
|
||||
<div class="forum-wrap">
|
||||
<form class="forum-search" action="{% url 'forum_search' %}" method="GET" style="margin-bottom:16px">
|
||||
<input type="text" name="q" placeholder="Buscar en el foro..."
|
||||
style="width:70%;background:rgba(0,0,0,.3);color:#e8e0d0;border:1px solid rgba(215,150,2,.4);padding:8px;border-radius:3px">
|
||||
<button type="submit" class="forum-btn">Buscar</button>
|
||||
</form>
|
||||
{% for category in categories %}
|
||||
<div class="forum-cat">
|
||||
<div class="forum-cat-title">{{ category.name }}</div>
|
||||
{% for forum in category.forums %}
|
||||
<div class="forum-row {% if forum.deleted %}forum-deleted{% endif %}">
|
||||
<div class="main">
|
||||
<a class="name" href="{% url 'forum_view_p1' forum_id=forum.id %}"
|
||||
{% if forum.colortitle and forum.colortitle != '0' %}style="color:{{ forum.colortitle }}"{% endif %}>
|
||||
{{ forum.name }}
|
||||
</a>
|
||||
{% if not forum.visibility %}<span class="forum-badge">Oculto</span>{% endif %}
|
||||
<div class="desc">{{ forum.description }}</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div>{{ forum.topic_count }} temas</div>
|
||||
<div>{{ forum.post_count }} mensajes</div>
|
||||
{% if forum.last_topic_name %}
|
||||
<div>Último: <a href="{% url 'forum_topic_p1' topic_id=forum.last_topic_id %}">{{ forum.last_topic_name|truncatechars:30 }}</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>Todavía no hay foros disponibles.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'partials/social.html' %}
|
||||
{% include 'partials/footer.html' %}
|
||||
{% include 'partials/final.html' %}
|
||||
@@ -1,30 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
{% include 'partials/head.html' %}
|
||||
{% include 'partials/header.html' %}
|
||||
{% include 'partials/video.html' %}
|
||||
|
||||
<div class="main-page">
|
||||
<div class="middle-content">
|
||||
<div class="body-content">
|
||||
<div class="title-content"><h1>Perfil de {{ profile.username }}</h1></div>
|
||||
<div class="box-content">
|
||||
<div class="body-box-content">
|
||||
{% include 'forum/_style.html' %}
|
||||
<div class="forum-wrap">
|
||||
<p>Temas creados: <strong>{{ profile.topic_count }}</strong></p>
|
||||
<p>Mensajes publicados: <strong>{{ profile.post_count }}</strong></p>
|
||||
{% if profile.joindate %}<p>Registro: <strong>{{ profile.joindate|date:"d/m/Y" }}</strong></p>{% endif %}
|
||||
<div class="forum-actions">
|
||||
<a class="forum-btn" href="{% url 'app_forum' %}">← Volver al foro</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'partials/social.html' %}
|
||||
{% include 'partials/footer.html' %}
|
||||
{% include 'partials/final.html' %}
|
||||
@@ -1,60 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
{% include 'partials/head.html' %}
|
||||
{% include 'partials/header.html' %}
|
||||
{% include 'partials/video.html' %}
|
||||
|
||||
<div class="main-page">
|
||||
<div class="middle-content">
|
||||
<div class="body-content">
|
||||
<div class="title-content"><h1>Buscar en el foro</h1></div>
|
||||
<div class="box-content">
|
||||
<div class="body-box-content">
|
||||
{% include 'forum/_style.html' %}
|
||||
<div class="forum-wrap">
|
||||
<form class="forum-search" action="{% url 'forum_search' %}" method="GET" style="margin-bottom:16px">
|
||||
<input type="text" name="q" value="{{ query }}" placeholder="Buscar en el foro..."
|
||||
style="width:70%;background:rgba(0,0,0,.3);color:#e8e0d0;border:1px solid rgba(215,150,2,.4);padding:8px;border-radius:3px">
|
||||
<button type="submit" class="forum-btn">Buscar</button>
|
||||
</form>
|
||||
|
||||
{% if query %}
|
||||
<p style="color:#b1997f">Resultados para «{{ query }}»: {{ results|length }}</p>
|
||||
{% for topic in results %}
|
||||
<div class="topic-row">
|
||||
<div class="main">
|
||||
<a class="name" href="{% url 'forum_topic_p1' topic_id=topic.id %}">{{ topic.name }}</a>
|
||||
<div class="desc">en {{ topic.forum_name }} · por {{ topic.display_poster }} · {{ topic.created }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>No se encontraron temas.</p>
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination and pagination.total_pages > 1 %}
|
||||
<div class="forum-pagination">
|
||||
{% if pagination.has_prev %}<a href="?q={{ query|urlencode }}&page={{ pagination.prev_page }}">« Anterior</a>{% endif %}
|
||||
{% for p in pagination.page_range %}
|
||||
{% if p == pagination.page %}<span class="current">{{ p }}</span>
|
||||
{% else %}<a href="?q={{ query|urlencode }}&page={{ p }}">{{ p }}</a>{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination.has_next %}<a href="?q={{ query|urlencode }}&page={{ pagination.next_page }}">Siguiente »</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>Escribe al menos 2 caracteres para buscar.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="forum-actions" style="margin-top:16px">
|
||||
<a class="forum-btn" href="{% url 'app_forum' %}">← Volver al foro</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'partials/social.html' %}
|
||||
{% include 'partials/footer.html' %}
|
||||
{% include 'partials/final.html' %}
|
||||
@@ -1,122 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
{% load forum_extras %}
|
||||
{% include 'partials/head.html' %}
|
||||
{% include 'partials/header.html' %}
|
||||
{% include 'partials/video.html' %}
|
||||
|
||||
<div class="main-page">
|
||||
<div class="middle-content">
|
||||
<div class="body-content">
|
||||
<div class="title-content"><h1>{{ topic.name }}</h1></div>
|
||||
<div class="box-content">
|
||||
<div class="body-box-content">
|
||||
{% include 'forum/_style.html' %}
|
||||
<div class="forum-wrap">
|
||||
<div class="forum-actions">
|
||||
{% if forum %}<a class="forum-btn" href="{% url 'forum_view_p1' forum_id=forum.id %}">← {{ forum.name }}</a>{% endif %}
|
||||
{% if topic.has_views %}<span style="color:#b1997f; align-self:center">{{ topic.views }} visitas</span>{% endif %}
|
||||
{% if topic.locked %}<span class="forum-badge">Tema bloqueado</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{% for post in posts %}
|
||||
<div class="forum-post {% if post.deleted %}forum-deleted{% endif %}" id="post-{{ post.id }}">
|
||||
<div class="left">
|
||||
<div class="pname">
|
||||
<a href="{% url 'forum_profile' username=post.poster %}" style="color:#e0c07a">{{ post.author.display_name|default:post.poster }}</a>
|
||||
</div>
|
||||
<div class="prole">{{ post.author.status }}</div>
|
||||
<div class="pmeta">
|
||||
{% if post.author.joindate %}Registro: {{ post.author.joindate|date:"d/m/Y" }}<br>{% endif %}
|
||||
Mensajes: {{ post.author.post_count }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
{% if post.deleted %}
|
||||
<div class="pbody"><em>[Mensaje eliminado]</em></div>
|
||||
{% else %}
|
||||
<div class="pbody">{{ post.text|forum_safe }}</div>
|
||||
{% endif %}
|
||||
<div class="pfoot">
|
||||
<span>{{ post.time }}{% if post.updated_at and post.updated_at != post.time %} · editado {{ post.updated_at }}{% endif %}</span>
|
||||
<span>
|
||||
{% if post.can_edit and not post.deleted %}
|
||||
<a href="{% url 'forum_edit_post' post_id=post.id %}">Editar</a>
|
||||
· <form class="forum-inline" action="{% url 'forum_delete_post' post_id=post.id %}" method="POST" onsubmit="return confirm('¿Borrar este mensaje?')">{% csrf_token %}<button type="submit" class="link-btn danger">Borrar</button></form>
|
||||
{% endif %}
|
||||
{% if post.deleted and forum_is_moderator %}
|
||||
<form class="forum-inline" action="{% url 'forum_restore_post' post_id=post.id %}" method="POST">{% csrf_token %}<button type="submit" class="link-btn">Restaurar</button></form>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.total_pages > 1 %}
|
||||
<div class="forum-pagination">
|
||||
{% if pagination.has_prev %}<a href="{% url 'forum_topic' topic_id=topic.id page=pagination.prev_page %}">« Anterior</a>{% endif %}
|
||||
{% for p in pagination.page_range %}
|
||||
{% if p == pagination.page %}<span class="current">{{ p }}</span>
|
||||
{% else %}<a href="{% url 'forum_topic' topic_id=topic.id page=p %}">{{ p }}</a>{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination.has_next %}<a href="{% url 'forum_topic' topic_id=topic.id page=pagination.next_page %}">Siguiente »</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<span id="post-bottom"></span>
|
||||
|
||||
{% if can_reply %}
|
||||
<div class="forum-form" style="margin-top:20px">
|
||||
<h3 style="color:#d79602">Responder</h3>
|
||||
<form action="{% url 'forum_post_reply' topic_id=topic.id %}" method="POST">
|
||||
{% csrf_token %}
|
||||
{{ reply_form.text }}
|
||||
<div class="forum-actions">
|
||||
<button type="submit" class="forum-btn">Publicar respuesta</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ reply_form.media }}
|
||||
</div>
|
||||
{% elif topic.locked %}
|
||||
<p style="color:#b1997f">Este tema está bloqueado. No se admiten nuevas respuestas.</p>
|
||||
{% elif not forum_is_authenticated %}
|
||||
<p><a href="{% url 'login' %}">Inicia sesión</a> para responder.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if forum_is_moderator %}
|
||||
<div class="forum-form" style="margin-top:24px; border-top:1px solid rgba(255,255,255,.1); padding-top:14px">
|
||||
<h3 style="color:#d79602">Moderación</h3>
|
||||
<div class="forum-actions">
|
||||
{% if topic.locked %}
|
||||
<form class="forum-inline" action="{% url 'forum_unlock_topic' topic_id=topic.id %}" method="POST">{% csrf_token %}<button type="submit" class="forum-btn">Desbloquear</button></form>
|
||||
{% else %}
|
||||
<form class="forum-inline" action="{% url 'forum_lock_topic' topic_id=topic.id %}" method="POST">{% csrf_token %}<button type="submit" class="forum-btn">Bloquear</button></form>
|
||||
{% endif %}
|
||||
<form class="forum-inline" action="{% url 'forum_toggle_sticky' topic_id=topic.id %}" method="POST">{% csrf_token %}<button type="submit" class="forum-btn">{% if topic.sticky %}Quitar fijado{% else %}Fijar{% endif %}</button></form>
|
||||
{% if topic.deleted %}
|
||||
<form class="forum-inline" action="{% url 'forum_restore_topic' topic_id=topic.id %}" method="POST">{% csrf_token %}<button type="submit" class="forum-btn">Restaurar tema</button></form>
|
||||
{% else %}
|
||||
<form class="forum-inline" action="{% url 'forum_delete_topic' topic_id=topic.id %}" method="POST" onsubmit="return confirm('¿Borrar el tema completo?')">{% csrf_token %}<button type="submit" class="forum-btn danger">Borrar tema</button></form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if move_forums %}
|
||||
<form action="{% url 'forum_move_topic' topic_id=topic.id %}" method="POST" style="margin-top:8px">
|
||||
{% csrf_token %}
|
||||
<select name="forum_id" style="padding:6px;background:rgba(0,0,0,.3);color:#e8e0d0;border:1px solid rgba(215,150,2,.4)">
|
||||
{% for f in move_forums %}<option value="{{ f.id }}">{{ f.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="forum-btn">Mover tema</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'partials/social.html' %}
|
||||
{% include 'partials/footer.html' %}
|
||||
{% include 'partials/final.html' %}
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Filtros de plantilla del foro."""
|
||||
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from .. import sanitize
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def forum_safe(value):
|
||||
"""
|
||||
Renderiza el HTML de un post saneándolo con nh3 en el momento (defensa en
|
||||
profundidad). Aunque el contenido nuevo ya se sanea al guardar, esto protege
|
||||
frente a filas heredadas/importadas que pudieran contener HTML sin sanear.
|
||||
"""
|
||||
return mark_safe(sanitize.clean_post_html(value or ''))
|
||||
@@ -1,38 +0,0 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# Se mantienen los nombres de ruta del proyecto original para coherencia.
|
||||
# El orden importa: las rutas con literales (topic/create/edit/post) van antes
|
||||
# que la genérica forum/<int:forum_id>/...
|
||||
urlpatterns = [
|
||||
path('forum', views.index, name='app_forum'),
|
||||
path('forum/search', views.search, name='forum_search'),
|
||||
|
||||
# Temas
|
||||
path('forum/topic/<int:topic_id>/reply', views.reply_to_topic, name='forum_post_reply'),
|
||||
path('forum/topic/<int:topic_id>/move', views.move_topic, name='forum_move_topic'),
|
||||
path('forum/topic/<int:topic_id>/lock', views.lock_topic, name='forum_lock_topic'),
|
||||
path('forum/topic/<int:topic_id>/unlock', views.unlock_topic, name='forum_unlock_topic'),
|
||||
path('forum/topic/<int:topic_id>/sticky', views.toggle_sticky, name='forum_toggle_sticky'),
|
||||
path('forum/topic/<int:topic_id>/restore', views.restore_topic, name='forum_restore_topic'),
|
||||
path('forum/topic/delete/<int:topic_id>', views.delete_topic, name='forum_delete_topic'),
|
||||
path('forum/topic/<int:topic_id>/<int:page>', views.view_topic, name='forum_topic'),
|
||||
path('forum/topic/<int:topic_id>', views.view_topic, name='forum_topic_p1'),
|
||||
|
||||
# Posts
|
||||
path('forum/post/update/<int:post_id>', views.update_post, name='forum_update_post'),
|
||||
path('forum/post/delete/<int:post_id>', views.delete_post, name='forum_delete_post'),
|
||||
path('forum/post/<int:post_id>/restore', views.restore_post, name='forum_restore_post'),
|
||||
path('forum/edit/<int:post_id>', views.edit_post, name='forum_edit_post'),
|
||||
|
||||
# Crear tema
|
||||
path('forum/create/<int:forum_id>/submit', views.submit_create_topic, name='forum_create_topic_submit'),
|
||||
path('forum/create/<int:forum_id>', views.create_topic, name='forum_create_topic'),
|
||||
|
||||
# Ver foro (paginado) — genérica, al final
|
||||
path('forum/<int:forum_id>/<int:page>', views.view_forum, name='forum_view'),
|
||||
path('forum/<int:forum_id>', views.view_forum, name='forum_view_p1'),
|
||||
|
||||
# Perfil público
|
||||
path('profile/<str:username>', views.profile, name='forum_profile'),
|
||||
]
|
||||
-453
@@ -1,453 +0,0 @@
|
||||
"""
|
||||
Vistas del foro. Diseño integrado con NovaWoW (usa los partials del sitio).
|
||||
|
||||
Identidad del autor = cuenta de juego en sesión (``username`` / ``account_id``).
|
||||
Permisos: ver `permissions.py`.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
|
||||
from . import db
|
||||
from . import permissions as perm
|
||||
from . import sanitize
|
||||
from .forms import TopicForm, ReplyForm, EditPostForm
|
||||
|
||||
MIN_TEXT_LEN = 5
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Utilidades
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _paginate(total, page, per_page):
|
||||
total_pages = max(1, math.ceil(total / per_page)) if per_page else 1
|
||||
page = max(1, min(page, total_pages))
|
||||
offset = (page - 1) * per_page
|
||||
return {
|
||||
'page': page,
|
||||
'total_pages': total_pages,
|
||||
'has_prev': page > 1,
|
||||
'has_next': page < total_pages,
|
||||
'prev_page': page - 1,
|
||||
'next_page': page + 1,
|
||||
'page_range': range(1, total_pages + 1),
|
||||
'total': total,
|
||||
}, offset
|
||||
|
||||
|
||||
def _base_context(request, extra=None):
|
||||
ctx = dict(perm.forum_context(request))
|
||||
if extra:
|
||||
ctx.update(extra)
|
||||
return ctx
|
||||
|
||||
|
||||
def _require_login(request):
|
||||
if not perm.is_authenticated(request):
|
||||
return redirect('login')
|
||||
return None
|
||||
|
||||
|
||||
def _topic_locked_for(request, topic_id):
|
||||
"""True si el tema está bloqueado y el usuario no es moderador."""
|
||||
if perm.is_moderator(request):
|
||||
return False
|
||||
topic = db.get_topic(topic_id, include_hidden=True)
|
||||
return bool(topic and topic['locked'])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Navegación
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def index(request):
|
||||
categories = db.get_index(include_hidden=perm.is_moderator(request))
|
||||
return render(request, 'forum/index.html', _base_context(request, {
|
||||
'categories': categories,
|
||||
}))
|
||||
|
||||
|
||||
def view_forum(request, forum_id, page=1):
|
||||
is_mod = perm.is_moderator(request)
|
||||
forum = db.get_forum(forum_id, include_hidden=is_mod)
|
||||
if not forum:
|
||||
messages.error(request, 'Foro no encontrado.')
|
||||
return redirect('app_forum')
|
||||
|
||||
total = db.count_topics(forum_id)
|
||||
pag, offset = _paginate(total, page, settings.FORUM_TOPICS_PER_PAGE)
|
||||
topics = db.get_topics(forum_id, offset, settings.FORUM_TOPICS_PER_PAGE)
|
||||
for t in topics:
|
||||
t['display_poster'] = db.get_display_name(t['poster_id']) or t['poster']
|
||||
|
||||
return render(request, 'forum/forum.html', _base_context(request, {
|
||||
'forum': forum,
|
||||
'topics': topics,
|
||||
'pagination': pag,
|
||||
'has_views': db.has_views_column(),
|
||||
}))
|
||||
|
||||
|
||||
def search(request):
|
||||
query = (request.GET.get('q') or '').strip()
|
||||
try:
|
||||
page = int(request.GET.get('page', 1))
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
results = []
|
||||
pag = None
|
||||
if len(query) >= 2:
|
||||
total = db.count_search_topics(query)
|
||||
pag, offset = _paginate(total, page, settings.FORUM_TOPICS_PER_PAGE)
|
||||
results = db.search_topics(query, offset, settings.FORUM_TOPICS_PER_PAGE)
|
||||
for r in results:
|
||||
r['display_poster'] = db.get_display_name(r['poster_id']) or r['poster']
|
||||
return render(request, 'forum/search.html', _base_context(request, {
|
||||
'query': query,
|
||||
'results': results,
|
||||
'pagination': pag,
|
||||
}))
|
||||
|
||||
|
||||
def view_topic(request, topic_id, page=1):
|
||||
is_mod = perm.is_moderator(request)
|
||||
topic = db.get_topic(topic_id, include_hidden=is_mod)
|
||||
if not topic:
|
||||
messages.error(request, 'Tema no encontrado.')
|
||||
return redirect('app_forum')
|
||||
|
||||
# El foro padre debe ser visible (salvo moderador); si no, no se accede al
|
||||
# tema por URL directa aunque no aparezca en los listados.
|
||||
forum = db.get_forum(topic['forum_id'], include_hidden=is_mod)
|
||||
if not forum:
|
||||
messages.error(request, 'Tema no disponible.')
|
||||
return redirect('app_forum')
|
||||
|
||||
# Contador de visitas (solo si la columna existe)
|
||||
if db.has_views_column():
|
||||
db.increment_views(topic_id)
|
||||
topic['views'] = (topic.get('views') or 0) + 1
|
||||
topic['has_views'] = True
|
||||
else:
|
||||
topic['has_views'] = False
|
||||
|
||||
total = db.count_posts(topic_id, include_deleted=is_mod)
|
||||
pag, offset = _paginate(total, page, settings.FORUM_POSTS_PER_PAGE)
|
||||
posts = db.get_posts(topic_id, offset, settings.FORUM_POSTS_PER_PAGE, is_mod)
|
||||
|
||||
# Marcar como leído
|
||||
db.mark_read(perm.get_account_id(request), topic_id)
|
||||
|
||||
# Permisos y datos de autor por post
|
||||
for p in posts:
|
||||
p['can_edit'] = perm.can_edit_post(request, p)
|
||||
p['author'] = db.get_author_info(p['poster_id'])
|
||||
|
||||
return render(request, 'forum/topic.html', _base_context(request, {
|
||||
'topic': topic,
|
||||
'forum': forum,
|
||||
'posts': posts,
|
||||
'pagination': pag,
|
||||
'can_reply': perm.can_post(request) and (not topic['locked'] or is_mod),
|
||||
'move_forums': db.list_forums_for_move(topic['forum_id']) if is_mod else [],
|
||||
'reply_form': ReplyForm(),
|
||||
}))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Crear tema / responder
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def create_topic(request, forum_id):
|
||||
login = _require_login(request)
|
||||
if login:
|
||||
return login
|
||||
forum = db.get_forum(forum_id, include_hidden=perm.is_moderator(request))
|
||||
if not forum:
|
||||
messages.error(request, 'Foro no encontrado.')
|
||||
return redirect('app_forum')
|
||||
return render(request, 'forum/create_topic.html', _base_context(request, {
|
||||
'forum': forum,
|
||||
'form': TopicForm(),
|
||||
}))
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def submit_create_topic(request, forum_id):
|
||||
login = _require_login(request)
|
||||
if login:
|
||||
return login
|
||||
if request.method != 'POST':
|
||||
return redirect('forum_create_topic', forum_id=forum_id)
|
||||
|
||||
forum = db.get_forum(forum_id, include_hidden=perm.is_moderator(request))
|
||||
if not forum:
|
||||
messages.error(request, 'Foro no encontrado.')
|
||||
return redirect('app_forum')
|
||||
|
||||
name = (request.POST.get('name') or '').strip()
|
||||
text = sanitize.clean_post_html((request.POST.get('text') or '').strip())
|
||||
if not name or sanitize.plain_length(text) < MIN_TEXT_LEN:
|
||||
messages.error(request, 'El título y el contenido (mínimo 5 caracteres) son obligatorios.')
|
||||
return redirect('forum_create_topic', forum_id=forum_id)
|
||||
if len(name) > 45:
|
||||
messages.error(request, 'El título no puede superar los 45 caracteres.')
|
||||
return redirect('forum_create_topic', forum_id=forum_id)
|
||||
|
||||
poster = request.session.get('username')
|
||||
poster_id = perm.get_account_id(request)
|
||||
topic_id = db.create_topic(forum_id, name, poster, poster_id, text)
|
||||
messages.success(request, 'Tema creado.')
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def reply_to_topic(request, topic_id):
|
||||
login = _require_login(request)
|
||||
if login:
|
||||
return login
|
||||
if request.method != 'POST':
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
|
||||
is_mod = perm.is_moderator(request)
|
||||
topic = db.get_topic(topic_id)
|
||||
if not topic:
|
||||
messages.error(request, 'Tema no encontrado.')
|
||||
return redirect('app_forum')
|
||||
# El foro padre debe ser visible (salvo moderador)
|
||||
if not db.get_forum(topic['forum_id'], include_hidden=is_mod):
|
||||
messages.error(request, 'Tema no disponible.')
|
||||
return redirect('app_forum')
|
||||
if topic['locked'] and not is_mod:
|
||||
messages.error(request, 'El tema está bloqueado.')
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
|
||||
text = sanitize.clean_post_html((request.POST.get('text') or '').strip())
|
||||
if sanitize.plain_length(text) < MIN_TEXT_LEN:
|
||||
messages.error(request, 'El mensaje debe tener al menos 5 caracteres.')
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
|
||||
db.create_post(topic_id, request.session.get('username'), perm.get_account_id(request), text)
|
||||
|
||||
# Ir a la última página
|
||||
total = db.count_posts(topic_id)
|
||||
last_page = max(1, math.ceil(total / settings.FORUM_POSTS_PER_PAGE))
|
||||
url = reverse('forum_topic', kwargs={'topic_id': topic_id, 'page': last_page})
|
||||
return redirect(f'{url}#post-bottom')
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Editar / borrar / restaurar posts
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def edit_post(request, post_id):
|
||||
login = _require_login(request)
|
||||
if login:
|
||||
return login
|
||||
post = db.get_post(post_id)
|
||||
if not post:
|
||||
messages.error(request, 'Post no encontrado.')
|
||||
return redirect('app_forum')
|
||||
if not perm.can_edit_post(request, post):
|
||||
messages.error(request, 'No tienes permiso para editar este post.')
|
||||
return redirect('forum_topic_p1', topic_id=post['topic_id'])
|
||||
return render(request, 'forum/edit_post.html', _base_context(request, {
|
||||
'post': post,
|
||||
'form': EditPostForm(initial={'text': post['text']}),
|
||||
}))
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def update_post(request, post_id):
|
||||
login = _require_login(request)
|
||||
if login:
|
||||
return login
|
||||
post = db.get_post(post_id)
|
||||
if not post:
|
||||
messages.error(request, 'Post no encontrado.')
|
||||
return redirect('app_forum')
|
||||
if not perm.can_edit_post(request, post):
|
||||
messages.error(request, 'No tienes permiso para editar este post.')
|
||||
return redirect('forum_topic_p1', topic_id=post['topic_id'])
|
||||
if _topic_locked_for(request, post['topic_id']):
|
||||
messages.error(request, 'El tema está bloqueado.')
|
||||
return redirect('forum_topic_p1', topic_id=post['topic_id'])
|
||||
if request.method != 'POST':
|
||||
return redirect('forum_edit_post', post_id=post_id)
|
||||
|
||||
text = sanitize.clean_post_html((request.POST.get('text') or '').strip())
|
||||
if sanitize.plain_length(text) < MIN_TEXT_LEN:
|
||||
messages.error(request, 'El mensaje debe tener al menos 5 caracteres.')
|
||||
return redirect('forum_edit_post', post_id=post_id)
|
||||
|
||||
db.update_post(post_id, text)
|
||||
messages.success(request, 'Post actualizado.')
|
||||
return redirect('forum_topic_p1', topic_id=post['topic_id'])
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def delete_post(request, post_id):
|
||||
login = _require_login(request)
|
||||
if login:
|
||||
return login
|
||||
post = db.get_post(post_id)
|
||||
if not post:
|
||||
messages.error(request, 'Post no encontrado.')
|
||||
return redirect('app_forum')
|
||||
if not perm.can_edit_post(request, post):
|
||||
messages.error(request, 'No tienes permiso.')
|
||||
return redirect('forum_topic_p1', topic_id=post['topic_id'])
|
||||
if _topic_locked_for(request, post['topic_id']):
|
||||
messages.error(request, 'El tema está bloqueado.')
|
||||
return redirect('forum_topic_p1', topic_id=post['topic_id'])
|
||||
if request.method != 'POST':
|
||||
return redirect('forum_topic_p1', topic_id=post['topic_id'])
|
||||
db.set_post_deleted(post_id, True)
|
||||
messages.success(request, 'Post borrado.')
|
||||
return redirect('forum_topic_p1', topic_id=post['topic_id'])
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def restore_post(request, post_id):
|
||||
if not perm.is_moderator(request):
|
||||
return redirect('app_forum')
|
||||
post = db.get_post(post_id)
|
||||
if not post:
|
||||
return redirect('app_forum')
|
||||
if request.method != 'POST':
|
||||
return redirect('forum_topic_p1', topic_id=post['topic_id'])
|
||||
db.set_post_deleted(post_id, False)
|
||||
messages.success(request, 'Post restaurado.')
|
||||
return redirect('forum_topic_p1', topic_id=post['topic_id'])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Moderación de temas
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _mod_only(request):
|
||||
if not perm.is_moderator(request):
|
||||
messages.error(request, 'Acción solo para moderadores.')
|
||||
return redirect('app_forum')
|
||||
return None
|
||||
|
||||
|
||||
def _mod_post_only(request):
|
||||
"""Exige moderador y método POST (CSRF)."""
|
||||
block = _mod_only(request)
|
||||
if block:
|
||||
return block
|
||||
if request.method != 'POST':
|
||||
return redirect('app_forum')
|
||||
return None
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def lock_topic(request, topic_id):
|
||||
block = _mod_post_only(request)
|
||||
if block:
|
||||
return block
|
||||
db.set_topic_flag(topic_id, 'locked', True)
|
||||
messages.success(request, 'Tema bloqueado.')
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def unlock_topic(request, topic_id):
|
||||
block = _mod_post_only(request)
|
||||
if block:
|
||||
return block
|
||||
db.set_topic_flag(topic_id, 'locked', False)
|
||||
messages.success(request, 'Tema desbloqueado.')
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def toggle_sticky(request, topic_id):
|
||||
block = _mod_post_only(request)
|
||||
if block:
|
||||
return block
|
||||
topic = db.get_topic(topic_id, include_hidden=True)
|
||||
if topic:
|
||||
db.set_topic_flag(topic_id, 'sticky', not topic['sticky'])
|
||||
messages.success(request, 'Tema fijado.' if not topic['sticky'] else 'Tema no fijado.')
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def delete_topic(request, topic_id):
|
||||
block = _mod_post_only(request)
|
||||
if block:
|
||||
return block
|
||||
topic = db.get_topic(topic_id, include_hidden=True)
|
||||
db.set_topic_flag(topic_id, 'deleted', True)
|
||||
messages.success(request, 'Tema borrado.')
|
||||
if topic:
|
||||
return redirect('forum_view_p1', forum_id=topic['forum_id'])
|
||||
return redirect('app_forum')
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def restore_topic(request, topic_id):
|
||||
block = _mod_post_only(request)
|
||||
if block:
|
||||
return block
|
||||
db.set_topic_flag(topic_id, 'deleted', False)
|
||||
messages.success(request, 'Tema restaurado.')
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def move_topic(request, topic_id):
|
||||
block = _mod_only(request)
|
||||
if block:
|
||||
return block
|
||||
if request.method != 'POST':
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
try:
|
||||
new_forum_id = int(request.POST.get('forum_id', ''))
|
||||
except (TypeError, ValueError):
|
||||
messages.error(request, 'Foro destino no válido.')
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
if not db.get_forum(new_forum_id, include_hidden=True):
|
||||
messages.error(request, 'Foro destino no válido.')
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
db.move_topic(topic_id, new_forum_id)
|
||||
messages.success(request, 'Tema movido.')
|
||||
return redirect('forum_topic_p1', topic_id=topic_id)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Perfil público
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def profile(request, username):
|
||||
from django.db import connections
|
||||
stats = {'username': username, 'post_count': 0, 'topic_count': 0, 'joindate': None}
|
||||
with connections['acore_web'].cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM forum_posts WHERE poster = %s AND deleted = 0", [username]
|
||||
)
|
||||
stats['post_count'] = cursor.fetchone()[0]
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM forum_topics WHERE poster = %s AND deleted = 0", [username]
|
||||
)
|
||||
stats['topic_count'] = cursor.fetchone()[0]
|
||||
try:
|
||||
with connections['acore_auth'].cursor() as cursor:
|
||||
cursor.execute("SELECT joindate FROM account WHERE username = %s", [username])
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
stats['joindate'] = row[0]
|
||||
except Exception:
|
||||
pass
|
||||
return render(request, 'forum/profile.html', _base_context(request, {
|
||||
'profile': stats,
|
||||
}))
|
||||
@@ -1,32 +0,0 @@
|
||||
# Frontend NovaWoW (islas React/TSX con Vite)
|
||||
|
||||
Migración incremental de las plantillas HTML de Django a componentes React/TSX,
|
||||
montados como **islas** sobre las páginas que sigue sirviendo Django. Los datos se
|
||||
obtienen de endpoints JSON bajo `/api/` (ver `home/api_urls.py`).
|
||||
|
||||
## Estructura
|
||||
- `src/entries/*.tsx` — puntos de entrada de Vite; cada uno monta una isla en un
|
||||
contenedor que deja la plantilla Django (p. ej. `#home-news-app`).
|
||||
- `src/components/*.tsx` — componentes React.
|
||||
- `vite.config.ts` — build a `../static/dist` con manifest (base `/static/dist/`).
|
||||
|
||||
La plantilla carga la isla con `{% load django_vite %}` + `{% vite_asset 'src/entries/xxx.tsx' %}`.
|
||||
|
||||
## Desarrollo (con HMR)
|
||||
Requiere `DJANGO_DEBUG=True`. En un terminal:
|
||||
```
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
django-vite (dev_mode = DEBUG) inyecta el cliente HMR y sirve desde el dev server.
|
||||
|
||||
## Producción (build)
|
||||
Con `DJANGO_DEBUG=False`, Django sirve el bundle construido vía manifest. Tras
|
||||
cualquier cambio en `frontend/`:
|
||||
```
|
||||
cd /root/NovaWoW/frontend && npm run build # genera ../static/dist + manifest
|
||||
cd /root/NovaWoW && .venv/bin/python manage.py collectstatic --noinput
|
||||
systemctl restart novawow
|
||||
```
|
||||
|
||||
Los artefactos (`node_modules/`, `static/dist/`) están en `.gitignore`: se regeneran
|
||||
con `npm run build`.
|
||||
Generated
-1729
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "novawow-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface UserInfo {
|
||||
bnet_email: string
|
||||
username: string
|
||||
reg_mail: string
|
||||
email: string
|
||||
last_ip: string
|
||||
last_attempt_ip: string
|
||||
joindate: string
|
||||
}
|
||||
|
||||
interface AccountStatus {
|
||||
is_banned: boolean
|
||||
unban_date?: string
|
||||
remaining_time?: number
|
||||
is_recruited?: boolean
|
||||
recruited_count?: number
|
||||
}
|
||||
|
||||
interface Character {
|
||||
name: string
|
||||
level: number
|
||||
gold: number
|
||||
silver: number
|
||||
copper: number
|
||||
zone: string
|
||||
class_css: string
|
||||
image_url: string
|
||||
}
|
||||
|
||||
interface AccountData {
|
||||
authenticated: boolean
|
||||
error?: string
|
||||
user_info: UserInfo
|
||||
account_status: AccountStatus
|
||||
dp: number
|
||||
vp: number
|
||||
battlepay_credits: number
|
||||
token_status: string
|
||||
token_date: string | null
|
||||
characters: Character[]
|
||||
}
|
||||
|
||||
function fmtRemaining(secs: number): string {
|
||||
if (secs <= 0) return '0h 0m 0s'
|
||||
const h = Math.floor(secs / 3600)
|
||||
const m = Math.floor((secs % 3600) / 60)
|
||||
const s = secs % 60
|
||||
return `${h}h ${m}m ${s}s`
|
||||
}
|
||||
|
||||
export function AccountDashboard() {
|
||||
const [data, setData] = useState<AccountData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [remaining, setRemaining] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/account/me/', { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
|
||||
.then((r) => r.json())
|
||||
.then((d: AccountData) => {
|
||||
if (cancelled) return
|
||||
if (d.error) setError(d.error)
|
||||
else {
|
||||
setData(d)
|
||||
if (d.account_status?.is_banned && d.account_status.remaining_time) {
|
||||
setRemaining(d.account_status.remaining_time)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => !cancelled && setError('No se pudieron cargar los datos de la cuenta.'))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (remaining === null) return
|
||||
if (remaining <= 0) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
const id = setInterval(() => setRemaining((r) => (r === null ? null : r - 1)), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [remaining])
|
||||
|
||||
if (error) return <p className="red-form-response">{error}</p>
|
||||
if (!data) return <p>Cargando datos de la cuenta…</p>
|
||||
|
||||
const { user_info: u, account_status: st } = data
|
||||
|
||||
return (
|
||||
<>
|
||||
<fieldset className="account-fieldset">
|
||||
<legend>Datos básicos</legend>
|
||||
<div className="separate">
|
||||
<p>Cuenta (Battle.net): <span>{u.bnet_email}</span></p>
|
||||
<p>Cuenta de juego: <span>{u.username}</span></p>
|
||||
<p>Correo de registro: <span>{u.reg_mail}</span></p>
|
||||
<p>Correo actual: <span>{u.email}</span></p>
|
||||
<p>Fecha de registro: <span>{u.joindate}</span></p>
|
||||
<p>Última IP (web): <span>{u.last_ip}</span></p>
|
||||
<p>Última IP (Servidor): <span>{u.last_attempt_ip}</span></p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="account-fieldset">
|
||||
<legend>Estado de la cuenta</legend>
|
||||
<div className="separate">
|
||||
<img
|
||||
src="/static/nw-themes/nw-ryu/nw-images/nw-ranks/1_Newbie.svg"
|
||||
className="nw-rank"
|
||||
alt="Nivel 1"
|
||||
title="Nivel 1"
|
||||
/>
|
||||
<p>PD: <span>{data.dp}</span></p>
|
||||
<p>PV: <span>{data.vp}</span></p>
|
||||
<p>Créditos Battlepay: <span>{data.battlepay_credits}</span></p>
|
||||
<p>
|
||||
Token de Seguridad:{' '}
|
||||
<span>
|
||||
{data.token_status === 'Solicitado' ? `${data.token_status} - ${data.token_date}` : data.token_status}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{st?.is_banned ? (
|
||||
<>
|
||||
<p>Cuenta baneada: <span>Sí</span></p>
|
||||
<p>Fin de la suspensión: <span>{st.unban_date}</span></p>
|
||||
<p id="ban-timer">
|
||||
Tiempo restante: <span>{remaining !== null ? fmtRemaining(remaining) : ''}</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Cuenta baneada: <span>No</span> (<a href="ban-history">Consultar historial</a>)</p>
|
||||
<p>Cuenta reclutada: <span>{st?.is_recruited ? 'Sí' : 'No'}</span></p>
|
||||
<p>Amigos reclutados: <span>{st?.recruited_count ?? 0}</span></p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{data.characters.length > 0 && (
|
||||
<div className="box-content">
|
||||
<div className="title-box-content">
|
||||
<h2>Mis personajes</h2>
|
||||
</div>
|
||||
<div className="characters-grid">
|
||||
{data.characters.map((c) => (
|
||||
<div className={`char-box char-box-${c.class_css}`} key={c.name}>
|
||||
<div className="char-text">
|
||||
<img className="img-small-icon char-icon" src={`/static/${c.image_url}`} alt={c.name} />
|
||||
<p className={`${c.class_css} big-font`}>{c.name}</p>
|
||||
<p><span>Nivel {c.level}</span></p>
|
||||
<p className="second-brown">{c.zone}</p>
|
||||
<p>
|
||||
<span>
|
||||
{c.gold}
|
||||
<img src="/static/nw-themes/nw-ryu/nw-images/nw-icons/money-gold.gif" width="10" />{' '}
|
||||
{c.silver}
|
||||
<img src="/static/nw-themes/nw-ryu/nw-images/nw-icons/money-silver.webp" width="10" />{' '}
|
||||
{c.copper}
|
||||
<img src="/static/nw-themes/nw-ryu/nw-images/nw-icons/money-copper.gif" width="10" />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
interface Field {
|
||||
name: string
|
||||
type: 'password' | 'email' | 'text'
|
||||
placeholder: string
|
||||
maxLength?: number
|
||||
toggle?: boolean
|
||||
}
|
||||
|
||||
const FIELDS: Field[] = [
|
||||
{ name: 'cur-password', type: 'password', placeholder: 'Contraseña actual', maxLength: 16, toggle: true },
|
||||
{ name: 'cur-email', type: 'email', placeholder: 'Correo actual' },
|
||||
{ name: 'new-email', type: 'email', placeholder: 'Nuevo correo' },
|
||||
{ name: 'conf-new-email', type: 'email', placeholder: 'Confirmar nuevo correo' },
|
||||
{ name: 'security-token', type: 'text', placeholder: 'Token de seguridad', maxLength: 6 },
|
||||
]
|
||||
|
||||
export function ChangeEmailForm({ url, csrfToken }: Props) {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
for (const f of FIELDS) body.set(f.name, (values[f.name] || '').trim())
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string } = await resp.json()
|
||||
setMessage({ ok: !!data.success, html: data.message || '' })
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error en el servidor. Inténtalo más tarde.</span>' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="nw-change-email-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
{FIELDS.map((f) => (
|
||||
<tr key={f.name}>
|
||||
<td>
|
||||
<input
|
||||
type={f.toggle ? (showPw ? 'text' : 'password') : f.type}
|
||||
name={f.name}
|
||||
id={f.name}
|
||||
placeholder={f.placeholder}
|
||||
maxLength={f.maxLength}
|
||||
required
|
||||
value={values[f.name] || ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
|
||||
/>
|
||||
{f.toggle && (
|
||||
<span
|
||||
className={`far toggle-password ${showPw ? 'fa-eye-slash' : 'fa-eye'}`}
|
||||
onClick={() => setShowPw((s) => !s)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="change-email-button" disabled={busy}>
|
||||
{busy ? 'Cambiando…' : 'Cambiar Correo'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<div className="alert-message" id="change-email-response" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
loginUrl: string
|
||||
}
|
||||
|
||||
interface Field {
|
||||
key: 'cur' | 'new' | 'conf' | 'token'
|
||||
name: string
|
||||
placeholder: string
|
||||
maxLength: number
|
||||
}
|
||||
|
||||
const FIELDS: Field[] = [
|
||||
{ key: 'cur', name: 'cur-password', placeholder: 'Contraseña actual', maxLength: 16 },
|
||||
{ key: 'new', name: 'new-password', placeholder: 'Contraseña nueva', maxLength: 16 },
|
||||
{ key: 'conf', name: 'conf-new-password', placeholder: 'Confirmar contraseña nueva', maxLength: 16 },
|
||||
{ key: 'token', name: 'security-token', placeholder: 'Token de seguridad', maxLength: 6 },
|
||||
]
|
||||
|
||||
export function ChangePasswordForm({ url, csrfToken, loginUrl }: Props) {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [shown, setShown] = useState<Record<string, boolean>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy || done) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
for (const f of FIELDS) body.set(f.name, (values[f.key] || '').trim())
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string; redirect?: boolean } = await resp.json()
|
||||
setMessage({ ok: !!data.success, html: data.message || '' })
|
||||
if (data.success && data.redirect) {
|
||||
setDone(true)
|
||||
setTimeout(() => {
|
||||
window.location.href = loginUrl
|
||||
}, 3000)
|
||||
} else {
|
||||
setBusy(false)
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error en el servidor. Inténtalo más tarde.</span>' })
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form id="nw-change-password-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
{FIELDS.map((f) => (
|
||||
<tr key={f.key}>
|
||||
<td>
|
||||
<input
|
||||
type={shown[f.key] ? 'text' : 'password'}
|
||||
maxLength={f.maxLength}
|
||||
name={f.name}
|
||||
id={f.name}
|
||||
placeholder={f.placeholder}
|
||||
value={values[f.key] || ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
||||
/>
|
||||
<span
|
||||
className={`far toggle-password ${shown[f.key] ? 'fa-eye-slash' : 'fa-eye'}`}
|
||||
onClick={() => setShown((s) => ({ ...s, [f.key]: !s[f.key] }))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="change-password-button" disabled={busy || done}>
|
||||
{done ? 'Hecho' : busy ? 'Cambiando…' : 'Cambiar contraseña'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<hr />
|
||||
<div className="alert-message" id="change-password-response" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { redirectToStripeCheckout } from '../stripe'
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
characters: string[]
|
||||
price: string
|
||||
label: string
|
||||
/** nombre del campo POST del personaje (por defecto "character") */
|
||||
field?: string
|
||||
/** clase CSS del botón (por defecto "rename-button") */
|
||||
buttonClass?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Formulario reutilizable para los servicios de personaje de pago: selector de
|
||||
* personaje + botón con precio. Al enviar, la vista devuelve un session_id de
|
||||
* Stripe y redirige al Checkout. Sirve para rename/customize/race/faction/level/
|
||||
* gold/transfer/... cambiando solo los data-* de la plantilla.
|
||||
*/
|
||||
export function CharacterPurchaseForm({
|
||||
url,
|
||||
csrfToken,
|
||||
characters,
|
||||
price,
|
||||
label,
|
||||
field = 'character',
|
||||
buttonClass = 'rename-button',
|
||||
}: Props) {
|
||||
const [character, setCharacter] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set(field, character)
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: {
|
||||
success?: boolean
|
||||
message?: string
|
||||
session_id?: string
|
||||
stripe_public_key?: string
|
||||
} = await resp.json()
|
||||
|
||||
if (data.success && data.session_id && data.stripe_public_key) {
|
||||
await redirectToStripeCheckout(data.stripe_public_key, data.session_id)
|
||||
return // redirigiendo a Stripe
|
||||
}
|
||||
setMessage({ ok: !!data.success, html: data.message || '' })
|
||||
setBusy(false)
|
||||
} catch {
|
||||
setMessage({
|
||||
ok: false,
|
||||
html: '<span class="red-form-response">Error al iniciar el pago. Inténtalo más tarde.</span>',
|
||||
})
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="centered">
|
||||
<br />
|
||||
<p>Escoge el personaje</p>
|
||||
<br />
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<select name={field} value={character} onChange={(e) => setCharacter(e.target.value)} required>
|
||||
<option value="" disabled>
|
||||
Seleccione un personaje
|
||||
</option>
|
||||
{characters.map((c) => (
|
||||
<option className="priest big-font" value={c} key={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<br />
|
||||
<button className={buttonClass} type="submit" disabled={busy || !character}>
|
||||
{busy ? 'Procesando…' : price ? `${label} por ${price} €` : label}
|
||||
</button>
|
||||
</form>
|
||||
<br />
|
||||
<hr />
|
||||
<center>
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</center>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { redirectToStripeCheckout } from '../stripe'
|
||||
|
||||
interface GoldOption {
|
||||
gold_amount: number
|
||||
price: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
characters: string[]
|
||||
options: GoldOption[]
|
||||
}
|
||||
|
||||
export function GoldForm({ url, csrfToken, characters, options }: Props) {
|
||||
const [character, setCharacter] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('character', character)
|
||||
body.set('gold_amount', amount)
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string; session_id?: string; stripe_public_key?: string } =
|
||||
await resp.json()
|
||||
if (data.success && data.session_id && data.stripe_public_key) {
|
||||
await redirectToStripeCheckout(data.stripe_public_key, data.session_id)
|
||||
return
|
||||
}
|
||||
setMessage({ ok: !!data.success, html: data.message || '' })
|
||||
setBusy(false)
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error al iniciar el pago. Inténtalo más tarde.</span>' })
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="centered">
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<select name="character" value={character} onChange={(e) => setCharacter(e.target.value)} required>
|
||||
<option value="" disabled>Seleccione un personaje</option>
|
||||
{characters.map((c) => (
|
||||
<option value={c} key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<br />
|
||||
<select name="gold_amount" value={amount} onChange={(e) => setAmount(e.target.value)} required>
|
||||
<option value="" disabled>Seleccione cantidad de oro</option>
|
||||
{options.map((o) => (
|
||||
<option value={o.gold_amount} key={o.gold_amount}>
|
||||
{o.gold_amount} oro - {o.price} EUR
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<br />
|
||||
<button type="submit" className="gold-button" disabled={busy || !character || !amount}>
|
||||
{busy ? 'Procesando…' : 'Enviar oro'}
|
||||
</button>
|
||||
</form>
|
||||
<hr />
|
||||
<center>
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</center>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { redirectToStripeCheckout } from '../stripe'
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
guilds: string[]
|
||||
}
|
||||
|
||||
export function GuildRenameForm({ url, csrfToken, guilds }: Props) {
|
||||
const [oldName, setOldName] = useState('')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [confName, setConfName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('old-guild-name', oldName)
|
||||
body.set('new-guild-name', newName.trim())
|
||||
body.set('conf-new-guild-name', confName.trim())
|
||||
body.set('cur-password', password.trim())
|
||||
body.set('security-token', token.trim())
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string; session_id?: string; stripe_public_key?: string } =
|
||||
await resp.json()
|
||||
if (data.success && data.session_id && data.stripe_public_key) {
|
||||
await redirectToStripeCheckout(data.stripe_public_key, data.session_id)
|
||||
return
|
||||
}
|
||||
setMessage({ ok: !!data.success, html: data.message || '' })
|
||||
setBusy(false)
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error al iniciar el pago. Inténtalo más tarde.</span>' })
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form id="nw-rename-guild-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<select name="old-guild-name" value={oldName} onChange={(e) => setOldName(e.target.value)} required>
|
||||
<option value="">Selecciona tu hermandad</option>
|
||||
{guilds.map((g) => (
|
||||
<option value={g} key={g}>{g}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" maxLength={24} name="new-guild-name" placeholder="Nuevo nombre de hermandad"
|
||||
value={newName} onChange={(e) => setNewName(e.target.value)} required />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" maxLength={24} name="conf-new-guild-name" placeholder="Confirmar nuevo nombre"
|
||||
value={confName} onChange={(e) => setConfName(e.target.value)} required />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="password" name="cur-password" placeholder="Contraseña actual"
|
||||
value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" name="security-token" placeholder="Token de seguridad"
|
||||
value={token} onChange={(e) => setToken(e.target.value)} required />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="rename-guild-button" disabled={busy}>
|
||||
{busy ? 'Procesando…' : 'Proceder al Pago'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<hr />
|
||||
<div className="alert-message" id="rename-guild-response" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
interface Column {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
columns: Column[]
|
||||
rows: Record<string, unknown>[]
|
||||
emptyText: string
|
||||
}
|
||||
|
||||
/** Tabla genérica de solo lectura para los historiales (seguridad, transacciones, baneos). */
|
||||
export function HistoryTable({ columns, rows, emptyText }: Props) {
|
||||
return (
|
||||
<table className="max-center-table-aligned">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th key={c.key}>{c.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length > 0 ? (
|
||||
rows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((c) => (
|
||||
<td key={c.key}>{String(row[c.key] ?? '')}</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="centered">
|
||||
<span>{emptyText}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useTurnstile } from '../turnstile'
|
||||
|
||||
interface Props {
|
||||
loginUrl: string
|
||||
successUrl: string
|
||||
csrfToken: string
|
||||
siteKey: string
|
||||
}
|
||||
|
||||
type LoginResponse = {
|
||||
success?: boolean
|
||||
alert?: boolean
|
||||
locked?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function LoginForm({ loginUrl, successUrl, csrfToken, siteKey }: Props) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [buttonText, setButtonText] = useState('Conectar')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
||||
const { ref: turnstileRef, token: captchaToken } = useTurnstile(siteKey)
|
||||
|
||||
function transientError(text: string) {
|
||||
setMessage({ kind: 'err', text })
|
||||
setBusy(false)
|
||||
setButtonText('Conectar')
|
||||
setTimeout(() => setMessage(null), 5000)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
|
||||
if (email.trim() === '' || password.trim() === '') {
|
||||
transientError('Por favor rellene todos los campos.')
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
setButtonText('Conectando')
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('email', email.trim())
|
||||
body.set('password', password.trim())
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
body.set('cf-turnstile-response', captchaToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(loginUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: LoginResponse = await resp.json()
|
||||
|
||||
if (data.success === true) {
|
||||
setButtonText('Conectado')
|
||||
setMessage({ kind: 'ok', text: 'Conexión exitosa. Redirigiendo…' })
|
||||
setTimeout(() => {
|
||||
window.location.href = successUrl
|
||||
}, 3000)
|
||||
} else if (data.alert === true) {
|
||||
setButtonText('Sancionado')
|
||||
setBusy(false)
|
||||
} else if (data.locked === true) {
|
||||
setButtonText('Seguridad')
|
||||
setBusy(false)
|
||||
} else {
|
||||
transientError(data.error || 'Nombre de usuario / Contraseña incorrectos.')
|
||||
}
|
||||
} catch {
|
||||
setTimeout(() => {
|
||||
alert('Algo ha salido mal. Por favor intente más tarde')
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form id="nw-login-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="email"
|
||||
maxLength={320}
|
||||
name="email"
|
||||
id="username"
|
||||
placeholder="Correo electrónico"
|
||||
autoFocus
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
maxLength={16}
|
||||
name="password"
|
||||
id="password"
|
||||
placeholder="Contraseña"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<span
|
||||
className={`far toggle-password ${showPassword ? 'fa-eye-slash' : 'fa-eye'}`}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div ref={turnstileRef} className="cf-turnstile" style={{ display: 'inline-block' }} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button"
|
||||
id="login-button"
|
||||
disabled={busy || (!!siteKey && !captchaToken)}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<hr />
|
||||
<div className="alert-message" id="login-response">
|
||||
{message && (
|
||||
<span className={message.kind === 'ok' ? 'ok-form-response' : 'red-form-response'}>
|
||||
{message.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Noticia } from '../types'
|
||||
|
||||
function formatFecha(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function NewsList() {
|
||||
const [noticias, setNoticias] = useState<Noticia[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/home/news/', { headers: { Accept: 'application/json' } })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json()
|
||||
})
|
||||
.then((data: { noticias: Noticia[] }) => {
|
||||
if (!cancelled) setNoticias(data.noticias)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(String(e))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return <p>No se pudieron cargar las noticias.</p>
|
||||
}
|
||||
if (noticias === null) {
|
||||
return <p>Cargando noticias…</p>
|
||||
}
|
||||
if (noticias.length === 0) {
|
||||
return <p>No hay noticias disponibles en este momento.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{noticias.map((n) => (
|
||||
<div key={n.id} className="noticia-item">
|
||||
<h3>{n.titulo}</h3>
|
||||
<span className="date">{formatFecha(n.fecha)}</span>
|
||||
<div
|
||||
className="noticia-contenido"
|
||||
dangerouslySetInnerHTML={{ __html: n.contenido }}
|
||||
/>
|
||||
{n.enlace && (
|
||||
<p>
|
||||
Enlace:{' '}
|
||||
<a href={n.enlace} target="_blank" rel="noopener noreferrer">
|
||||
aquí
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
</div>
|
||||
))}
|
||||
<p className="centered third-brown">
|
||||
<i>* Mostrando las últimas 50 noticias</i>
|
||||
</p>
|
||||
<br />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useTurnstile } from '../turnstile'
|
||||
|
||||
interface Props {
|
||||
recoverUrl: string
|
||||
csrfToken: string
|
||||
siteKey: string
|
||||
}
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'password', label: 'Contraseña' },
|
||||
{ value: 'accountname', label: 'Nombre de cuenta' },
|
||||
{ value: 'activation', label: 'Enlace de activación' },
|
||||
] as const
|
||||
|
||||
export function RecoverForm({ recoverUrl, csrfToken, siteKey }: Props) {
|
||||
const { ref: turnstileRef, token: captchaToken } = useTurnstile(siteKey)
|
||||
const [type, setType] = useState<string>('password')
|
||||
const [email, setEmail] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('type', type)
|
||||
body.set('email', email.trim())
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
body.set('cf-turnstile-response', captchaToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(recoverUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string } = await resp.json()
|
||||
setMessage({ ok: !!data.success, text: data.message || '' })
|
||||
} catch {
|
||||
setMessage({ ok: false, text: 'Error en el servidor. Inténtalo de nuevo más tarde.' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>Escoge la opción acorde a la información que quieres recuperar</p>
|
||||
<br />
|
||||
<form id="nw-recover-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<select id="selection" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
{OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="Correo electrónico de Gmail"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div ref={turnstileRef} className="cf-turnstile" style={{ display: 'inline-block' }} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button
|
||||
type="submit"
|
||||
className="recover-button"
|
||||
disabled={busy || (!!siteKey && !captchaToken)}
|
||||
>
|
||||
{busy ? 'Enviando…' : 'Solicitar'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<hr />
|
||||
<div className="alert-message" id="recover-response" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && (
|
||||
<span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useTurnstile } from '../turnstile'
|
||||
|
||||
interface Props {
|
||||
registerUrl: string
|
||||
csrfToken: string
|
||||
siteKey: string
|
||||
}
|
||||
|
||||
type RegisterResponse = {
|
||||
success?: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function RegisterForm({ registerUrl, csrfToken, siteKey }: Props) {
|
||||
const { ref: turnstileRef, token: captchaToken } = useTurnstile(siteKey)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confPassword, setConfPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [confEmail, setConfEmail] = useState('')
|
||||
const [recruiter, setRecruiter] = useState('')
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [showConfPw, setShowConfPw] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [response, setResponse] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy || !accepted) return
|
||||
|
||||
setBusy(true)
|
||||
setResponse(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('password', password.trim())
|
||||
body.set('conf-password', confPassword.trim())
|
||||
body.set('email', email.trim())
|
||||
body.set('conf-email', confEmail.trim())
|
||||
body.set('recruiter', recruiter.trim())
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
body.set('cf-turnstile-response', captchaToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(registerUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: RegisterResponse = await resp.json()
|
||||
const msg = data.message ?? ''
|
||||
// El mensaje de éxito ya viene como HTML; el de error es texto.
|
||||
setResponse({
|
||||
ok: !!data.success,
|
||||
html: data.success ? msg : `<span class="red-form-response">${msg}</span>`,
|
||||
})
|
||||
} catch {
|
||||
setResponse({
|
||||
ok: false,
|
||||
html: '<span class="red-form-response">Error en el servidor. Inténtalo de nuevo más tarde.</span>',
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form id="nw-create-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
maxLength={16}
|
||||
name="password"
|
||||
id="password"
|
||||
placeholder="Contraseña"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<span
|
||||
className={`far toggle-password ${showPw ? 'fa-eye-slash' : 'fa-eye'}`}
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type={showConfPw ? 'text' : 'password'}
|
||||
maxLength={16}
|
||||
name="conf-password"
|
||||
id="conf-password"
|
||||
placeholder="Confirmar contraseña"
|
||||
value={confPassword}
|
||||
onChange={(e) => setConfPassword(e.target.value)}
|
||||
/>
|
||||
<span
|
||||
className={`far toggle-password-conf ${showConfPw ? 'fa-eye-slash' : 'fa-eye'}`}
|
||||
onClick={() => setShowConfPw((v) => !v)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="Correo electrónico de Gmail"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="email"
|
||||
name="conf-email"
|
||||
id="conf-email"
|
||||
placeholder="Confirmar correo electrónico"
|
||||
value={confEmail}
|
||||
onChange={(e) => setConfEmail(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<hr />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Opcional: Sólo si eres reclutado por un amigo</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={12}
|
||||
name="recruiter"
|
||||
id="recruiter"
|
||||
placeholder="Reclutante"
|
||||
value={recruiter}
|
||||
onChange={(e) => setRecruiter(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="accept-terms-cookies"
|
||||
className="terms-check"
|
||||
name="accept-terms-cookies"
|
||||
checked={accepted}
|
||||
onChange={(e) => setAccepted(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="accept-terms-cookies">
|
||||
{' '}
|
||||
Acepto las{' '}
|
||||
<a
|
||||
href="https://foro.novawow.com/forum/novawow/normas-de-la-comunidad/16502-normas-del-juego"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Normas
|
||||
</a>
|
||||
,{' '}
|
||||
<a href="terms-and-conditions" target="_blank">
|
||||
Términos y Condiciones
|
||||
</a>{' '}
|
||||
y la{' '}
|
||||
<a href="privacy-policy" target="_blank">
|
||||
Política de Privacidad
|
||||
</a>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div ref={turnstileRef} className="cf-turnstile" style={{ display: 'inline-block' }} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button
|
||||
type="submit"
|
||||
name="create"
|
||||
className="create-button"
|
||||
id="create-account-btn"
|
||||
disabled={!accepted || busy || (!!siteKey && !captchaToken)}
|
||||
>
|
||||
{busy ? 'Creando…' : 'Crear cuenta'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<hr />
|
||||
<div className="alert-message" id="create-response" style={{ display: response ? 'block' : 'none' }}>
|
||||
{response && <span dangerouslySetInnerHTML={{ __html: response.html }} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
valid: boolean
|
||||
token: string
|
||||
resetUrl: string
|
||||
recoverUrl: string
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
export function ResetPassword({ valid, token, resetUrl, recoverUrl, csrfToken }: Props) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confPassword, setConfPassword] = useState('')
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
if (!valid) {
|
||||
return (
|
||||
<div className="body-box-content centered">
|
||||
<p className="red-form-response">El enlace no es válido o ha caducado.</p>
|
||||
<br />
|
||||
<p>
|
||||
Puedes solicitar uno nuevo en <a href={recoverUrl}>recuperar cuenta</a>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy || done) return
|
||||
if (password.trim() === '' || password.trim() !== confPassword.trim()) {
|
||||
setMessage({ ok: false, text: 'Las contraseñas no coinciden.' })
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('token', token)
|
||||
body.set('new-password', password.trim())
|
||||
body.set('conf-password', confPassword.trim())
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(resetUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string; redirect?: string } = await resp.json()
|
||||
setMessage({ ok: !!data.success, text: data.message || '' })
|
||||
if (data.success) {
|
||||
setDone(true)
|
||||
if (data.redirect) {
|
||||
setTimeout(() => {
|
||||
window.location.href = data.redirect as string
|
||||
}, 2500)
|
||||
}
|
||||
} else {
|
||||
setBusy(false)
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, text: 'Error en el servidor. Inténtalo de nuevo más tarde.' })
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="body-box-content centered">
|
||||
<form id="nw-reset-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
maxLength={16}
|
||||
name="new-password"
|
||||
id="new-password"
|
||||
placeholder="Nueva contraseña"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<span
|
||||
className={`far toggle-password ${showPw ? 'fa-eye-slash' : 'fa-eye'}`}
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
maxLength={16}
|
||||
name="conf-password"
|
||||
id="conf-password"
|
||||
placeholder="Confirmar contraseña"
|
||||
value={confPassword}
|
||||
onChange={(e) => setConfPassword(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="login-button" disabled={busy || done}>
|
||||
{done ? 'Hecho' : busy ? 'Guardando…' : 'Restablecer contraseña'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && (
|
||||
<span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface DeletedChar {
|
||||
guid: number
|
||||
name: string
|
||||
level: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
characters: DeletedChar[]
|
||||
}
|
||||
|
||||
export function RestoreCharacterForm({ url, csrfToken, characters }: Props) {
|
||||
const [guid, setGuid] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy || done || !guid) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('guid', guid)
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string; redirect?: string } = await resp.json()
|
||||
setMessage({ ok: !!data.success, html: data.message || '' })
|
||||
if (data.success && data.redirect) {
|
||||
setDone(true)
|
||||
setTimeout(() => {
|
||||
window.location.href = data.redirect as string
|
||||
}, 2000)
|
||||
} else {
|
||||
setBusy(false)
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error en el servidor. Inténtalo más tarde.</span>' })
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<select name="guid" value={guid} onChange={(e) => setGuid(e.target.value)} required>
|
||||
<option value="" disabled>Selecciona un personaje</option>
|
||||
{characters.map((c) => (
|
||||
<option value={c.guid} key={c.guid}>
|
||||
{c.name} (Nivel {c.level})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<br />
|
||||
<button type="submit" disabled={busy || done || !guid}>
|
||||
{done ? 'Hecho' : busy ? 'Restaurando…' : 'Restaurar Personaje'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface DeletedItem {
|
||||
recover_id: string
|
||||
item_name: string
|
||||
item_id: string
|
||||
count: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
characters: string[]
|
||||
}
|
||||
|
||||
export function RestoreItemsForm({ url, csrfToken, characters }: Props) {
|
||||
const [character, setCharacter] = useState('')
|
||||
const [items, setItems] = useState<DeletedItem[] | null>(null)
|
||||
const [recoverId, setRecoverId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function doSearch(char: string, keepMessage = false) {
|
||||
if (!char) return
|
||||
setBusy(true)
|
||||
if (!keepMessage) setMessage(null)
|
||||
setItems(null)
|
||||
setRecoverId('')
|
||||
try {
|
||||
const r = await fetch(`${url}?character_name=${encodeURIComponent(char)}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const d: { items?: DeletedItem[]; error?: string } = await r.json()
|
||||
if (d.error) setMessage({ ok: false, html: `<span class="red-form-response">${d.error}</span>` })
|
||||
else setItems(d.items || [])
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error al buscar los ítems.</span>' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function search(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
void doSearch(character)
|
||||
}
|
||||
|
||||
async function recover(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!recoverId || busy) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
const body = new URLSearchParams()
|
||||
body.set('recover_id', recoverId)
|
||||
body.set('character_name', character)
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
const d: { success?: boolean; message?: string } = await r.json()
|
||||
setMessage({ ok: !!d.success, html: d.message || '' })
|
||||
if (d.success) {
|
||||
// refrescar la lista de ítems tras recuperar uno (conservando el mensaje)
|
||||
void doSearch(character, true)
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error al recuperar el ítem.</span>' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={search}>
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
|
||||
<option value="" disabled>Selecciona un personaje</option>
|
||||
{characters.map((c) => (
|
||||
<option value={c} key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" disabled={busy || !character}>
|
||||
{busy ? 'Buscando…' : 'Buscar Ítems Eliminados'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{items !== null && (
|
||||
items.length > 0 ? (
|
||||
<form onSubmit={recover}>
|
||||
<input type="hidden" name="character_name" value={character} />
|
||||
<select value={recoverId} onChange={(e) => setRecoverId(e.target.value)} required>
|
||||
<option value="" disabled>Selecciona un ítem</option>
|
||||
{items.map((it) => (
|
||||
<option value={it.recover_id} key={it.recover_id}>
|
||||
{it.item_name} (x{it.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" disabled={busy || !recoverId}>
|
||||
{busy ? 'Recuperando…' : 'Recuperar Ítem'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p>No hay ítems eliminados para este personaje.</p>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
initialDate: string
|
||||
}
|
||||
|
||||
export function SecurityTokenForm({ url, csrfToken, initialDate }: Props) {
|
||||
const [date, setDate] = useState(initialDate)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string; token_date?: string } = await resp.json()
|
||||
setMessage({ ok: !!data.success, html: data.message || '' })
|
||||
if (data.success && data.token_date) setDate(data.token_date)
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error en el servidor. Inténtalo más tarde.</span>' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>Fecha de solicitud de Token de seguridad:</p>
|
||||
<p><span id="token-date">{date}</span></p>
|
||||
<br />
|
||||
<form id="nw-sec-token-form" onSubmit={handleSubmit}>
|
||||
<button className="sec-token-button" type="submit" disabled={busy}>
|
||||
{busy ? 'Solicitando…' : 'Solicitar token'}
|
||||
</button>
|
||||
</form>
|
||||
<br />
|
||||
<hr />
|
||||
<div className="alert-message" id="sec-token-response" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export interface GameAccount {
|
||||
id: number
|
||||
username: string
|
||||
index: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
accounts: GameAccount[]
|
||||
selectUrl: string
|
||||
}
|
||||
|
||||
export function SelectAccount({ accounts, selectUrl }: Props) {
|
||||
const [busy, setBusy] = useState<number | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function select(id: number) {
|
||||
if (busy !== null) return
|
||||
setBusy(id)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetch(`${selectUrl}?account_id=${id}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const d: { success?: boolean; redirect?: string; error?: string } = await r.json()
|
||||
if (d.success && d.redirect) {
|
||||
window.location.href = d.redirect
|
||||
} else {
|
||||
setError(d.error || 'No se pudo seleccionar la cuenta.')
|
||||
setBusy(null)
|
||||
}
|
||||
} catch {
|
||||
setError('Error de red. Inténtalo de nuevo.')
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
{accounts.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="login-button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => select(a.id)}
|
||||
>
|
||||
{busy === a.id ? 'Entrando…' : a.username}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{error && (
|
||||
<tr>
|
||||
<td>
|
||||
<span className="red-form-response">{error}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ServerStatusData } from '../types'
|
||||
|
||||
function useClock(): string {
|
||||
const [time, setTime] = useState(() => new Date().toLocaleTimeString('es-ES'))
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTime(new Date().toLocaleTimeString('es-ES')), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
return time
|
||||
}
|
||||
|
||||
export function ServerStatus() {
|
||||
const [data, setData] = useState<ServerStatusData | null>(null)
|
||||
const clock = useClock()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/home/status/', { headers: { Accept: 'application/json' } })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json()
|
||||
})
|
||||
.then((d: ServerStatusData) => {
|
||||
if (!cancelled) setData(d)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setData(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const online = data ? data.online_characters : 0
|
||||
const status = data ? data.status : 'Offline'
|
||||
const loginClass = status === 'Online' ? 'green-info' : 'red-info'
|
||||
|
||||
return (
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="yellow-info">
|
||||
{data?.server_name ?? '—'}{' '}
|
||||
<span className="blue-info small-font">{data?.expansion ?? ''}</span>
|
||||
</td>
|
||||
<td className="real-info-box green-info">{online}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Login</td>
|
||||
<td className={`real-info-box ${loginClass}`}>{status}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Hora</td>
|
||||
<td className="real-info-box">
|
||||
<span id="server-time">{clock}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<hr />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="centered">
|
||||
<td colSpan={2}>
|
||||
<p>
|
||||
<span>{data?.address ?? ''}</span>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { redirectToStripeCheckout } from '../stripe'
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
characters: string[]
|
||||
price: string
|
||||
}
|
||||
|
||||
export function TransferForm({ url, csrfToken, characters, price }: Props) {
|
||||
const [character, setCharacter] = useState('')
|
||||
const [destination, setDestination] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('character', character)
|
||||
body.set('destination_account', destination.trim())
|
||||
body.set('security_token', token.trim())
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string; session_id?: string; stripe_public_key?: string } =
|
||||
await resp.json()
|
||||
if (data.success && data.session_id && data.stripe_public_key) {
|
||||
await redirectToStripeCheckout(data.stripe_public_key, data.session_id)
|
||||
return
|
||||
}
|
||||
setMessage({ ok: !!data.success, html: data.message || '' })
|
||||
setBusy(false)
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error al iniciar el pago. Inténtalo más tarde.</span>' })
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="centered">
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<select name="character" value={character} onChange={(e) => setCharacter(e.target.value)} required>
|
||||
<option value="" disabled>Seleccione un personaje</option>
|
||||
{characters.map((c) => (
|
||||
<option value={c} key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<br />
|
||||
<input
|
||||
type="text"
|
||||
name="destination_account"
|
||||
placeholder="Cuenta destino"
|
||||
required
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
/>
|
||||
<br />
|
||||
<input
|
||||
type="password"
|
||||
name="security_token"
|
||||
placeholder="Token de seguridad"
|
||||
required
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
<br />
|
||||
<button type="submit" className="transfer-button" disabled={busy || !character || !destination || !token}>
|
||||
{busy ? 'Procesando…' : `Transferir por ${price} €`}
|
||||
</button>
|
||||
</form>
|
||||
<hr />
|
||||
<center>
|
||||
<div id="transfer-character-response" className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</center>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface VoteSite {
|
||||
name: string
|
||||
image_url: string
|
||||
points: number
|
||||
url: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
voteUrl: string
|
||||
csrfToken: string
|
||||
sites: VoteSite[]
|
||||
}
|
||||
|
||||
export function VotePanel({ voteUrl, csrfToken, sites }: Props) {
|
||||
const [busyUrl, setBusyUrl] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function vote(url: string) {
|
||||
if (busyUrl) return
|
||||
// Abrir el sitio de votación en otra pestaña
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
setBusyUrl(url)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('vote', url)
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(voteUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string } = await resp.json()
|
||||
// El backend manda mensajes con o sin HTML; los envolvemos en verde si es éxito.
|
||||
const html = data.success ? `<span class="ok-form-response">${data.message ?? ''}</span>` : (data.message ?? '')
|
||||
setMessage({ ok: !!data.success, html })
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error al registrar el voto. Inténtalo más tarde.</span>' })
|
||||
} finally {
|
||||
setBusyUrl(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<span>Nota:</span> Gtop100 puede demorar unos minutos en acreditar el voto.
|
||||
</p>
|
||||
<br />
|
||||
<div className="vote-sites">
|
||||
{sites.map((s) => (
|
||||
<div className="inline-div" key={s.url}>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img className="img-med-icon" src={s.image_url} title={s.name} alt={s.name} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{s.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
PV: <span>{s.points}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="vote-button"
|
||||
disabled={busyUrl === s.url}
|
||||
onClick={() => vote(s.url)}
|
||||
>
|
||||
{busyUrl === s.url ? 'Votando…' : 'Votar'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<hr />
|
||||
<div className="alert-message" id="voteResponse" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChangeEmailForm } from '../components/ChangeEmailForm'
|
||||
|
||||
const el = document.getElementById('change-email-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<ChangeEmailForm url={el.dataset.url || ''} csrfToken={el.dataset.csrf || ''} />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChangePasswordForm } from '../components/ChangePasswordForm'
|
||||
|
||||
const el = document.getElementById('change-password-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<ChangePasswordForm
|
||||
url={el.dataset.url || ''}
|
||||
csrfToken={el.dataset.csrf || ''}
|
||||
loginUrl={el.dataset.loginUrl || '/es/log-in/'}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { CharacterPurchaseForm } from '../components/CharacterPurchaseForm'
|
||||
|
||||
// Entry genérico para todos los servicios de personaje de pago. La plantilla
|
||||
// configura el formulario con data-* y una lista de personajes en json_script.
|
||||
const el = document.getElementById('character-purchase-app')
|
||||
if (el) {
|
||||
const dataEl = document.getElementById('character-purchase-data')
|
||||
const characters: string[] = dataEl ? JSON.parse(dataEl.textContent || '[]') : []
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<CharacterPurchaseForm
|
||||
url={el.dataset.url || ''}
|
||||
csrfToken={el.dataset.csrf || ''}
|
||||
characters={characters}
|
||||
price={el.dataset.price || ''}
|
||||
label={el.dataset.label || 'Comprar'}
|
||||
field={el.dataset.field || 'character'}
|
||||
buttonClass={el.dataset.buttonClass || 'rename-button'}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { GoldForm } from '../components/GoldForm'
|
||||
|
||||
const el = document.getElementById('gold-app')
|
||||
if (el) {
|
||||
const cEl = document.getElementById('gold-characters-data')
|
||||
const oEl = document.getElementById('gold-options-data')
|
||||
const characters: string[] = cEl ? JSON.parse(cEl.textContent || '[]') : []
|
||||
const options = oEl ? JSON.parse(oEl.textContent || '[]') : []
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<GoldForm url={el.dataset.url || ''} csrfToken={el.dataset.csrf || ''} characters={characters} options={options} />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HistoryTable } from '../components/HistoryTable'
|
||||
|
||||
// Entry genérico para los historiales de solo lectura. La plantilla define las
|
||||
// columnas en data-columns (JSON) y las filas en un json_script.
|
||||
const el = document.getElementById('history-table-app')
|
||||
if (el) {
|
||||
const dEl = document.getElementById('history-table-data')
|
||||
const rows = dEl ? JSON.parse(dEl.textContent || '[]') : []
|
||||
const columns = JSON.parse(el.dataset.columns || '[]')
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<HistoryTable columns={columns} rows={rows} emptyText={el.dataset.emptyText || 'No hay datos'} />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { NewsList } from '../components/NewsList'
|
||||
import { ServerStatus } from '../components/ServerStatus'
|
||||
|
||||
// Islas de la portada: cada componente React se monta donde Django deja su
|
||||
// contenedor. El resto de la página sigue siendo Django.
|
||||
function mount(id: string, node: React.ReactElement) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
createRoot(el).render(<StrictMode>{node}</StrictMode>)
|
||||
}
|
||||
}
|
||||
|
||||
mount('home-news-app', <NewsList />)
|
||||
mount('home-status-app', <ServerStatus />)
|
||||
@@ -1,17 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { LoginForm } from '../components/LoginForm'
|
||||
|
||||
const el = document.getElementById('login-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<LoginForm
|
||||
loginUrl={el.dataset.loginUrl || ''}
|
||||
successUrl={el.dataset.successUrl || 'my-account'}
|
||||
csrfToken={el.dataset.csrf || ''}
|
||||
siteKey={el.dataset.sitekey || ''}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { AccountDashboard } from '../components/AccountDashboard'
|
||||
|
||||
const el = document.getElementById('account-dashboard-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<AccountDashboard />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RecoverForm } from '../components/RecoverForm'
|
||||
|
||||
const el = document.getElementById('recover-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<RecoverForm
|
||||
recoverUrl={el.dataset.recoverUrl || ''}
|
||||
csrfToken={el.dataset.csrf || ''}
|
||||
siteKey={el.dataset.sitekey || ''}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RegisterForm } from '../components/RegisterForm'
|
||||
|
||||
const el = document.getElementById('register-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<RegisterForm
|
||||
registerUrl={el.dataset.registerUrl || ''}
|
||||
csrfToken={el.dataset.csrf || ''}
|
||||
siteKey={el.dataset.sitekey || ''}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { GuildRenameForm } from '../components/GuildRenameForm'
|
||||
|
||||
const el = document.getElementById('rename-guild-app')
|
||||
if (el) {
|
||||
const dEl = document.getElementById('rename-guild-data')
|
||||
const guilds: string[] = dEl ? JSON.parse(dEl.textContent || '[]') : []
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<GuildRenameForm url={el.dataset.url || ''} csrfToken={el.dataset.csrf || ''} guilds={guilds} />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ResetPassword } from '../components/ResetPassword'
|
||||
|
||||
const el = document.getElementById('reset-password-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<ResetPassword
|
||||
valid={el.dataset.valid === 'true'}
|
||||
token={el.dataset.token || ''}
|
||||
resetUrl={el.dataset.resetUrl || ''}
|
||||
recoverUrl={el.dataset.recoverUrl || 'recover'}
|
||||
csrfToken={el.dataset.csrf || ''}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RestoreCharacterForm } from '../components/RestoreCharacterForm'
|
||||
|
||||
const el = document.getElementById('restore-character-app')
|
||||
if (el) {
|
||||
const dEl = document.getElementById('restore-character-data')
|
||||
const characters = dEl ? JSON.parse(dEl.textContent || '[]') : []
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<RestoreCharacterForm url={el.dataset.url || ''} csrfToken={el.dataset.csrf || ''} characters={characters} />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RestoreItemsForm } from '../components/RestoreItemsForm'
|
||||
|
||||
const el = document.getElementById('restore-items-app')
|
||||
if (el) {
|
||||
const dEl = document.getElementById('restore-items-data')
|
||||
const characters: string[] = dEl ? JSON.parse(dEl.textContent || '[]') : []
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<RestoreItemsForm url={el.dataset.url || ''} csrfToken={el.dataset.csrf || ''} characters={characters} />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SecurityTokenForm } from '../components/SecurityTokenForm'
|
||||
|
||||
const el = document.getElementById('security-token-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<SecurityTokenForm
|
||||
url={el.dataset.url || ''}
|
||||
csrfToken={el.dataset.csrf || ''}
|
||||
initialDate={el.dataset.tokenDate || 'Sin solicitar'}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SelectAccount, type GameAccount } from '../components/SelectAccount'
|
||||
|
||||
const dataEl = document.getElementById('select-account-data')
|
||||
const accounts: GameAccount[] = dataEl ? JSON.parse(dataEl.textContent || '[]') : []
|
||||
|
||||
const el = document.getElementById('select-account-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<SelectAccount
|
||||
accounts={accounts}
|
||||
selectUrl={el.dataset.selectUrl || '/api/account/select/'}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { TransferForm } from '../components/TransferForm'
|
||||
|
||||
const el = document.getElementById('transfer-app')
|
||||
if (el) {
|
||||
const cEl = document.getElementById('transfer-characters-data')
|
||||
const characters: string[] = cEl ? JSON.parse(cEl.textContent || '[]') : []
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<TransferForm
|
||||
url={el.dataset.url || ''}
|
||||
csrfToken={el.dataset.csrf || ''}
|
||||
characters={characters}
|
||||
price={el.dataset.price || ''}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { VotePanel } from '../components/VotePanel'
|
||||
|
||||
const el = document.getElementById('vote-points-app')
|
||||
if (el) {
|
||||
const dEl = document.getElementById('vote-points-data')
|
||||
const sites = dEl ? JSON.parse(dEl.textContent || '[]') : []
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<VotePanel voteUrl={el.dataset.url || ''} csrfToken={el.dataset.csrf || ''} sites={sites} />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
interface StripeInstance {
|
||||
redirectToCheckout: (opts: { sessionId: string }) => Promise<{ error?: { message: string } }>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Stripe?: (key: string) => StripeInstance
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_ID = 'stripe-js'
|
||||
|
||||
/**
|
||||
* Carga Stripe.js (una vez) y redirige al Checkout con el session_id dado.
|
||||
* Rechaza si Stripe no carga o devuelve error.
|
||||
*/
|
||||
export function redirectToStripeCheckout(publicKey: string, sessionId: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
function go() {
|
||||
if (!window.Stripe) return reject(new Error('Stripe no está disponible'))
|
||||
const stripe = window.Stripe(publicKey)
|
||||
stripe
|
||||
.redirectToCheckout({ sessionId })
|
||||
.then((r) => (r.error ? reject(new Error(r.error.message)) : resolve()))
|
||||
.catch(reject)
|
||||
}
|
||||
|
||||
if (window.Stripe) return go()
|
||||
|
||||
if (!document.getElementById(SCRIPT_ID)) {
|
||||
const s = document.createElement('script')
|
||||
s.id = SCRIPT_ID
|
||||
s.src = 'https://js.stripe.com/v3/'
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
let waited = 0
|
||||
const iv = window.setInterval(() => {
|
||||
if (window.Stripe) {
|
||||
window.clearInterval(iv)
|
||||
go()
|
||||
} else if ((waited += 150) > 8000) {
|
||||
window.clearInterval(iv)
|
||||
reject(new Error('No se pudo cargar Stripe.'))
|
||||
}
|
||||
}, 150)
|
||||
})
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface TurnstileOptions {
|
||||
sitekey: string
|
||||
callback?: (token: string) => void
|
||||
'error-callback'?: () => void
|
||||
'expired-callback'?: () => void
|
||||
theme?: 'auto' | 'light' | 'dark'
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (el: HTMLElement, opts: TurnstileOptions) => string
|
||||
reset: (id?: string) => void
|
||||
remove: (id: string) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_ID = 'cf-turnstile-script'
|
||||
|
||||
/**
|
||||
* Carga el script de Cloudflare Turnstile (una vez) y renderiza el widget en el
|
||||
* div referenciado. Devuelve el ref para el contenedor y el token resuelto.
|
||||
* Si sitekey es vacío, no hace nada (captcha desactivado).
|
||||
*/
|
||||
export function useTurnstile(sitekey: string) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [token, setToken] = useState('')
|
||||
const rendered = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sitekey) return
|
||||
let cancelled = false
|
||||
|
||||
function tryRender() {
|
||||
if (cancelled || rendered.current || !ref.current || !window.turnstile) return
|
||||
rendered.current = true
|
||||
window.turnstile.render(ref.current, {
|
||||
sitekey,
|
||||
theme: 'dark',
|
||||
callback: (t) => setToken(t),
|
||||
'error-callback': () => setToken(''),
|
||||
'expired-callback': () => setToken(''),
|
||||
})
|
||||
}
|
||||
|
||||
if (window.turnstile) {
|
||||
tryRender()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!document.getElementById(SCRIPT_ID)) {
|
||||
const s = document.createElement('script')
|
||||
s.id = SCRIPT_ID
|
||||
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||
s.async = true
|
||||
s.defer = true
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
const iv = window.setInterval(() => {
|
||||
if (window.turnstile) {
|
||||
window.clearInterval(iv)
|
||||
tryRender()
|
||||
}
|
||||
}, 200)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(iv)
|
||||
}
|
||||
}, [sitekey])
|
||||
|
||||
return { ref, token }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export interface Noticia {
|
||||
id: number
|
||||
titulo: string
|
||||
fecha: string | null
|
||||
contenido: string
|
||||
enlace: string | null
|
||||
}
|
||||
|
||||
export interface ServerStatusData {
|
||||
server_name: string | null
|
||||
address: string | null
|
||||
expansion: string
|
||||
online_characters: number
|
||||
status: 'Online' | 'Offline'
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// El bundle se construye en ../static/dist (que está en STATICFILES_DIRS),
|
||||
// con manifest para que django-vite resuelva los assets en producción.
|
||||
// base '/static/dist/' → coincide con static_url_prefix='dist' de django-vite.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/static/dist/',
|
||||
build: {
|
||||
manifest: true,
|
||||
outDir: resolve(__dirname, '../static/dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
home: resolve(__dirname, 'src/entries/home.tsx'),
|
||||
login: resolve(__dirname, 'src/entries/login.tsx'),
|
||||
register: resolve(__dirname, 'src/entries/register.tsx'),
|
||||
select_account: resolve(__dirname, 'src/entries/select_account.tsx'),
|
||||
recover: resolve(__dirname, 'src/entries/recover.tsx'),
|
||||
reset_password: resolve(__dirname, 'src/entries/reset_password.tsx'),
|
||||
my_account: resolve(__dirname, 'src/entries/my_account.tsx'),
|
||||
change_password: resolve(__dirname, 'src/entries/change_password.tsx'),
|
||||
security_token: resolve(__dirname, 'src/entries/security_token.tsx'),
|
||||
change_email: resolve(__dirname, 'src/entries/change_email.tsx'),
|
||||
character_purchase: resolve(__dirname, 'src/entries/character_purchase.tsx'),
|
||||
gold_character: resolve(__dirname, 'src/entries/gold_character.tsx'),
|
||||
transfer_character: resolve(__dirname, 'src/entries/transfer_character.tsx'),
|
||||
rename_guild: resolve(__dirname, 'src/entries/rename_guild.tsx'),
|
||||
vote_points: resolve(__dirname, 'src/entries/vote_points.tsx'),
|
||||
history_table: resolve(__dirname, 'src/entries/history_table.tsx'),
|
||||
restore_character: resolve(__dirname, 'src/entries/restore_character.tsx'),
|
||||
restore_items: resolve(__dirname, 'src/entries/restore_items.tsx'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
# ac_soap.py
|
||||
import requests
|
||||
import base64
|
||||
from django.conf import settings
|
||||
|
||||
def execute_soap_command(command):
|
||||
"""
|
||||
Ejecuta un comando en el servidor AzerothCore usando SOAP.
|
||||
|
||||
Parameters:
|
||||
- command (str): El comando a ejecutar, por ejemplo, ".additem 3200"
|
||||
|
||||
Returns:
|
||||
- str: La respuesta del servidor si el comando se ejecuta correctamente.
|
||||
- None: Si ocurre algún error durante la ejecución.
|
||||
"""
|
||||
try:
|
||||
# Crear la carga SOAP
|
||||
soap_command = f'''<?xml version="1.0" encoding="utf-8"?>
|
||||
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="{settings.AC_SOAP_URN}">
|
||||
<SOAP-ENV:Body>
|
||||
<ns1:executeCommand>
|
||||
<command>{command}</command>
|
||||
</ns1:executeCommand>
|
||||
</SOAP-ENV:Body>
|
||||
</SOAP-ENV:Envelope>'''
|
||||
|
||||
# Autenticación básica usando las credenciales en settings.py
|
||||
auth_header = f"Basic {base64.b64encode(f'{settings.AC_SOAP_USER}:{settings.AC_SOAP_PASSWORD}'.encode()).decode()}"
|
||||
|
||||
headers = {
|
||||
'Authorization': auth_header,
|
||||
'Content-Type': 'application/xml'
|
||||
}
|
||||
|
||||
# Realizar la solicitud SOAP al servidor de AzerothCore
|
||||
response = requests.post(settings.AC_SOAP_URL, data=soap_command, headers=headers, timeout=5)
|
||||
|
||||
# Verificar si la respuesta fue exitosa
|
||||
if response.status_code == 200:
|
||||
return response.text
|
||||
else:
|
||||
print(f"[ERROR] Comando SOAP fallido: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[EXCEPCIÓN] Error de conexión con el servidor SOAP: {str(e)}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[EXCEPCIÓN] Error inesperado al ejecutar el comando SOAP: {str(e)}")
|
||||
return None
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
# home/admin.py
|
||||
from django.contrib import admin
|
||||
from django import forms
|
||||
from .models import (
|
||||
Noticia, ClienteCategoria, SistemaOperativo, ServerSelection,
|
||||
RecruitAFriend, DownloadClientPage, ContentCreator, RecruitReward, GuildRenameSettings, VoteSite, RenamePrice, CustomizePrice, ChangeRacePrice, ChangeFactionPrice, LevelUpPrice, GoldPrice, TransferPrice, MaintenanceMode,
|
||||
StripeLog, Pedido
|
||||
)
|
||||
from django.db import connections
|
||||
import logging
|
||||
from django.shortcuts import redirect
|
||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@admin.register(StripeLog)
|
||||
class StripeLogAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'account_id', 'username', 'email', 'acore_ip', 'stripe_ip', 'product_name',
|
||||
'amount', 'session_id', 'mode', 'character_name', 'timestamp'
|
||||
) # Añadido stripe_ip
|
||||
list_filter = ('mode', 'timestamp') # Filtros por campo en la parte lateral
|
||||
search_fields = ('username', 'product_name', 'session_id', 'email', 'stripe_ip') # Ahora incluye 'stripe_ip' en la búsqueda
|
||||
ordering = ('-timestamp',) # Ordenar por timestamp descendentes
|
||||
|
||||
@admin.register(Pedido)
|
||||
class PedidoLogAdmin(admin.ModelAdmin): # Ahora esta es una clase de admin
|
||||
list_display = ('transaction_id', 'email_cliente', 'estado', 'monto') # Campos que se mostrarán en el panel
|
||||
search_fields = ('transaction_id', 'email_cliente') # Habilita búsqueda por ID y email
|
||||
list_filter = ('estado',) # Filtro por estado de pago
|
||||
|
||||
|
||||
@admin.register(ChangeFactionPrice)
|
||||
class ChangeFactionPriceAdmin(admin.ModelAdmin):
|
||||
list_display = ('price',)
|
||||
|
||||
|
||||
@admin.register(MaintenanceMode)
|
||||
class MaintenanceModeAdmin(admin.ModelAdmin):
|
||||
list_display = ('is_active',)
|
||||
|
||||
@admin.register(GoldPrice)
|
||||
class GoldPriceAdmin(admin.ModelAdmin):
|
||||
list_display = ('gold_amount', 'price')
|
||||
ordering = ('gold_amount',)
|
||||
|
||||
|
||||
@admin.register(TransferPrice)
|
||||
class TransferPriceAdmin(admin.ModelAdmin):
|
||||
list_display = ('price',)
|
||||
|
||||
@admin.register(LevelUpPrice)
|
||||
class LevelUpPriceAdmin(admin.ModelAdmin):
|
||||
list_display = ('price',)
|
||||
|
||||
# Formulario para el modelo Noticia
|
||||
class NoticiaAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Noticia
|
||||
fields = ['titulo', 'contenido', 'fecha_fin_evento', 'enlace']
|
||||
widgets = {
|
||||
'contenido': CKEditor5Widget(config_name='default'),
|
||||
}
|
||||
|
||||
@admin.register(RenamePrice)
|
||||
class RenamePriceAdmin(admin.ModelAdmin):
|
||||
list_display = ("price",)
|
||||
|
||||
|
||||
@admin.register(CustomizePrice)
|
||||
class CustomizePriceAdmin(admin.ModelAdmin):
|
||||
list_display = ("price",)
|
||||
|
||||
@admin.register(ChangeRacePrice)
|
||||
class ChangeRacePriceAdmin(admin.ModelAdmin):
|
||||
list_display = ('price',)
|
||||
|
||||
@admin.register(Noticia)
|
||||
class NoticiaAdmin(admin.ModelAdmin):
|
||||
form = NoticiaAdminForm
|
||||
list_display = ['titulo', 'fecha_publicacion']
|
||||
readonly_fields = ['fecha_publicacion']
|
||||
fields = ['titulo', 'contenido', 'fecha_fin_evento', 'enlace']
|
||||
|
||||
|
||||
# Formulario para el modelo RecruitAFriend
|
||||
class RecruitAFriendAdminForm(forms.ModelForm):
|
||||
content = forms.CharField(widget=CKEditor5Widget(config_name='default'))
|
||||
|
||||
class Meta:
|
||||
model = RecruitAFriend
|
||||
fields = ['title', 'information', 'content']
|
||||
|
||||
@admin.register(RecruitAFriend)
|
||||
class RecruitAFriendAdmin(admin.ModelAdmin):
|
||||
form = RecruitAFriendAdminForm
|
||||
list_display = ['title', 'updated_at']
|
||||
fields = ['title', 'information', 'content']
|
||||
|
||||
|
||||
# Formulario para el modelo DownloadClientPage
|
||||
class DownloadClientPageAdminForm(forms.ModelForm):
|
||||
content = forms.CharField(widget=CKEditor5Widget(config_name='default'))
|
||||
mac_instructions = forms.CharField(widget=CKEditor5Widget(config_name='default'))
|
||||
|
||||
class Meta:
|
||||
model = DownloadClientPage
|
||||
fields = ['title', 'content', 'mac_instructions']
|
||||
|
||||
@admin.register(DownloadClientPage)
|
||||
class DownloadClientPageAdmin(admin.ModelAdmin):
|
||||
form = DownloadClientPageAdminForm
|
||||
list_display = ['title', 'updated_at']
|
||||
fields = ['title', 'content', 'mac_instructions']
|
||||
|
||||
|
||||
# Formulario para el modelo ContentCreator
|
||||
class ContentCreatorAdminForm(forms.ModelForm):
|
||||
description = forms.CharField(widget=CKEditor5Widget(config_name='default'))
|
||||
|
||||
class Meta:
|
||||
model = ContentCreator
|
||||
fields = [
|
||||
'title', 'description', 'content_type', 'subtitle',
|
||||
'youtube_video_url', 'avatar_image_url',
|
||||
'youtube_channel_url', 'facebook_page_url'
|
||||
]
|
||||
|
||||
@admin.register(ContentCreator)
|
||||
class ContentCreatorAdmin(admin.ModelAdmin):
|
||||
form = ContentCreatorAdminForm
|
||||
list_display = ['title', 'updated_at']
|
||||
fields = [
|
||||
'title', 'description', 'content_type', 'subtitle',
|
||||
'youtube_video_url', 'avatar_image_url',
|
||||
'youtube_channel_url', 'facebook_page_url'
|
||||
]
|
||||
|
||||
|
||||
# Administración para ServerSelection
|
||||
class ServerSelectionAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'address', 'port', 'gamebuild']
|
||||
change_list_template = "admin/server_selection.html"
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
servers = []
|
||||
try:
|
||||
with connections['acore_auth'].cursor() as cursor:
|
||||
cursor.execute("SELECT id, name, address, port, gamebuild FROM realmlist")
|
||||
servers = cursor.fetchall()
|
||||
except Exception as e:
|
||||
logger.error("Error al obtener servidores: %s", e)
|
||||
|
||||
if request.method == "POST" and "server_selection" in request.POST:
|
||||
selected_server_id = int(request.POST["server_selection"])
|
||||
selected_server = next((s for s in servers if s[0] == selected_server_id), None)
|
||||
if selected_server:
|
||||
ServerSelection.objects.all().delete()
|
||||
ServerSelection.objects.create(
|
||||
server_id=selected_server[0],
|
||||
name=selected_server[1],
|
||||
address=selected_server[2],
|
||||
port=selected_server[3],
|
||||
gamebuild=selected_server[4]
|
||||
)
|
||||
return redirect("admin:home_serverselection_changelist")
|
||||
|
||||
extra_context = extra_context or {}
|
||||
extra_context['servers'] = servers
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
# Inlines para ClienteCategoria
|
||||
class SistemaOperativoInline(admin.TabularInline):
|
||||
model = SistemaOperativo
|
||||
extra = 1
|
||||
|
||||
class ClienteCategoriaAdmin(admin.ModelAdmin):
|
||||
inlines = [SistemaOperativoInline]
|
||||
|
||||
|
||||
@admin.register(RecruitReward)
|
||||
class RecruitRewardAdmin(admin.ModelAdmin):
|
||||
list_display = ('required_friends', 'reward_name', 'item_id', 'item_quantity', 'icon_class')
|
||||
search_fields = ('reward_name',)
|
||||
|
||||
@admin.register(GuildRenameSettings)
|
||||
class GuildRenameSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'cost') # Asegúrate de incluir 'id' antes de 'cost'
|
||||
list_display_links = ('id',) # Permite hacer clic en 'id' para editar
|
||||
list_editable = ('cost',) # Ahora 'cost' es editable en línea
|
||||
|
||||
@admin.register(VoteSite)
|
||||
class VoteSiteAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'points', 'url', 'image_url')
|
||||
search_fields = ('name',)
|
||||
list_editable = ('points',)
|
||||
list_display_links = ('name',)
|
||||
|
||||
|
||||
# Registro de modelos en el admin
|
||||
admin.site.register(ClienteCategoria, ClienteCategoriaAdmin)
|
||||
admin.site.register(SistemaOperativo)
|
||||
admin.site.register(ServerSelection, ServerSelectionAdmin)
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Rutas de la API JSON (islas React). Se montan bajo /api/ fuera de i18n_patterns."""
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('home/news/', views.home_news_api, name='api_home_news'),
|
||||
path('home/status/', views.home_status_api, name='api_home_status'),
|
||||
path('account/select/', views.account_select_api, name='api_account_select'),
|
||||
path('account/me/', views.account_me_api, name='api_account_me'),
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HomeConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'home'
|
||||
-216
@@ -1,216 +0,0 @@
|
||||
"""
|
||||
Utilidades de autenticación para servidores 3.4.3 (TrinityCore) con cuentas
|
||||
Battle.net.
|
||||
|
||||
Dos algoritmos SRP6 distintos conviven:
|
||||
|
||||
* ``battlenet_accounts`` -> SRP6 **v2**: PBKDF2-HMAC-SHA512, N de 2048 bits,
|
||||
g = 2. El "usuario" SRP es HEX(SHA256(UPPER(email))). Es el que usa el
|
||||
cliente 3.4.3 para conectarse (login por email).
|
||||
|
||||
* ``account`` (cuenta de juego) -> SRP6 **Grunt/SHA1**: el clásico de
|
||||
AzerothCore/3.3.5a. Cada cuenta bnet tiene 1..N cuentas de juego con
|
||||
username ``<bnetId>#<index>``.
|
||||
|
||||
Referencias del propio source 3.4.3:
|
||||
src/common/Cryptography/Authentication/SRP6.cpp (CalculateX / CalculateVerifier)
|
||||
src/server/game/Accounts/BattlenetAccountMgr.cpp (CreateBattlenetAccount)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SRP6 v2 (battlenet_accounts) — PBKDF2-HMAC-SHA512
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# BnetSRP6v2Base::N (2048 bits) y g, tal cual en SRP6.cpp
|
||||
_BNET_N = int(
|
||||
"AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050"
|
||||
"A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50"
|
||||
"E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8"
|
||||
"55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B"
|
||||
"CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748"
|
||||
"544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6"
|
||||
"AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6"
|
||||
"94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73",
|
||||
16,
|
||||
)
|
||||
_BNET_g = 2
|
||||
_BNET_SALT_LEN = 32
|
||||
_BNET_ITERATIONS = 15000 # GetXIterations() para v2
|
||||
_BNET_SRP_VERSION = 2
|
||||
_TWO_POW_512 = 1 << 512
|
||||
|
||||
# TrinityCore almacena el verifier con BigNumber::ToByteVector(), que por
|
||||
# defecto es little-endian. Si al comparar contra una cuenta creada por el
|
||||
# propio servidor (`.bnetaccount create`) el login del CLIENTE fallara, prueba a
|
||||
# poner esto en False (big-endian). El login del PORTAL funciona en cualquier
|
||||
# caso porque registra y verifica con el mismo criterio.
|
||||
_BNET_VERIFIER_LITTLE_ENDIAN = True
|
||||
|
||||
|
||||
def bnet_srp_username(email):
|
||||
"""SRP username de bnet = HEX(SHA256(UPPER(email))) en mayúsculas."""
|
||||
email_up = email.strip().upper()
|
||||
return hashlib.sha256(email_up.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
def _bnet_calculate_x(email, password, salt):
|
||||
"""Replica BnetSRP6v2Base::CalculateX (SRP6.cpp)."""
|
||||
srp_user = bnet_srp_username(email)
|
||||
tmp = (srp_user + ":" + password).encode("utf-8")
|
||||
x_bytes = hashlib.pbkdf2_hmac("sha512", tmp, salt, _BNET_ITERATIONS, dklen=64)
|
||||
x = int.from_bytes(x_bytes, "big")
|
||||
if x_bytes[0] & 0x80: # si el bit alto está puesto, resta 2^512
|
||||
x -= _TWO_POW_512
|
||||
return x % (_BNET_N - 1)
|
||||
|
||||
|
||||
def _int_to_verifier_bytes(value):
|
||||
"""Emula BigNumber::ToByteVector(): longitud mínima, endianness configurable."""
|
||||
length = max(1, (value.bit_length() + 7) // 8)
|
||||
order = "little" if _BNET_VERIFIER_LITTLE_ENDIAN else "big"
|
||||
return value.to_bytes(length, order)
|
||||
|
||||
|
||||
def _verifier_bytes_to_int(data):
|
||||
order = "little" if _BNET_VERIFIER_LITTLE_ENDIAN else "big"
|
||||
return int.from_bytes(data, order)
|
||||
|
||||
|
||||
def bnet_make_registration(email, password):
|
||||
"""
|
||||
Genera (salt, verifier, srp_version) para una nueva cuenta Battle.net.
|
||||
|
||||
salt: 32 bytes aleatorios (se guarda tal cual).
|
||||
verifier: g^x mod N en longitud mínima (como TrinityCore::ToByteVector).
|
||||
"""
|
||||
salt = os.urandom(_BNET_SALT_LEN)
|
||||
x = _bnet_calculate_x(email, password, salt)
|
||||
v = pow(_BNET_g, x, _BNET_N)
|
||||
return salt, _int_to_verifier_bytes(v), _BNET_SRP_VERSION
|
||||
|
||||
|
||||
def bnet_verify(email, password, salt, stored_verifier):
|
||||
"""Comprueba la contraseña de una cuenta Battle.net (login por email)."""
|
||||
if salt is None or stored_verifier is None:
|
||||
return False
|
||||
if isinstance(salt, memoryview):
|
||||
salt = bytes(salt)
|
||||
if isinstance(stored_verifier, memoryview):
|
||||
stored_verifier = bytes(stored_verifier)
|
||||
x = _bnet_calculate_x(email, password, salt)
|
||||
v = pow(_BNET_g, x, _BNET_N)
|
||||
# Comparación numérica (robusta frente a padding).
|
||||
return v == _verifier_bytes_to_int(stored_verifier)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SRP6 Grunt/SHA1 (cuenta de juego `account`) — el clásico de 3.3.5a
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GRUNT_g = 7
|
||||
_GRUNT_N = int("894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7", 16)
|
||||
_GRUNT_SALT_LEN = 32
|
||||
|
||||
|
||||
def _sha1(data):
|
||||
return hashlib.sha1(data).digest()
|
||||
|
||||
|
||||
def game_calculate_verifier(username, password, salt):
|
||||
"""
|
||||
SRP6 Grunt (SHA1) para la tabla `account`. ``username`` y ``password`` se
|
||||
pasan en mayúsculas (TrinityCore hace Utf8ToUpperOnlyLatin en ambos).
|
||||
Devuelve el verifier en bytes little-endian (32).
|
||||
"""
|
||||
if isinstance(salt, memoryview):
|
||||
salt = bytes(salt)
|
||||
h1 = _sha1((username.upper() + ":" + password.upper()).encode("utf-8"))
|
||||
h2 = _sha1(salt + h1)
|
||||
h2_int = int.from_bytes(h2, "little")
|
||||
verifier = pow(_GRUNT_g, h2_int, _GRUNT_N)
|
||||
return verifier.to_bytes(32, "little")
|
||||
|
||||
|
||||
def game_make_registration(username, password):
|
||||
"""Genera (salt, verifier) Grunt para una cuenta de juego nueva."""
|
||||
salt = os.urandom(_GRUNT_SALT_LEN)
|
||||
verifier = game_calculate_verifier(username, password[:16], salt)
|
||||
return salt, verifier
|
||||
|
||||
|
||||
def game_verify(username, password, salt, stored_verifier):
|
||||
"""Comprueba la contraseña de una cuenta de juego (SRP6 Grunt)."""
|
||||
if salt is None or stored_verifier is None:
|
||||
return False
|
||||
if isinstance(stored_verifier, memoryview):
|
||||
stored_verifier = bytes(stored_verifier)
|
||||
calc = game_calculate_verifier(username, password[:16], salt)
|
||||
return int.from_bytes(calc, "little") == int.from_bytes(stored_verifier, "little")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers de acceso a la BD auth (bnet <-> cuentas de juego)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def normalize_email(email):
|
||||
"""El email en battlenet_accounts se almacena en MAYÚSCULAS."""
|
||||
return email.strip().upper()
|
||||
|
||||
|
||||
def get_bnet_account_by_email(cursor, email):
|
||||
"""Devuelve dict {id, email, srp_version, salt, verifier} o None."""
|
||||
cursor.execute(
|
||||
"SELECT id, email, srp_version, salt, verifier "
|
||||
"FROM battlenet_accounts WHERE email = %s",
|
||||
[normalize_email(email)],
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"email": row[1],
|
||||
"srp_version": row[2],
|
||||
"salt": row[3],
|
||||
"verifier": row[4],
|
||||
}
|
||||
|
||||
|
||||
def get_game_accounts_for_bnet(cursor, bnet_id):
|
||||
"""Lista de cuentas de juego de una cuenta bnet: [{id, username, index}]."""
|
||||
cursor.execute(
|
||||
"SELECT id, username, battlenet_index "
|
||||
"FROM account WHERE battlenet_account = %s ORDER BY battlenet_index",
|
||||
[bnet_id],
|
||||
)
|
||||
return [{"id": r[0], "username": r[1], "index": r[2]} for r in cursor.fetchall()]
|
||||
|
||||
|
||||
def get_next_battlenet_index(cursor, bnet_id):
|
||||
"""Siguiente battlenet_index libre para una cuenta bnet (1-based)."""
|
||||
cursor.execute(
|
||||
"SELECT MAX(battlenet_index) FROM account WHERE battlenet_account = %s",
|
||||
[bnet_id],
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return (row[0] or 0) + 1
|
||||
|
||||
|
||||
def make_game_account_username(bnet_id, index=1):
|
||||
"""Nombre de la cuenta de juego: ``<bnetId>#<index>`` (como TrinityCore)."""
|
||||
return f"{bnet_id}#{index}"
|
||||
|
||||
|
||||
def get_battlepay_credits(cursor, bnet_id):
|
||||
"""Créditos Battlepay de la cuenta bnet (0 si la columna no existe)."""
|
||||
if not bnet_id:
|
||||
return 0
|
||||
try:
|
||||
cursor.execute("SELECT battlePayCredits FROM battlenet_accounts WHERE id = %s", [bnet_id])
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row and row[0] is not None else 0
|
||||
except Exception:
|
||||
return 0
|
||||
@@ -1,40 +0,0 @@
|
||||
from django.conf import settings
|
||||
from .models import ServerSelection
|
||||
from django.db import connections
|
||||
import socket
|
||||
|
||||
def url_principal(request):
|
||||
"""Devuelve la URL principal del sitio."""
|
||||
current_domain = f"{request.scheme}://{request.get_host()}"
|
||||
return {'URL_PRINCIPAL': current_domain}
|
||||
|
||||
def configuracion_global(request):
|
||||
"""Devuelve configuraciones globales como el nombre del servidor y el año actual."""
|
||||
return {
|
||||
'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR,
|
||||
'ANIO_ACTUAL': settings.ANIO_ACTUAL,
|
||||
'TURNSTILE_SITE_KEY': settings.TURNSTILE_SITE_KEY,
|
||||
}
|
||||
|
||||
def get_server_info(request):
|
||||
"""Devuelve información del servidor para que esté disponible en todas las plantillas."""
|
||||
server_selection = ServerSelection.objects.first()
|
||||
|
||||
online_count = 0
|
||||
if server_selection:
|
||||
# Obtener la cantidad de personajes en línea
|
||||
try:
|
||||
with connections['acore_characters'].cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM characters WHERE online = 1")
|
||||
result = cursor.fetchone()
|
||||
if result is not None:
|
||||
online_count = result[0]
|
||||
except Exception as e:
|
||||
# Manejar errores de conexión o consulta
|
||||
print(f"Error al obtener personajes en línea: {e}")
|
||||
|
||||
return {
|
||||
'server': server_selection,
|
||||
'online_characters': online_count,
|
||||
'expansion': server_selection.expansion if server_selection else 'Unknown',
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import os
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
def enviar_correo(subject, to_email, template, context):
|
||||
"""
|
||||
Envía un correo electrónico utilizando una plantilla HTML.
|
||||
|
||||
Args:
|
||||
subject (str): El asunto del correo.
|
||||
to_email (str): Dirección de correo del destinatario.
|
||||
template (str): Ruta de la plantilla HTML.
|
||||
context (dict): Contexto para renderizar la plantilla.
|
||||
"""
|
||||
try:
|
||||
# Renderizar el contenido del correo desde una plantilla
|
||||
html_message = render_to_string(template, context)
|
||||
plain_message = strip_tags(html_message)
|
||||
from_email = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
# Enviar el correo
|
||||
send_mail(
|
||||
subject,
|
||||
plain_message,
|
||||
from_email,
|
||||
[to_email],
|
||||
html_message=html_message,
|
||||
fail_silently=False,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error al enviar el correo: {e}")
|
||||
return False
|
||||
@@ -1,28 +0,0 @@
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import resolve, reverse
|
||||
from .models import MaintenanceMode
|
||||
|
||||
class MaintenanceMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# Excluir todas las rutas bajo /admin y la propia página de mantenimiento
|
||||
excluded_prefixes = [
|
||||
reverse('admin:index').rstrip('/'), # Asegura que todas las rutas bajo /admin estén excluidas
|
||||
reverse('maintenance').rstrip('/'), # Página de mantenimiento
|
||||
]
|
||||
|
||||
# Verificar si la ruta actual está en los prefijos excluidos
|
||||
if any(request.path.startswith(prefix) for prefix in excluded_prefixes):
|
||||
return self.get_response(request)
|
||||
|
||||
# Verificar si el modo de mantenimiento está activo
|
||||
try:
|
||||
maintenance = MaintenanceMode.objects.first()
|
||||
if maintenance and maintenance.is_active:
|
||||
return redirect('maintenance')
|
||||
except MaintenanceMode.DoesNotExist:
|
||||
pass
|
||||
|
||||
return self.get_response(request)
|
||||
@@ -1,114 +0,0 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-11 16:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_ckeditor_5.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClienteCategoria',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('nombre', models.CharField(max_length=100)),
|
||||
('descripcion', models.CharField(blank=True, max_length=200, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContentCreator',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('description', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Descripción')),
|
||||
('content_type', models.CharField(default='Contenido general', max_length=60)),
|
||||
('subtitle', models.CharField(default='Nova WoW', max_length=60)),
|
||||
('youtube_video_url', models.URLField(blank=True, max_length=300, null=True)),
|
||||
('avatar_image_url', models.URLField(blank=True, max_length=300, null=True)),
|
||||
('youtube_channel_url', models.URLField(blank=True, max_length=300, null=True)),
|
||||
('facebook_page_url', models.URLField(blank=True, max_length=300, null=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DownloadClientPage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(default='Descarga del cliente', max_length=255)),
|
||||
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Contenido')),
|
||||
('mac_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True, verbose_name='Instrucciones para macOS')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Noticia',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('titulo', models.CharField(max_length=200)),
|
||||
('contenido', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Contenido')),
|
||||
('fecha_publicacion', models.DateTimeField(auto_now=True)),
|
||||
('fecha_fin_evento', models.DateField(blank=True, null=True)),
|
||||
('enlace', models.URLField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RecruitAFriend',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(default='Recluta a un amigo', max_length=255)),
|
||||
('information', models.CharField(default='Información', max_length=255)),
|
||||
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Contenido')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RecruitReward',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('required_friends', models.IntegerField()),
|
||||
('reward_name', models.CharField(max_length=100)),
|
||||
('item_id', models.IntegerField()),
|
||||
('item_quantity', models.IntegerField(default=1)),
|
||||
('item_link', models.URLField()),
|
||||
('icon_class', models.CharField(help_text="Clase CSS para el icono (ej. 'icontinyl q3')", max_length=50)),
|
||||
('claimed_by', models.CharField(blank=True, max_length=100, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ServerSelection',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('server_id', models.IntegerField()),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('address', models.CharField(max_length=100)),
|
||||
('port', models.IntegerField()),
|
||||
('gamebuild', models.IntegerField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClaimedReward',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('account_id', models.IntegerField()),
|
||||
('character_name', models.CharField(max_length=100)),
|
||||
('username', models.CharField(max_length=100)),
|
||||
('claimed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('recruit_reward', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='claimed_rewards', to='home.recruitreward')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SistemaOperativo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('nombre', models.CharField(max_length=100)),
|
||||
('url_descarga', models.URLField()),
|
||||
('categoria', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sistemas_operativos', to='home.clientecategoria')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,37 +0,0 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-12 12:56
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccountActivation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('username', models.CharField(max_length=17)),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('password', models.CharField(max_length=64)),
|
||||
('salt', models.CharField(max_length=64)),
|
||||
('verifier', models.CharField(max_length=128)),
|
||||
('recruiter_id', models.IntegerField(blank=True, null=True)),
|
||||
('hash', models.CharField(max_length=32, unique=True)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
],
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='recruitreward',
|
||||
name='claimed_by',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='claimedreward',
|
||||
name='ip_address',
|
||||
field=models.CharField(default='127.0.0.1', max_length=45),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-12 13:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0002_accountactivation_remove_recruitreward_claimed_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='accountactivation',
|
||||
name='salt',
|
||||
field=models.CharField(max_length=128),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-12 14:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0003_alter_accountactivation_salt'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='accountactivation',
|
||||
name='salt',
|
||||
field=models.BinaryField(max_length=32),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountactivation',
|
||||
name='verifier',
|
||||
field=models.BinaryField(max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -1,24 +0,0 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-13 10:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0004_alter_accountactivation_salt_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SecurityToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user_id', models.IntegerField()),
|
||||
('token', models.CharField(max_length=6)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,186 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2024-12-26 21:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0005_securitytoken'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ChangeFactionPrice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(decimal_places=2, default=10.0, max_digits=6)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ChangeRacePrice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=5)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CustomizePrice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(decimal_places=2, default=1.0, max_digits=5)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GoldPrice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('gold_amount', models.IntegerField()),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GuildRenameSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('cost', models.IntegerField(default=1000, help_text='Costo en Donation Points (DP) para renombrar una hermandad.')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Configuración de Renombrar Hermandad',
|
||||
'verbose_name_plural': 'Configuraciones de Renombrar Hermandad',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HomeApiPoints',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('accountID', models.IntegerField(unique=True)),
|
||||
('vp', models.IntegerField(default=0)),
|
||||
('dp', models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'home_api_points',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LevelUpPrice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(decimal_places=2, default=10.0, max_digits=10)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RenamePrice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(decimal_places=2, default=2.0, max_digits=5)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReviveHistory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('character_name', models.CharField(max_length=50)),
|
||||
('used_at', models.DateTimeField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TransferPrice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=5)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UnstuckHistory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('character_name', models.CharField(max_length=50)),
|
||||
('used_at', models.DateTimeField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VoteSite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('url', models.URLField(help_text='URL del sitio de votación')),
|
||||
('image_url', models.URLField(help_text='URL completa de la imagen', max_length=300)),
|
||||
('points', models.IntegerField(default=1, help_text='Puntos otorgados por votar')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='securitytoken',
|
||||
name='user_id',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountactivation',
|
||||
name='is_new_email_used',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountactivation',
|
||||
name='is_used',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountactivation',
|
||||
name='old_email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountactivation',
|
||||
name='old_email_hash',
|
||||
field=models.CharField(blank=True, max_length=32, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='securitytoken',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StoreCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='home.storecategory')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StoreItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('item_id', models.IntegerField()),
|
||||
('image_url', models.URLField()),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=6)),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.storecategory')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Cart',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('username', models.CharField(max_length=150)),
|
||||
('added_at', models.DateTimeField(auto_now_add=True)),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.storeitem')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VoteLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('account_id', models.IntegerField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_vote_time', models.DateTimeField(blank=True, null=True)),
|
||||
('processed', models.BooleanField(default=False)),
|
||||
('processed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('vote_site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.votesite')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Generated by Django 4.2.17 on 2024-12-28 21:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0006_changefactionprice_changeraceprice_customizeprice_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MaintenanceMode',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_active', models.BooleanField(default=False)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,49 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-15 18:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0007_maintenancemode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
],
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='storecategory',
|
||||
name='parent_category',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='storeitem',
|
||||
name='category',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Item',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('item_id', models.IntegerField(unique=True)),
|
||||
('image_url', models.URLField()),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='home.category')),
|
||||
],
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Cart',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='StoreCategory',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='StoreItem',
|
||||
),
|
||||
]
|
||||
@@ -1,30 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-16 18:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0008_category_remove_storecategory_parent_category_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='StripeLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('account_id', models.IntegerField()),
|
||||
('username', models.CharField(max_length=100)),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('acore_ip', models.GenericIPAddressField()),
|
||||
('stripe_ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('product_name', models.TextField()),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('session_id', models.CharField(max_length=255)),
|
||||
('mode', models.CharField(choices=[('test', 'Test'), ('live', 'Live')], max_length=10)),
|
||||
('character_name', models.CharField(max_length=100)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-18 10:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0009_stripelog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LoginAttempt',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('username', models.CharField(max_length=100)),
|
||||
('status', models.CharField(max_length=50)),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-03-02 16:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0010_loginattempt'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Pedido',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('transaction_id', models.CharField(blank=True, max_length=100, null=True, unique=True)),
|
||||
('email_cliente', models.EmailField(max_length=254)),
|
||||
('estado', models.CharField(default='Pendiente', max_length=20)),
|
||||
('monto', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='guildrenamesettings',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='guildrenamesettings',
|
||||
name='cost',
|
||||
field=models.DecimalField(decimal_places=2, default=10, help_text='Costo en euros para renombrar una hermandad.', max_digits=10),
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# Generated by Django 6.0.7 on 2026-07-12 16:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0011_pedido_alter_guildrenamesettings_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='accountactivation',
|
||||
name='salt',
|
||||
field=models.BinaryField(blank=True, max_length=32, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountactivation',
|
||||
name='username',
|
||||
field=models.CharField(blank=True, max_length=17, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountactivation',
|
||||
name='verifier',
|
||||
field=models.BinaryField(blank=True, max_length=256, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.7 on 2026-07-12 17:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0012_alter_accountactivation_salt_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stripelog',
|
||||
name='fulfilled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,24 +0,0 @@
|
||||
# Generated by Django 6.0.7 on 2026-07-12 20:53
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0013_stripelog_fulfilled'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PasswordReset',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('token', models.CharField(max_length=64, unique=True)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('used', models.BooleanField(default=False)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Modelos de la app `home`, divididos por secciones.
|
||||
|
||||
Antes un único home/models.py. El `import *` de cada submódulo re-exporta
|
||||
los modelos para que `from home.models import X`, el admin y las migraciones
|
||||
(app_label=`home`, nombres de modelo intactos) sigan funcionando igual.
|
||||
"""
|
||||
from .pricing import * # noqa: F401,F403
|
||||
from .store import * # noqa: F401,F403
|
||||
from .site import * # noqa: F401,F403
|
||||
from .recruit import * # noqa: F401,F403
|
||||
from .accounts import * # noqa: F401,F403
|
||||
from .voting import * # noqa: F401,F403
|
||||
from .history import * # noqa: F401,F403
|
||||
@@ -1,62 +0,0 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class AccountActivation(models.Model):
|
||||
# En 3.4.3 (Battle.net) la identidad es el email; el username de la cuenta
|
||||
# de juego se genera al activar (<bnetId>#1), por eso es opcional aquí.
|
||||
username = models.CharField(max_length=17, null=True, blank=True)
|
||||
email = models.EmailField()
|
||||
old_email = models.EmailField(null=True, blank=True)
|
||||
password = models.CharField(max_length=64)
|
||||
# salt/verifier ya no se precalculan en el registro (se calculan al activar,
|
||||
# tanto el de bnet como el de la cuenta de juego). Se conservan opcionales
|
||||
# por compatibilidad con el flujo de cambio de email.
|
||||
salt = models.BinaryField(max_length=32, null=True, blank=True)
|
||||
verifier = models.BinaryField(max_length=256, null=True, blank=True)
|
||||
recruiter_id = models.IntegerField(null=True, blank=True)
|
||||
hash = models.CharField(max_length=32, unique=True)
|
||||
old_email_hash = models.CharField(max_length=32, null=True, blank=True)
|
||||
is_used = models.BooleanField(default=False) # Indica si el old_email_hash ha sido usado
|
||||
is_new_email_used = models.BooleanField(default=False) # Indica si el hash del nuevo correo ha sido usado
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
|
||||
def is_expired(self):
|
||||
return timezone.now() > self.created_at + timezone.timedelta(hours=1)
|
||||
|
||||
def __str__(self):
|
||||
return f"Activation for {self.username}"
|
||||
|
||||
class PasswordReset(models.Model):
|
||||
"""Token de restablecimiento de contraseña (cuenta Battle.net, por email)."""
|
||||
email = models.EmailField()
|
||||
token = models.CharField(max_length=64, unique=True)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
used = models.BooleanField(default=False)
|
||||
|
||||
def is_expired(self):
|
||||
return timezone.now() > self.created_at + timezone.timedelta(hours=1)
|
||||
|
||||
def __str__(self):
|
||||
return f"PasswordReset for {self.email}"
|
||||
|
||||
|
||||
class SecurityToken(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
|
||||
token = models.CharField(max_length=6)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
ip_address = models.GenericIPAddressField()
|
||||
|
||||
def __str__(self):
|
||||
return f"Token for {self.user.username if self.user else 'Desconocido'}"
|
||||
|
||||
class LoginAttempt(models.Model):
|
||||
username = models.CharField(max_length=100)
|
||||
status = models.CharField(max_length=50) # Ejemplo: 'Éxito' o 'Fracaso'
|
||||
ip_address = models.GenericIPAddressField() # IP del intento de inicio de sesión
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} - {self.status} - {self.timestamp}"
|
||||
@@ -1,13 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class UnstuckHistory(models.Model):
|
||||
character_name = models.CharField(max_length=50)
|
||||
used_at = models.DateTimeField()
|
||||
|
||||
class ReviveHistory(models.Model):
|
||||
character_name = models.CharField(max_length=50)
|
||||
used_at = models.DateTimeField()
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.character_name} - {self.used_at}"
|
||||
@@ -1,51 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ChangeFactionPrice(models.Model):
|
||||
price = models.DecimalField(max_digits=6, decimal_places=2, default=10.00)
|
||||
|
||||
def __str__(self):
|
||||
return f"Precio para cambiar facción: {self.price} EUR"
|
||||
|
||||
class LevelUpPrice(models.Model):
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2, default=10.00)
|
||||
|
||||
def __str__(self):
|
||||
return f"Subir a nivel 80: {self.price} EUR"
|
||||
|
||||
class GoldPrice(models.Model):
|
||||
gold_amount = models.IntegerField() # Cantidad de oro
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2) # Precio en EUR
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.gold_amount} oro - {self.price} EUR"
|
||||
|
||||
class TransferPrice(models.Model):
|
||||
price = models.DecimalField(max_digits=5, decimal_places=2)
|
||||
|
||||
def __str__(self):
|
||||
return f"Precio de transferencia: {self.price} EUR"
|
||||
|
||||
class RenamePrice(models.Model):
|
||||
price = models.DecimalField(max_digits=5, decimal_places=2, default=2.00)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.price} EUR"
|
||||
|
||||
class CustomizePrice(models.Model):
|
||||
price = models.DecimalField(max_digits=5, decimal_places=2, default=1.00)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.price} EUR"
|
||||
|
||||
class ChangeRacePrice(models.Model):
|
||||
price = models.DecimalField(max_digits=5, decimal_places=2)
|
||||
|
||||
def __str__(self):
|
||||
return f"Cambiar raza: {self.price} EUR"
|
||||
|
||||
class GuildRenameSettings(models.Model):
|
||||
cost = models.DecimalField(max_digits=10, decimal_places=2, default=10, help_text="Costo en euros para renombrar una hermandad.")
|
||||
|
||||
def __str__(self):
|
||||
return f"Configuración de renombrar hermandad - Costo: {self.cost} €"
|
||||
@@ -1,34 +0,0 @@
|
||||
from django.db import models
|
||||
from django_ckeditor_5.fields import CKEditor5Field
|
||||
|
||||
|
||||
class RecruitAFriend(models.Model):
|
||||
title = models.CharField(max_length=255, default="Recluta a un amigo")
|
||||
information = models.CharField(max_length=255, default="Información")
|
||||
content = CKEditor5Field('Contenido', config_name='default')
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class RecruitReward(models.Model):
|
||||
required_friends = models.IntegerField()
|
||||
reward_name = models.CharField(max_length=100)
|
||||
item_id = models.IntegerField()
|
||||
item_quantity = models.IntegerField(default=1)
|
||||
item_link = models.URLField()
|
||||
icon_class = models.CharField(max_length=50, help_text="Clase CSS para el icono (ej. 'icontinyl q3')")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.required_friends} amigos - {self.reward_name}"
|
||||
|
||||
class ClaimedReward(models.Model):
|
||||
recruit_reward = models.ForeignKey(RecruitReward, on_delete=models.CASCADE, related_name='claimed_rewards')
|
||||
account_id = models.IntegerField()
|
||||
character_name = models.CharField(max_length=100)
|
||||
username = models.CharField(max_length=100)
|
||||
claimed_at = models.DateTimeField(auto_now_add=True)
|
||||
ip_address = models.CharField(max_length=45, default='127.0.0.1') # Añadir un valor predeterminado
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} - {self.recruit_reward.reward_name} - {self.claimed_at}"
|
||||
@@ -1,95 +0,0 @@
|
||||
from django.db import models
|
||||
from django_ckeditor_5.fields import CKEditor5Field
|
||||
|
||||
|
||||
def expansion_from_gamebuild(gamebuild):
|
||||
"""Etiqueta de expansión a partir del build del cliente.
|
||||
|
||||
Distingue el retail antiguo del relanzamiento WoW Classic:
|
||||
- 12340 -> 'WotLK' (3.3.5a, retail/privado clásico)
|
||||
- 44832..54261 -> 'WotLK Classic' (3.4.x; 3.4.3 = 54261)
|
||||
- 15595 -> 'Cataclysm' (4.3.4 retail)
|
||||
- 54332..60000 -> 'Cataclysm Classic' (4.4.x)
|
||||
WotLK Classic llega a 3.4.3=54261 y Cataclysm Classic empieza en 4.4.0=54332,
|
||||
están muy pegados: de ahí el corte en 54261. Nova WoW corre 3.4.3 -> WotLK Classic.
|
||||
"""
|
||||
if gamebuild is None:
|
||||
return 'Unknown'
|
||||
if gamebuild == 12340:
|
||||
return 'WotLK'
|
||||
if 44000 <= gamebuild <= 54261:
|
||||
return 'WotLK Classic'
|
||||
if gamebuild == 15595:
|
||||
return 'Cataclysm'
|
||||
if 54262 <= gamebuild <= 60000:
|
||||
return 'Cataclysm Classic'
|
||||
return 'Unknown'
|
||||
|
||||
|
||||
class Noticia(models.Model):
|
||||
titulo = models.CharField(max_length=200)
|
||||
contenido = CKEditor5Field('Contenido', config_name='default')
|
||||
fecha_publicacion = models.DateTimeField(auto_now=True) # Cambiado a DateTimeField
|
||||
fecha_fin_evento = models.DateField(null=True, blank=True)
|
||||
enlace = models.URLField(max_length=200, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.titulo
|
||||
|
||||
class MaintenanceMode(models.Model):
|
||||
is_active = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return "Maintenance Mode"
|
||||
|
||||
class ClienteCategoria(models.Model):
|
||||
nombre = models.CharField(max_length=100)
|
||||
descripcion = models.CharField(max_length=200, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.nombre
|
||||
|
||||
class SistemaOperativo(models.Model):
|
||||
categoria = models.ForeignKey(ClienteCategoria, related_name='sistemas_operativos', on_delete=models.CASCADE)
|
||||
nombre = models.CharField(max_length=100)
|
||||
url_descarga = models.URLField()
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.nombre} - {self.categoria.nombre}"
|
||||
|
||||
class ServerSelection(models.Model):
|
||||
server_id = models.IntegerField()
|
||||
name = models.CharField(max_length=100)
|
||||
address = models.CharField(max_length=100)
|
||||
port = models.IntegerField()
|
||||
gamebuild = models.IntegerField()
|
||||
|
||||
@property
|
||||
def expansion(self):
|
||||
return expansion_from_gamebuild(self.gamebuild)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.expansion})"
|
||||
|
||||
class DownloadClientPage(models.Model):
|
||||
title = models.CharField(max_length=255, default="Descarga del cliente")
|
||||
content = CKEditor5Field('Contenido', config_name='default')
|
||||
mac_instructions = CKEditor5Field('Instrucciones para macOS', blank=True, null=True, config_name='default')
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class ContentCreator(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
description = CKEditor5Field('Descripción', config_name='default')
|
||||
content_type = models.CharField(max_length=60, default="Contenido general")
|
||||
subtitle = models.CharField(max_length=60, default="Nova WoW")
|
||||
youtube_video_url = models.URLField(max_length=300, blank=True, null=True)
|
||||
avatar_image_url = models.URLField(max_length=300, blank=True, null=True)
|
||||
youtube_channel_url = models.URLField(max_length=300, blank=True, null=True)
|
||||
facebook_page_url = models.URLField(max_length=300, blank=True, null=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@@ -1,44 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Item(models.Model):
|
||||
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="items")
|
||||
name = models.CharField(max_length=200)
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2) # Precio en euros
|
||||
item_id = models.IntegerField(unique=True) # ID del ítem en el juego
|
||||
image_url = models.URLField() # URL de la imagen del ítem
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class StripeLog(models.Model):
|
||||
account_id = models.IntegerField() # ID de la cuenta en acore_auth
|
||||
username = models.CharField(max_length=100) # Nombre de la cuenta
|
||||
email = models.EmailField() # Correo electrónico
|
||||
acore_ip = models.GenericIPAddressField() # IP de acore_auth
|
||||
stripe_ip = models.GenericIPAddressField(blank=True, null=True) # IP detectada por Stripe
|
||||
product_name = models.TextField() # Producto comprado
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2) # Precio total
|
||||
session_id = models.CharField(max_length=255) # ID de la sesión de Stripe
|
||||
mode = models.CharField(max_length=10, choices=[("test", "Test"), ("live", "Live")]) # Modo de Stripe
|
||||
character_name = models.CharField(max_length=100) # Nombre del personaje que compró
|
||||
timestamp = models.DateTimeField(auto_now_add=True) # Fecha y hora
|
||||
fulfilled = models.BooleanField(default=False) # Si la entrega ya se procesó (evita re-entregas)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} compró {self.product_name} por {self.amount}€ ({self.mode})"
|
||||
|
||||
class Pedido(models.Model):
|
||||
transaction_id = models.CharField(max_length=100, unique=True, null=True, blank=True)
|
||||
email_cliente = models.EmailField()
|
||||
estado = models.CharField(max_length=20, default="Pendiente")
|
||||
monto = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
|
||||
def __str__(self):
|
||||
return f"Pedido {self.transaction_id} - {self.estado}"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user