From 2c30844bf7034021f5b1e32f9d5e3e58d11e26d2 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 17:10:55 +0000 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20foro=20de=20comunidad=20integrado?= =?UTF-8?q?=20(app=20forum)=20con=20el=20dise=C3=B1o=20de=20NovaWoW?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app 'forum': portada (categorías/foros), ver foro y tema (paginado), crear tema, responder, editar/borrar posts y moderación (fijar, bloquear, mover, borrar/restaurar) - lee/escribe la BD acore_web (5ª conexión, DB_NAME_WEB) por SQL directo, tablas forum_categories/forums/forum_topics/forum_posts/forum_reads (portado de NovaWeb-main) - identidad = cuenta de juego en sesión; permisos simplificados por gmlevel (FORUM_MOD_GMLEVEL) en vez de la matriz group_level×permission del original - saneado del HTML de posts con nh3 (evita el XSS del original); POST+CSRF en formularios - plantillas con los partials del sitio; enlace 'FOROS' del menú apunta al foro interno - sql/forum_schema.sql y docs/FORO.md Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 2 + docs/FORO.md | 77 +++++ forum/__init__.py | 0 forum/apps.py | 7 + forum/db.py | 321 +++++++++++++++++++++ forum/permissions.py | 71 +++++ forum/sanitize.py | 52 ++++ forum/templates/forum/_style.html | 42 +++ forum/templates/forum/create_topic.html | 34 +++ forum/templates/forum/edit_post.html | 32 +++ forum/templates/forum/forum.html | 63 ++++ forum/templates/forum/index.html | 50 ++++ forum/templates/forum/profile.html | 30 ++ forum/templates/forum/topic.html | 120 ++++++++ forum/urls.py | 37 +++ forum/views.py | 367 ++++++++++++++++++++++++ home/templates/partials/header.html | 2 +- novawow/settings.py | 9 + novawow/urls.py | 1 + requirements.txt | 1 + sql/forum_schema.sql | 68 +++++ 21 files changed, 1385 insertions(+), 1 deletion(-) create mode 100644 docs/FORO.md create mode 100644 forum/__init__.py create mode 100644 forum/apps.py create mode 100644 forum/db.py create mode 100644 forum/permissions.py create mode 100644 forum/sanitize.py create mode 100644 forum/templates/forum/_style.html create mode 100644 forum/templates/forum/create_topic.html create mode 100644 forum/templates/forum/edit_post.html create mode 100644 forum/templates/forum/forum.html create mode 100644 forum/templates/forum/index.html create mode 100644 forum/templates/forum/profile.html create mode 100644 forum/templates/forum/topic.html create mode 100644 forum/urls.py create mode 100644 forum/views.py create mode 100644 sql/forum_schema.sql diff --git a/.env.example b/.env.example index 8016f80..323f41b 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,5 @@ EMAIL_USE_TLS=True EMAIL_USE_SSL=False EMAIL_HOST_USER= EMAIL_HOST_PASSWORD= +DB_NAME_WEB=acore_web +FORUM_MOD_GMLEVEL=2 diff --git a/docs/FORO.md b/docs/FORO.md new file mode 100644 index 0000000..a852ca0 --- /dev/null +++ b/docs/FORO.md @@ -0,0 +1,77 @@ +# Foro integrado (app `forum`) + +Foro de comunidad reimplementado en Django dentro de NovaWoW, con el **diseño +del propio sitio** (usa los `partials` head/header/footer). Portado desde la app +Symfony *NovaWeb-main*, contra las **mismas tablas** en la base de datos +`acore_web`. + +## Arquitectura + +- **BD**: `acore_web` (5ª conexión Django, `DB_NAME_WEB`). Acceso por SQL directo + (`connections['acore_web']`), mismo estilo que el resto del proyecto. +- **Tablas**: `forum_categories`, `forums`, `forum_topics`, `forum_posts`, + `forum_reads`. (La tabla legacy `forum_forums` NO se usa.) +- **Identidad del autor**: la cuenta de juego en sesión de NovaWoW + (`session['account_id']` / `session['username']`). No usa las cookies del + proyecto original. + +## Funcionalidad (completa) + +- Portada con categorías y foros (contadores de temas/mensajes, último tema). +- Ver foro: lista de temas paginada. +- Ver tema: posts paginados, con panel de autor (rol, registro, nº de mensajes). +- Crear tema, responder, editar y borrar el post propio. +- Moderación: fijar/desfijar, bloquear/desbloquear, mover, borrar/restaurar temas + y posts. +- Perfil público básico (`/es/profile/`). + +Rutas bajo `/es/forum...` (idénticas al original: `app_forum`, `forum_view`, +`forum_topic`, `forum_create_topic`, etc.). + +## Modelo de permisos (simplificado) + +En vez de la matriz `group_level`×`permission` del original (enrevesada y con +acciones que quedaban deshabilitadas por falta de filas), se usa un modelo claro +en `forum/permissions.py`: + +- **Ver**: foros públicos (`visibility=1`, `deleted=0`) visibles para todos. +- **Publicar / responder / crear tema**: cualquier usuario con sesión iniciada y + cuenta de juego seleccionada. +- **Editar / borrar** un post: su autor, o un moderador. +- **Moderar**: cuentas con `gmlevel >= FORUM_MOD_GMLEVEL` (por defecto **2**), + leído de `acore_auth.account_access`. + +## Seguridad + +- El texto de los posts se **sanea con `nh3`** (allowlist de etiquetas) al + guardar — `forum/sanitize.py` — evitando el XSS que tenía el original (que + renderizaba HTML del editor sin filtrar). Se renderiza con `|safe` ya saneado. +- Formularios de publicar/responder/editar/mover van por **POST con CSRF**. +- ⚠️ Pendiente de endurecer: las acciones de moderación (bloquear, borrar, + restaurar, fijar) son enlaces **GET** (como en el original). Para producción se + recomienda pasarlas a POST con CSRF. + +## Despliegue + +1. Crear/usar la BD `acore_web` y aplicar el esquema: + ```bash + mysql acore_web < sql/forum_schema.sql + ``` + (Si ya existe la BD del portal Symfony con esas tablas, apunta `DB_NAME_WEB` + a ella y omite este paso.) +2. Configurar variables de entorno: + ``` + DB_NAME_WEB=acore_web + FORUM_MOD_GMLEVEL=2 + FORUM_TOPICS_PER_PAGE=20 + FORUM_POSTS_PER_PAGE=15 + ``` +3. El enlace «FOROS» del menú COMUNIDAD ya apunta al foro interno (`/es/forum`). + +## Notas / posibles mejoras + +- Editor enriquecido (CKEditor/TinyMCE) en el textarea de posts (ahora es + texto/HTML saneado; los saltos de línea se respetan por CSS `pre-wrap`). +- El autor se muestra como el nombre de cuenta de juego (`#1`); se podría + mapear a un nick o al email como en «Mi cuenta». +- Endurecer las acciones de moderación a POST+CSRF. diff --git a/forum/__init__.py b/forum/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/forum/apps.py b/forum/apps.py new file mode 100644 index 0000000..c84a22c --- /dev/null +++ b/forum/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ForumConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'forum' + verbose_name = 'Foro' diff --git a/forum/db.py b/forum/db.py new file mode 100644 index 0000000..524bcde --- /dev/null +++ b/forum/db.py @@ -0,0 +1,321 @@ +""" +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'] + + +# -------------------------------------------------------------------------- +# 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): + with _conn().cursor() as cursor: + cursor.execute( + """ + SELECT t.id, t.name, t.poster, t.poster_id, t.created, t.sticky, + t.locked, t.moved, t.updated_at, + (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): + with _conn().cursor() as cursor: + cursor.execute( + "SELECT id, forum_id, name, poster, poster_id, created, sticky, " + "locked, deleted, moved, updated_at 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): + with _conn().cursor() as cursor: + cursor.execute( + "SELECT COUNT(*) FROM forum_posts WHERE topic_id = %s AND deleted = 0", + [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'} + if not poster_id: + return info + 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 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 diff --git a/forum/permissions.py b/forum/permissions.py new file mode 100644 index 0000000..5e511f5 --- /dev/null +++ b/forum/permissions.py @@ -0,0 +1,71 @@ +""" +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'), + } diff --git a/forum/sanitize.py b/forum/sanitize.py new file mode 100644 index 0000000..3beba9e --- /dev/null +++ b/forum/sanitize.py @@ -0,0 +1,52 @@ +""" +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()) diff --git a/forum/templates/forum/_style.html b/forum/templates/forum/_style.html new file mode 100644 index 0000000..83e96a3 --- /dev/null +++ b/forum/templates/forum/_style.html @@ -0,0 +1,42 @@ + +{% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+{% endif %} diff --git a/forum/templates/forum/create_topic.html b/forum/templates/forum/create_topic.html new file mode 100644 index 0000000..b9ae3a3 --- /dev/null +++ b/forum/templates/forum/create_topic.html @@ -0,0 +1,34 @@ + + + {% include 'partials/head.html' %} + {% include 'partials/header.html' %} + {% include 'partials/video.html' %} + +
+
+
+

