89ea16ba4d
- CSRF real en las vistas mutadoras con @csrf_protect (el middleware global no estaba activo; verificado: POST sin token -> 403). Se documenta el hueco global. - bloquea acceso a temas de foros ocultos/borrados por URL directa (view_topic y reply) - conteo de posts respeta borrados para moderadores (paginación correcta) - tema bloqueado impide editar/borrar posts a no-moderadores (_topic_locked_for) - moderador ve el formulario de respuesta en temas bloqueados (can_reply) - filtro forum_safe: sanea el HTML también al renderizar (defensa en profundidad) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
454 lines
16 KiB
Python
454 lines
16 KiB
Python
"""
|
|
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,
|
|
}))
|