eb93758c43
- búsqueda paginada (count_search_topics + offset/limit) con controles en la plantilla - contador de visitas: columna opcional forum_topics.views con detección automática (has_views_column), increment_views al ver el tema, mostrado en lista y cabecera - sql/forum_views.sql (ALTER opcional) + docs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
414 lines
14 KiB
Python
414 lines
14 KiB
Python
"""
|
|
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):
|
|
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',
|
|
'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
|