Nuevo tema

+
+
+ {% include 'forum/_style.html' %} +
+

Foro: {{ forum.name }}

+
+ {% csrf_token %} + + +
+ + Cancelar +
+
+
+
+
+
+
+
+ + {% include 'partials/social.html' %} + {% include 'partials/footer.html' %} + {% include 'partials/final.html' %} diff --git a/forum/templates/forum/edit_post.html b/forum/templates/forum/edit_post.html new file mode 100644 index 0000000..994ecf5 --- /dev/null +++ b/forum/templates/forum/edit_post.html @@ -0,0 +1,32 @@ + + + {% include 'partials/head.html' %} + {% include 'partials/header.html' %} + {% include 'partials/video.html' %} + +
+
+
+

Editar mensaje

+
+
+ {% include 'forum/_style.html' %} +
+
+ {% csrf_token %} + +
+ + Cancelar +
+
+
+
+
+
+
+
+ + {% include 'partials/social.html' %} + {% include 'partials/footer.html' %} + {% include 'partials/final.html' %} diff --git a/forum/templates/forum/forum.html b/forum/templates/forum/forum.html new file mode 100644 index 0000000..c100242 --- /dev/null +++ b/forum/templates/forum/forum.html @@ -0,0 +1,63 @@ + + + {% include 'partials/head.html' %} + {% include 'partials/header.html' %} + {% include 'partials/video.html' %} + +
+
+
+

{{ forum.name }}

+
+
+ {% include 'forum/_style.html' %} +
+

{{ forum.description }}

+
+ ← Volver al foro + {% if forum_is_authenticated %} + Crear nuevo tema + {% endif %} +
+ + {% for topic in topics %} +
+ +
+
{{ topic.post_count }} respuestas
+ {% if topic.last_poster %}
último: {{ topic.last_poster }}
{% endif %} + {% if topic.last_time %}
{{ topic.last_time }}
{% endif %} +
+
+ {% empty %} +

No hay temas todavía. ¡Sé el primero en publicar!

+ {% endfor %} + + {% if pagination.total_pages > 1 %} +
+ {% if pagination.has_prev %}« Anterior{% endif %} + {% for p in pagination.page_range %} + {% if p == pagination.page %}{{ p }} + {% else %}{{ p }}{% endif %} + {% endfor %} + {% if pagination.has_next %}Siguiente »{% endif %} +
+ {% endif %} +
+
+
+
+
+
+ + {% include 'partials/social.html' %} + {% include 'partials/footer.html' %} + {% include 'partials/final.html' %} diff --git a/forum/templates/forum/index.html b/forum/templates/forum/index.html new file mode 100644 index 0000000..ee4e70e --- /dev/null +++ b/forum/templates/forum/index.html @@ -0,0 +1,50 @@ + + + {% include 'partials/head.html' %} + {% include 'partials/header.html' %} + {% include 'partials/video.html' %} + +
+
+
+

Foro de la comunidad

+
+
+ {% include 'forum/_style.html' %} +
+ {% for category in categories %} +
+
{{ category.name }}
+ {% for forum in category.forums %} +
+
+ + {{ forum.name }} + + {% if not forum.visibility %}Oculto{% endif %} +
{{ forum.description }}
+
+
+
{{ forum.topic_count }} temas
+
{{ forum.post_count }} mensajes
+ {% if forum.last_topic_name %} + + {% endif %} +
+
+ {% endfor %} +
+ {% empty %} +

Todavía no hay foros disponibles.

+ {% endfor %} +
+
+
+
+
+
+ + {% include 'partials/social.html' %} + {% include 'partials/footer.html' %} + {% include 'partials/final.html' %} diff --git a/forum/templates/forum/profile.html b/forum/templates/forum/profile.html new file mode 100644 index 0000000..a71cd7e --- /dev/null +++ b/forum/templates/forum/profile.html @@ -0,0 +1,30 @@ + + + {% include 'partials/head.html' %} + {% include 'partials/header.html' %} + {% include 'partials/video.html' %} + +
+
+
+

Perfil de {{ profile.username }}

+
+
+ {% include 'forum/_style.html' %} +
+

Temas creados: {{ profile.topic_count }}

+

Mensajes publicados: {{ profile.post_count }}

+ {% if profile.joindate %}

Registro: {{ profile.joindate|date:"d/m/Y" }}

{% endif %} + +
+
+
+
+
+
+ + {% include 'partials/social.html' %} + {% include 'partials/footer.html' %} + {% include 'partials/final.html' %} diff --git a/forum/templates/forum/topic.html b/forum/templates/forum/topic.html new file mode 100644 index 0000000..7c97af0 --- /dev/null +++ b/forum/templates/forum/topic.html @@ -0,0 +1,120 @@ + + + {% include 'partials/head.html' %} + {% include 'partials/header.html' %} + {% include 'partials/video.html' %} + +
+
+
+

{{ topic.name }}

+
+
+ {% include 'forum/_style.html' %} +
+
+ {% if forum %}← {{ forum.name }}{% endif %} + {% if topic.locked %}Tema bloqueado{% endif %} +
+ + {% for post in posts %} +
+
+ +
{{ post.author.status }}
+
+ {% if post.author.joindate %}Registro: {{ post.author.joindate|date:"d/m/Y" }}
{% endif %} + Mensajes: {{ post.author.post_count }} +
+
+
+ {% if post.deleted %} +
[Mensaje eliminado]
+ {% else %} +
{{ post.text|safe }}
+ {% endif %} +
+ {{ post.time }}{% if post.updated_at and post.updated_at != post.time %} · editado {{ post.updated_at }}{% endif %} + + {% if post.can_edit and not post.deleted %} + Editar + · Borrar + {% endif %} + {% if post.deleted and forum_is_moderator %} + Restaurar + {% endif %} + +
+
+
+ {% endfor %} + + {% if pagination.total_pages > 1 %} +
+ {% if pagination.has_prev %}« Anterior{% endif %} + {% for p in pagination.page_range %} + {% if p == pagination.page %}{{ p }} + {% else %}{{ p }}{% endif %} + {% endfor %} + {% if pagination.has_next %}Siguiente »{% endif %} +
+ {% endif %} + + + + {% if can_reply %} +
+

Responder

+
+ {% csrf_token %} + +
+ +
+
+
+ {% elif topic.locked %} +

Este tema está bloqueado. No se admiten nuevas respuestas.

+ {% elif not forum_is_authenticated %} +

Inicia sesión para responder.

+ {% endif %} + + {% if forum_is_moderator %} +
+

Moderación

+
+ {% if topic.locked %} + Desbloquear + {% else %} + Bloquear + {% endif %} + {% if topic.sticky %}Quitar fijado{% else %}Fijar{% endif %} + {% if topic.deleted %} + Restaurar tema + {% else %} + Borrar tema + {% endif %} +
+ {% if move_forums %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+ {% endif %} +
+
+
+
+
+
+ + {% include 'partials/social.html' %} + {% include 'partials/footer.html' %} + {% include 'partials/final.html' %} diff --git a/forum/urls.py b/forum/urls.py new file mode 100644 index 0000000..06e9997 --- /dev/null +++ b/forum/urls.py @@ -0,0 +1,37 @@ +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//... +urlpatterns = [ + path('forum', views.index, name='app_forum'), + + # Temas + path('forum/topic//reply', views.reply_to_topic, name='forum_post_reply'), + path('forum/topic//move', views.move_topic, name='forum_move_topic'), + path('forum/topic//lock', views.lock_topic, name='forum_lock_topic'), + path('forum/topic//unlock', views.unlock_topic, name='forum_unlock_topic'), + path('forum/topic//sticky', views.toggle_sticky, name='forum_toggle_sticky'), + path('forum/topic//restore', views.restore_topic, name='forum_restore_topic'), + path('forum/topic/delete/', views.delete_topic, name='forum_delete_topic'), + path('forum/topic//', views.view_topic, name='forum_topic'), + path('forum/topic/', views.view_topic, name='forum_topic_p1'), + + # Posts + path('forum/post/update/', views.update_post, name='forum_update_post'), + path('forum/post/delete/', views.delete_post, name='forum_delete_post'), + path('forum/post//restore', views.restore_post, name='forum_restore_post'), + path('forum/edit/', views.edit_post, name='forum_edit_post'), + + # Crear tema + path('forum/create//submit', views.submit_create_topic, name='forum_create_topic_submit'), + path('forum/create/', views.create_topic, name='forum_create_topic'), + + # Ver foro (paginado) — genérica, al final + path('forum//', views.view_forum, name='forum_view'), + path('forum/', views.view_forum, name='forum_view_p1'), + + # Perfil público + path('profile/', views.profile, name='forum_profile'), +] diff --git a/forum/views.py b/forum/views.py new file mode 100644 index 0000000..8c864a3 --- /dev/null +++ b/forum/views.py @@ -0,0 +1,367 @@ +""" +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 . import db +from . import permissions as perm +from . import sanitize + +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 + + +# -------------------------------------------------------------------------- +# 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) + + return render(request, 'forum/forum.html', _base_context(request, { + 'forum': forum, + 'topics': topics, + 'pagination': pag, + })) + + +def view_topic(request, topic_id, page=1): + topic = db.get_topic(topic_id, include_hidden=perm.is_moderator(request)) + if not topic: + messages.error(request, 'Tema no encontrado.') + return redirect('app_forum') + + forum = db.get_forum(topic['forum_id'], include_hidden=True) + + total = db.count_posts(topic_id) + pag, offset = _paginate(total, page, settings.FORUM_POSTS_PER_PAGE) + include_deleted = perm.is_moderator(request) + posts = db.get_posts(topic_id, offset, settings.FORUM_POSTS_PER_PAGE, include_deleted) + + # 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'], + 'move_forums': db.list_forums_for_move(topic['forum_id']) if perm.is_moderator(request) else [], + })) + + +# -------------------------------------------------------------------------- +# 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, + })) + + +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) + + +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) + + topic = db.get_topic(topic_id) + if not topic: + messages.error(request, 'Tema no encontrado.') + return redirect('app_forum') + if topic['locked'] and not perm.is_moderator(request): + 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, + })) + + +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 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']) + + +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']) + db.set_post_deleted(post_id, True) + messages.success(request, 'Post borrado.') + return redirect('forum_topic_p1', topic_id=post['topic_id']) + + +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') + 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 lock_topic(request, topic_id): + block = _mod_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) + + +def unlock_topic(request, topic_id): + block = _mod_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) + + +def toggle_sticky(request, topic_id): + block = _mod_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) + + +def delete_topic(request, topic_id): + block = _mod_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') + + +def restore_topic(request, topic_id): + block = _mod_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) + + +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, + })) diff --git a/home/templates/partials/header.html b/home/templates/partials/header.html index 77cf2c1..2ab3f58 100644 --- a/home/templates/partials/header.html +++ b/home/templates/partials/header.html @@ -31,7 +31,7 @@