Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Rozszerzenie powiadomień o kolejne typy zdarzeń, z symetrycznymi togglami e-mail i push w /konto/prywatnosc. Migracje 103 + 104 — 6 nowych kolumn preferencji e-mail + NordaEvent.reminder_24h_sent_at. Triggery: - Forum odpowiedź → push do autora wątku (notify_push_forum_reply) - Forum cytat (> **Imię** napisał(a):) → push + email do cytowanego (notify_push/email_forum_quote) - Admin publikuje aktualność → broadcast push (ON) + email (OFF) do aktywnych członków (notify_push/email_announcements) - Board: utworzenie / publikacja programu / publikacja protokołu → broadcast push + opt-in email (notify_push/email_board_meetings) - Nowe wydarzenie w kalendarzu → broadcast push + email (oba ON) (notify_push/email_event_invites) - Cron scripts/event_reminders_cron.py co godzinę — wydarzenia za 23-25h, dla zapisanych (EventAttendee.status != 'declined') push + email, znacznik NordaEvent.reminder_24h_sent_at żeby nie dublować. Email defaults dobrane, by nie zalać inbox: broadcast OFF (announcements, board, forum_reply), personalne/actionable ON (forum_quote, event_invites, event_reminders). Wszystkie nowe e-maile mają jednym-kliknięciem unsubscribe (RFC 8058 + link w stopce) — unsubscribe_tokens.py rozszerzony o nowe typy. Cron entry do dodania na prod (osobny krok, bo to edycja crontaba): 0 * * * * cd /var/www/nordabiznes && venv/bin/python3 scripts/event_reminders_cron.py Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2400 lines
88 KiB
Python
2400 lines
88 KiB
Python
"""
|
|
Forum Routes
|
|
============
|
|
|
|
Forum topics, replies, and admin moderation.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
|
|
from flask import render_template, request, redirect, url_for, flash, jsonify
|
|
from flask_login import login_required, current_user
|
|
|
|
from . import bp
|
|
from database import (
|
|
SessionLocal, ForumTopic, ForumReply, ForumAttachment,
|
|
ForumTopicSubscription, ForumReport, ForumEditHistory, User,
|
|
ForumTopicRead, ForumReplyRead
|
|
)
|
|
from utils.helpers import sanitize_input
|
|
from utils.decorators import forum_access_required
|
|
from utils.notifications import (
|
|
create_forum_reply_notification,
|
|
create_forum_reaction_notification,
|
|
create_forum_solution_notification,
|
|
create_forum_report_notification,
|
|
parse_mentions_and_notify,
|
|
send_forum_reply_email
|
|
)
|
|
|
|
# Constants
|
|
EDIT_TIME_LIMIT_HOURS = 24
|
|
AVAILABLE_REACTIONS = ['👍', '❤️']
|
|
|
|
# Logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Import FileUploadService (may not be available in all environments)
|
|
try:
|
|
from file_upload_service import FileUploadService
|
|
FILE_UPLOAD_AVAILABLE = True
|
|
except ImportError:
|
|
FILE_UPLOAD_AVAILABLE = False
|
|
logger.warning("FileUploadService not available for forum")
|
|
|
|
|
|
# ============================================================
|
|
# PUBLIC FORUM ROUTES
|
|
# ============================================================
|
|
|
|
@bp.route('/forum')
|
|
@login_required
|
|
@forum_access_required
|
|
def forum_index():
|
|
"""Forum - list of topics with category/status/solution filters and search"""
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 20
|
|
category_filter = request.args.get('category', '')
|
|
status_filter = request.args.get('status', '')
|
|
has_solution = request.args.get('has_solution', '')
|
|
search_query = request.args.get('q', '').strip()
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Build query with optional filters (exclude soft-deleted)
|
|
query = db.query(ForumTopic).filter(
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
|
|
)
|
|
|
|
if category_filter and category_filter in ForumTopic.CATEGORIES:
|
|
query = query.filter(ForumTopic.category == category_filter)
|
|
|
|
if status_filter and status_filter in ForumTopic.STATUSES:
|
|
query = query.filter(ForumTopic.status == status_filter)
|
|
|
|
# Filter by has solution
|
|
if has_solution == '1':
|
|
# Topics that have at least one reply marked as solution
|
|
from sqlalchemy import exists
|
|
query = query.filter(
|
|
exists().where(
|
|
(ForumReply.topic_id == ForumTopic.id) &
|
|
(ForumReply.is_solution == True)
|
|
)
|
|
)
|
|
|
|
# Search in title and content
|
|
if search_query:
|
|
search_term = f'%{search_query}%'
|
|
query = query.filter(
|
|
(ForumTopic.title.ilike(search_term)) |
|
|
(ForumTopic.content.ilike(search_term))
|
|
)
|
|
|
|
# Order by pinned first, then by last activity
|
|
query = query.order_by(
|
|
ForumTopic.is_pinned.desc(),
|
|
ForumTopic.updated_at.desc()
|
|
)
|
|
|
|
total_topics = query.count()
|
|
topics = query.limit(per_page).offset((page - 1) * per_page).all()
|
|
|
|
return render_template(
|
|
'forum/index.html',
|
|
topics=topics,
|
|
page=page,
|
|
per_page=per_page,
|
|
total_topics=total_topics,
|
|
total_pages=(total_topics + per_page - 1) // per_page,
|
|
category_filter=category_filter,
|
|
status_filter=status_filter,
|
|
has_solution=has_solution,
|
|
search_query=search_query,
|
|
categories=ForumTopic.CATEGORIES,
|
|
statuses=ForumTopic.STATUSES,
|
|
category_labels=ForumTopic.CATEGORY_LABELS,
|
|
status_labels=ForumTopic.STATUS_LABELS
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/forum/nowy', methods=['GET', 'POST'])
|
|
@login_required
|
|
@forum_access_required
|
|
def forum_new_topic():
|
|
"""Create new forum topic with category and attachments"""
|
|
if request.method == 'POST':
|
|
title = sanitize_input(request.form.get('title', ''), 255)
|
|
content = request.form.get('content', '').strip()
|
|
category = request.form.get('category', 'question')
|
|
|
|
# Validate category
|
|
if category not in ForumTopic.CATEGORIES:
|
|
category = 'question'
|
|
|
|
if not title or len(title) < 5:
|
|
flash('Tytuł musi mieć co najmniej 5 znaków.', 'error')
|
|
return render_template('forum/new_topic.html',
|
|
categories=ForumTopic.CATEGORIES,
|
|
category_labels=ForumTopic.CATEGORY_LABELS)
|
|
|
|
if not content or len(content) < 10:
|
|
flash('Treść musi mieć co najmniej 10 znaków.', 'error')
|
|
return render_template('forum/new_topic.html',
|
|
categories=ForumTopic.CATEGORIES,
|
|
category_labels=ForumTopic.CATEGORY_LABELS)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Duplicate submission protection: same author, same title+content, within 60 seconds
|
|
recent_duplicate = db.query(ForumTopic).filter(
|
|
ForumTopic.author_id == current_user.id,
|
|
ForumTopic.title == title,
|
|
ForumTopic.content == content,
|
|
ForumTopic.created_at >= datetime.now() - timedelta(seconds=60)
|
|
).first()
|
|
if recent_duplicate:
|
|
return redirect(url_for('.forum_topic', topic_id=recent_duplicate.id))
|
|
|
|
topic = ForumTopic(
|
|
title=title,
|
|
content=content,
|
|
author_id=current_user.id,
|
|
category=category
|
|
)
|
|
db.add(topic)
|
|
db.commit()
|
|
db.refresh(topic)
|
|
|
|
# Auto-subscribe author to their own topic
|
|
subscription = ForumTopicSubscription(
|
|
user_id=current_user.id,
|
|
topic_id=topic.id
|
|
)
|
|
db.add(subscription)
|
|
db.commit()
|
|
|
|
# Handle file upload
|
|
if FILE_UPLOAD_AVAILABLE and 'attachment' in request.files:
|
|
file = request.files['attachment']
|
|
if file and file.filename:
|
|
is_valid, error_msg = FileUploadService.validate_file(file)
|
|
if is_valid:
|
|
stored_filename, rel_path, file_size, mime_type = FileUploadService.save_file(file, 'topic')
|
|
attachment = ForumAttachment(
|
|
attachment_type='topic',
|
|
topic_id=topic.id,
|
|
original_filename=file.filename,
|
|
stored_filename=stored_filename,
|
|
file_extension=stored_filename.rsplit('.', 1)[-1],
|
|
file_size=file_size,
|
|
mime_type=mime_type,
|
|
uploaded_by=current_user.id
|
|
)
|
|
db.add(attachment)
|
|
db.commit()
|
|
else:
|
|
flash(f'Załącznik: {error_msg}', 'warning')
|
|
|
|
# Parse @mentions and send notifications
|
|
try:
|
|
author_name = current_user.name or current_user.email.split('@')[0]
|
|
parse_mentions_and_notify(
|
|
content=content,
|
|
author_id=current_user.id,
|
|
author_name=author_name,
|
|
topic_id=topic.id,
|
|
content_type='topic',
|
|
content_id=topic.id
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse mentions in new topic: {e}")
|
|
|
|
flash('Temat został utworzony.', 'success')
|
|
return redirect(url_for('.forum_topic', topic_id=topic.id))
|
|
finally:
|
|
db.close()
|
|
|
|
return render_template('forum/new_topic.html',
|
|
categories=ForumTopic.CATEGORIES,
|
|
category_labels=ForumTopic.CATEGORY_LABELS)
|
|
|
|
|
|
@bp.route('/forum/<int:topic_id>')
|
|
@login_required
|
|
@forum_access_required
|
|
def forum_topic(topic_id):
|
|
"""View forum topic with replies"""
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
|
|
if not topic:
|
|
flash('Temat nie istnieje.', 'error')
|
|
return redirect(url_for('.forum_index'))
|
|
|
|
# Check if topic is soft-deleted (only moderators can view)
|
|
if topic.is_deleted and not current_user.can_moderate_forum():
|
|
flash('Temat nie istnieje.', 'error')
|
|
return redirect(url_for('.forum_index'))
|
|
|
|
# Increment view count (handle NULL)
|
|
topic.views_count = (topic.views_count or 0) + 1
|
|
|
|
# Record topic read by current user
|
|
existing_topic_read = db.query(ForumTopicRead).filter(
|
|
ForumTopicRead.topic_id == topic.id,
|
|
ForumTopicRead.user_id == current_user.id
|
|
).first()
|
|
if not existing_topic_read:
|
|
db.add(ForumTopicRead(topic_id=topic.id, user_id=current_user.id))
|
|
|
|
# Filter soft-deleted replies for non-moderators
|
|
visible_replies = [r for r in topic.replies
|
|
if not r.is_deleted or current_user.can_moderate_forum()]
|
|
|
|
# Record read for all visible replies
|
|
for reply in visible_replies:
|
|
existing_reply_read = db.query(ForumReplyRead).filter(
|
|
ForumReplyRead.reply_id == reply.id,
|
|
ForumReplyRead.user_id == current_user.id
|
|
).first()
|
|
if not existing_reply_read:
|
|
db.add(ForumReplyRead(reply_id=reply.id, user_id=current_user.id))
|
|
|
|
db.commit()
|
|
|
|
# Get topic readers
|
|
from sqlalchemy import desc
|
|
topic_readers = db.query(ForumTopicRead).filter(
|
|
ForumTopicRead.topic_id == topic.id
|
|
).order_by(desc(ForumTopicRead.read_at)).all()
|
|
|
|
# Check subscription status
|
|
is_subscribed = db.query(ForumTopicSubscription).filter(
|
|
ForumTopicSubscription.topic_id == topic_id,
|
|
ForumTopicSubscription.user_id == current_user.id
|
|
).first() is not None
|
|
|
|
# Build reaction user names map (user_id -> name) for tooltips
|
|
reaction_user_ids = set()
|
|
if topic.reactions:
|
|
for uid_list in topic.reactions.values():
|
|
if isinstance(uid_list, list):
|
|
reaction_user_ids.update(uid_list)
|
|
for reply in visible_replies:
|
|
if reply.reactions:
|
|
for uid_list in reply.reactions.values():
|
|
if isinstance(uid_list, list):
|
|
reaction_user_ids.update(uid_list)
|
|
reaction_user_names = {}
|
|
if reaction_user_ids:
|
|
users = db.query(User.id, User.name, User.email).filter(User.id.in_(reaction_user_ids)).all()
|
|
reaction_user_names = {u.id: (u.name or u.email.split('@')[0]) for u in users}
|
|
|
|
return render_template('forum/topic.html',
|
|
topic=topic,
|
|
visible_replies=visible_replies,
|
|
topic_readers=topic_readers,
|
|
is_subscribed=is_subscribed,
|
|
category_labels=ForumTopic.CATEGORY_LABELS,
|
|
status_labels=ForumTopic.STATUS_LABELS,
|
|
available_reactions=AVAILABLE_REACTIONS,
|
|
reaction_user_names=reaction_user_names)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/forum/<int:topic_id>/odpowiedz', methods=['POST'])
|
|
@login_required
|
|
@forum_access_required
|
|
def forum_reply(topic_id):
|
|
"""Add reply to forum topic with optional attachment"""
|
|
content = request.form.get('content', '').strip()
|
|
|
|
if not content or len(content) < 3:
|
|
flash('Odpowiedź musi mieć co najmniej 3 znaki.', 'error')
|
|
return redirect(url_for('.forum_topic', topic_id=topic_id))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
|
|
if not topic:
|
|
flash('Temat nie istnieje.', 'error')
|
|
return redirect(url_for('.forum_index'))
|
|
|
|
if topic.is_locked:
|
|
flash('Ten temat jest zamknięty.', 'error')
|
|
return redirect(url_for('.forum_topic', topic_id=topic_id))
|
|
|
|
# Duplicate submission protection: same user, same content, within 30 seconds
|
|
recent_duplicate = db.query(ForumReply).filter(
|
|
ForumReply.topic_id == topic_id,
|
|
ForumReply.author_id == current_user.id,
|
|
ForumReply.content == content,
|
|
ForumReply.created_at >= datetime.now() - timedelta(seconds=30)
|
|
).first()
|
|
if recent_duplicate:
|
|
return redirect(url_for('.forum_topic', topic_id=topic_id))
|
|
|
|
reply = ForumReply(
|
|
topic_id=topic_id,
|
|
author_id=current_user.id,
|
|
content=content
|
|
)
|
|
db.add(reply)
|
|
db.commit()
|
|
db.refresh(reply)
|
|
|
|
# Handle multiple file uploads (max 10)
|
|
if FILE_UPLOAD_AVAILABLE:
|
|
MAX_ATTACHMENTS = 10
|
|
files = request.files.getlist('attachments[]')
|
|
if not files:
|
|
# Fallback for single file upload (backward compatibility)
|
|
files = request.files.getlist('attachment')
|
|
|
|
uploaded_count = 0
|
|
errors = []
|
|
|
|
for file in files[:MAX_ATTACHMENTS]:
|
|
if file and file.filename:
|
|
is_valid, error_msg = FileUploadService.validate_file(file)
|
|
if is_valid:
|
|
stored_filename, rel_path, file_size, mime_type = FileUploadService.save_file(file, 'reply')
|
|
attachment = ForumAttachment(
|
|
attachment_type='reply',
|
|
reply_id=reply.id,
|
|
original_filename=file.filename,
|
|
stored_filename=stored_filename,
|
|
file_extension=stored_filename.rsplit('.', 1)[-1],
|
|
file_size=file_size,
|
|
mime_type=mime_type,
|
|
uploaded_by=current_user.id
|
|
)
|
|
db.add(attachment)
|
|
uploaded_count += 1
|
|
else:
|
|
errors.append(f'{file.filename}: {error_msg}')
|
|
|
|
if uploaded_count > 0:
|
|
db.commit()
|
|
|
|
if errors:
|
|
flash(f'Niektóre załączniki nie zostały dodane: {"; ".join(errors)}', 'warning')
|
|
|
|
# Update topic updated_at
|
|
topic.updated_at = datetime.now()
|
|
db.commit()
|
|
|
|
# Auto-subscribe replier to topic (if not already subscribed)
|
|
try:
|
|
existing_sub = db.query(ForumTopicSubscription).filter(
|
|
ForumTopicSubscription.topic_id == topic_id,
|
|
ForumTopicSubscription.user_id == current_user.id
|
|
).first()
|
|
if not existing_sub:
|
|
db.add(ForumTopicSubscription(
|
|
topic_id=topic_id,
|
|
user_id=current_user.id,
|
|
notify_email=True,
|
|
notify_app=True
|
|
))
|
|
db.commit()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to auto-subscribe replier: {e}")
|
|
|
|
# Send in-app notifications to subscribers (except the replier)
|
|
replier_name = current_user.name or current_user.email.split('@')[0]
|
|
try:
|
|
app_subs = db.query(ForumTopicSubscription).filter(
|
|
ForumTopicSubscription.topic_id == topic_id,
|
|
ForumTopicSubscription.user_id != current_user.id,
|
|
ForumTopicSubscription.notify_app == True
|
|
).all()
|
|
|
|
subscriber_ids = [s.user_id for s in app_subs]
|
|
if subscriber_ids:
|
|
create_forum_reply_notification(
|
|
topic_id=topic_id,
|
|
topic_title=topic.title,
|
|
replier_name=replier_name,
|
|
reply_id=reply.id,
|
|
subscriber_ids=subscriber_ids
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send reply notifications: {e}")
|
|
|
|
# Send email notifications to subscribers with notify_email=True
|
|
try:
|
|
email_subs = db.query(ForumTopicSubscription).filter(
|
|
ForumTopicSubscription.topic_id == topic_id,
|
|
ForumTopicSubscription.user_id != current_user.id,
|
|
ForumTopicSubscription.notify_email == True
|
|
).all()
|
|
|
|
if email_subs:
|
|
subscriber_emails = []
|
|
for sub in email_subs:
|
|
user = db.query(User).filter(User.id == sub.user_id).first()
|
|
if user and user.email:
|
|
subscriber_emails.append({
|
|
'email': user.email,
|
|
'name': user.name or user.email.split('@')[0]
|
|
})
|
|
|
|
if subscriber_emails:
|
|
send_forum_reply_email(
|
|
topic_id=topic_id,
|
|
topic_title=topic.title,
|
|
replier_name=replier_name,
|
|
reply_content=content,
|
|
subscriber_emails=subscriber_emails,
|
|
reply_id=reply.id
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send email notifications: {e}")
|
|
|
|
# Parse @mentions and send notifications
|
|
try:
|
|
author_name = current_user.name or current_user.email.split('@')[0]
|
|
parse_mentions_and_notify(
|
|
content=content,
|
|
author_id=current_user.id,
|
|
author_name=author_name,
|
|
topic_id=topic_id,
|
|
content_type='reply',
|
|
content_id=reply.id
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse mentions: {e}")
|
|
|
|
# Web Push: autor wątku (forum_reply) + cytowani (forum_quote)
|
|
try:
|
|
from blueprints.push.push_service import send_push
|
|
import re as _re
|
|
|
|
topic_url = f'/forum/{topic_id}#reply-{reply.id}'
|
|
plain = _re.sub(r'<[^>]+>', '', content or '').strip()
|
|
# Usuń cytat bloki z podglądu (linie zaczynające się od '>')
|
|
plain_lines = [ln for ln in plain.splitlines() if not ln.lstrip().startswith('>')]
|
|
clean = ' '.join(plain_lines).strip()
|
|
preview = (clean[:80] + '…') if len(clean) > 80 else clean
|
|
|
|
notified_user_ids = set()
|
|
|
|
# Autor wątku (o ile nie jest sam replierem)
|
|
if topic.author_id and topic.author_id != current_user.id:
|
|
t_author = db.query(User).filter(User.id == topic.author_id).first()
|
|
if t_author and t_author.notify_push_forum_reply is not False:
|
|
send_push(
|
|
user_id=t_author.id,
|
|
title=f'Nowa odpowiedź: {topic.title[:60]}',
|
|
body=f'{replier_name}: {preview}' if preview else f'{replier_name} odpowiedział',
|
|
url=topic_url,
|
|
tag=f'forum-reply-{topic_id}',
|
|
)
|
|
notified_user_ids.add(t_author.id)
|
|
|
|
# Cytowani autorzy — pattern "> **Imię Nazwisko** napisał(a):"
|
|
quoted_names = set(_re.findall(r'>\s*\*\*([^*\n]+?)\*\*\s*napisał', content))
|
|
for qname in quoted_names:
|
|
qname = qname.strip()
|
|
if not qname:
|
|
continue
|
|
quoted_users = db.query(User).filter(User.name == qname).all()
|
|
if len(quoted_users) != 1:
|
|
continue # niejednoznaczne — pomijamy
|
|
quoted = quoted_users[0]
|
|
if quoted.id == current_user.id or quoted.id in notified_user_ids:
|
|
continue
|
|
if quoted.notify_push_forum_quote is not False:
|
|
send_push(
|
|
user_id=quoted.id,
|
|
title=f'Zacytowano Twoją wypowiedź: {topic.title[:50]}',
|
|
body=f'{replier_name}: {preview}' if preview else f'{replier_name} zacytował',
|
|
url=topic_url,
|
|
tag=f'forum-quote-{reply.id}-{quoted.id}',
|
|
)
|
|
notified_user_ids.add(quoted.id)
|
|
# Email dla cytowanego (jeśli flag=True, default TRUE dla quote)
|
|
if quoted.email and quoted.notify_email_forum_quote is not False:
|
|
try:
|
|
from utils.notifications import send_forum_reply_email
|
|
send_forum_reply_email(
|
|
topic_id=topic_id,
|
|
topic_title=topic.title,
|
|
replier_name=replier_name,
|
|
reply_content=content,
|
|
subscriber_emails=[{'email': quoted.email, 'name': quoted.name or quoted.email}],
|
|
reply_id=reply.id,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send forum quote email: {e}")
|
|
except Exception as e:
|
|
logger.warning(f"Forum push trigger error: {e}")
|
|
|
|
flash('Odpowiedź dodana.', 'success')
|
|
return redirect(url_for('.forum_topic', topic_id=topic_id))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# ADMIN FORUM ROUTES
|
|
# ============================================================
|
|
|
|
@bp.route('/admin/forum')
|
|
@login_required
|
|
def admin_forum():
|
|
"""Admin panel for forum moderation"""
|
|
if not current_user.can_moderate_forum():
|
|
flash('Brak uprawnień do tej strony.', 'error')
|
|
return redirect(url_for('.forum_index'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Get all topics with stats
|
|
topics = db.query(ForumTopic).order_by(
|
|
ForumTopic.created_at.desc()
|
|
).all()
|
|
|
|
# Get recent replies
|
|
recent_replies = db.query(ForumReply).order_by(
|
|
ForumReply.created_at.desc()
|
|
).limit(50).all()
|
|
|
|
# Stats
|
|
total_topics = len(topics)
|
|
total_replies = db.query(ForumReply).count()
|
|
pinned_count = sum(1 for t in topics if t.is_pinned)
|
|
locked_count = sum(1 for t in topics if t.is_locked)
|
|
|
|
# Category and status stats
|
|
category_counts = {}
|
|
status_counts = {}
|
|
for t in topics:
|
|
cat = t.category or 'question'
|
|
status = t.status or 'new'
|
|
category_counts[cat] = category_counts.get(cat, 0) + 1
|
|
status_counts[status] = status_counts.get(status, 0) + 1
|
|
|
|
return render_template(
|
|
'admin/forum.html',
|
|
topics=topics,
|
|
recent_replies=recent_replies,
|
|
total_topics=total_topics,
|
|
total_replies=total_replies,
|
|
pinned_count=pinned_count,
|
|
locked_count=locked_count,
|
|
category_counts=category_counts,
|
|
status_counts=status_counts,
|
|
categories=ForumTopic.CATEGORIES,
|
|
statuses=ForumTopic.STATUSES,
|
|
category_labels=ForumTopic.CATEGORY_LABELS,
|
|
status_labels=ForumTopic.STATUS_LABELS
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/topic/<int:topic_id>/pin', methods=['POST'])
|
|
@login_required
|
|
def admin_forum_pin(topic_id):
|
|
"""Toggle topic pin status"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
topic.is_pinned = not topic.is_pinned
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} {'pinned' if topic.is_pinned else 'unpinned'} topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'is_pinned': topic.is_pinned,
|
|
'message': f"Temat {'przypięty' if topic.is_pinned else 'odpięty'}"
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/topic/<int:topic_id>/lock', methods=['POST'])
|
|
@login_required
|
|
def admin_forum_lock(topic_id):
|
|
"""Toggle topic lock status"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
topic.is_locked = not topic.is_locked
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} {'locked' if topic.is_locked else 'unlocked'} topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'is_locked': topic.is_locked,
|
|
'message': f"Temat {'zamknięty' if topic.is_locked else 'otwarty'}"
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/topic/<int:topic_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def admin_forum_delete_topic(topic_id):
|
|
"""Delete topic and all its replies"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
topic_title = topic.title
|
|
|
|
# Manually delete dependent records (FK may lack CASCADE in DB)
|
|
from database import ForumTopicRead, ForumReplyRead, ForumAttachment, ForumReport, ForumEditHistory, ForumReply, ForumTopicSubscription
|
|
for reply in topic.replies:
|
|
db.query(ForumReplyRead).filter(ForumReplyRead.reply_id == reply.id).delete()
|
|
db.query(ForumAttachment).filter(ForumAttachment.reply_id == reply.id).delete()
|
|
db.query(ForumReport).filter(ForumReport.reply_id == reply.id).delete()
|
|
db.query(ForumEditHistory).filter(ForumEditHistory.reply_id == reply.id).delete()
|
|
db.query(ForumReply).filter(ForumReply.topic_id == topic_id).delete()
|
|
db.query(ForumTopicRead).filter(ForumTopicRead.topic_id == topic_id).delete()
|
|
db.query(ForumAttachment).filter(ForumAttachment.topic_id == topic_id).delete()
|
|
db.query(ForumReport).filter(ForumReport.topic_id == topic_id).delete()
|
|
db.query(ForumEditHistory).filter(ForumEditHistory.topic_id == topic_id).delete()
|
|
db.query(ForumTopicSubscription).filter(ForumTopicSubscription.topic_id == topic_id).delete()
|
|
|
|
db.delete(topic)
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} deleted topic #{topic_id}: {topic_title}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Temat usunięty'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/reply/<int:reply_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def admin_forum_delete_reply(reply_id):
|
|
"""Delete a reply"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
reply = db.query(ForumReply).filter(ForumReply.id == reply_id).first()
|
|
if not reply:
|
|
return jsonify({'success': False, 'error': 'Odpowiedź nie istnieje'}), 404
|
|
|
|
topic_id = reply.topic_id
|
|
# Delete dependent records first (reply reads, attachments, reports, edit history)
|
|
db.query(ForumReplyRead).filter(ForumReplyRead.reply_id == reply_id).delete()
|
|
db.query(ForumAttachment).filter(ForumAttachment.reply_id == reply_id).delete()
|
|
db.query(ForumReport).filter(ForumReport.reply_id == reply_id).delete()
|
|
db.query(ForumEditHistory).filter(ForumEditHistory.reply_id == reply_id).delete()
|
|
db.delete(reply)
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} deleted reply #{reply_id} from topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Odpowiedź usunięta'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/topic/<int:topic_id>/status', methods=['POST'])
|
|
@login_required
|
|
def admin_forum_change_status(topic_id):
|
|
"""Change topic status (moderators only)"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
new_status = data.get('status')
|
|
note = data.get('note', '').strip()
|
|
|
|
if not new_status or new_status not in ForumTopic.STATUSES:
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowy status'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
old_status = topic.status
|
|
topic.status = new_status
|
|
topic.status_changed_by = current_user.id
|
|
topic.status_changed_at = datetime.now()
|
|
if note:
|
|
topic.status_note = note
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} changed topic #{topic_id} status: {old_status} -> {new_status}")
|
|
return jsonify({
|
|
'success': True,
|
|
'status': new_status,
|
|
'status_label': ForumTopic.STATUS_LABELS.get(new_status, new_status),
|
|
'message': f"Status zmieniony na: {ForumTopic.STATUS_LABELS.get(new_status, new_status)}"
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/bulk-action', methods=['POST'])
|
|
@login_required
|
|
def admin_forum_bulk_action():
|
|
"""Perform bulk action on multiple topics (moderators only)"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
topic_ids = data.get('topic_ids', [])
|
|
action = data.get('action')
|
|
|
|
if not topic_ids or not isinstance(topic_ids, list):
|
|
return jsonify({'success': False, 'error': 'Nie wybrano tematów'}), 400
|
|
|
|
if action not in ['pin', 'unpin', 'lock', 'unlock', 'status', 'delete']:
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowa akcja'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topics = db.query(ForumTopic).filter(ForumTopic.id.in_(topic_ids)).all()
|
|
|
|
if not topics:
|
|
return jsonify({'success': False, 'error': 'Nie znaleziono tematów'}), 404
|
|
|
|
count = len(topics)
|
|
|
|
if action == 'pin':
|
|
for topic in topics:
|
|
topic.is_pinned = True
|
|
message = f'Przypięto {count} tematów'
|
|
|
|
elif action == 'unpin':
|
|
for topic in topics:
|
|
topic.is_pinned = False
|
|
message = f'Odpięto {count} tematów'
|
|
|
|
elif action == 'lock':
|
|
for topic in topics:
|
|
topic.is_locked = True
|
|
message = f'Zablokowano {count} tematów'
|
|
|
|
elif action == 'unlock':
|
|
for topic in topics:
|
|
topic.is_locked = False
|
|
message = f'Odblokowano {count} tematów'
|
|
|
|
elif action == 'status':
|
|
new_status = data.get('status')
|
|
if not new_status or new_status not in ForumTopic.STATUSES:
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowy status'}), 400
|
|
for topic in topics:
|
|
topic.status = new_status
|
|
topic.status_changed_by = current_user.id
|
|
topic.status_changed_at = datetime.now()
|
|
status_label = ForumTopic.STATUS_LABELS.get(new_status, new_status)
|
|
message = f'Zmieniono status {count} tematów na: {status_label}'
|
|
|
|
elif action == 'delete':
|
|
# Soft delete topics
|
|
for topic in topics:
|
|
topic.is_deleted = True
|
|
topic.deleted_at = datetime.now()
|
|
topic.deleted_by = current_user.id
|
|
message = f'Usunięto {count} tematów'
|
|
|
|
db.commit()
|
|
logger.info(f"Admin {current_user.email} performed bulk action '{action}' on {count} topics: {topic_ids}")
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': message,
|
|
'affected_count': count
|
|
})
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error in bulk action: {e}")
|
|
return jsonify({'success': False, 'error': 'Wystąpił błąd'}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# USER FORUM ACTIONS (edit, delete, reactions, subscriptions)
|
|
# ============================================================
|
|
|
|
def _can_edit_content(content_created_at):
|
|
"""Check if content is within editable time window (24h)"""
|
|
if not content_created_at:
|
|
return False
|
|
from datetime import timedelta
|
|
return datetime.now() - content_created_at < timedelta(hours=EDIT_TIME_LIMIT_HOURS)
|
|
|
|
|
|
@bp.route('/forum/topic/<int:topic_id>/edit', methods=['POST'])
|
|
@login_required
|
|
@forum_access_required
|
|
def edit_topic(topic_id):
|
|
"""Edit own topic (within 24h)"""
|
|
data = request.get_json() or {}
|
|
new_content = data.get('content', '').strip()
|
|
|
|
if not new_content or len(new_content) < 10:
|
|
return jsonify({'success': False, 'error': 'Treść musi mieć co najmniej 10 znaków'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
# Check ownership (unless moderator)
|
|
if topic.author_id != current_user.id and not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
# Check time limit (unless moderator)
|
|
if not current_user.can_moderate_forum() and not _can_edit_content(topic.created_at):
|
|
return jsonify({'success': False, 'error': 'Minął limit czasu edycji (24h)'}), 403
|
|
|
|
if topic.is_locked:
|
|
return jsonify({'success': False, 'error': 'Temat jest zamknięty'}), 403
|
|
|
|
# Save edit history
|
|
history = ForumEditHistory(
|
|
content_type='topic',
|
|
topic_id=topic.id,
|
|
editor_id=current_user.id,
|
|
old_content=topic.content,
|
|
new_content=new_content,
|
|
edit_reason=data.get('reason', '')
|
|
)
|
|
db.add(history)
|
|
|
|
# Update topic
|
|
topic.content = new_content
|
|
topic.edited_at = datetime.now()
|
|
topic.edited_by = current_user.id
|
|
topic.edit_count = (topic.edit_count or 0) + 1
|
|
db.commit()
|
|
|
|
logger.info(f"User {current_user.email} edited topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Temat zaktualizowany',
|
|
'edit_count': topic.edit_count,
|
|
'edited_at': topic.edited_at.isoformat()
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/forum/reply/<int:reply_id>/edit', methods=['POST'])
|
|
@login_required
|
|
@forum_access_required
|
|
def edit_reply(reply_id):
|
|
"""Edit own reply (within 24h)"""
|
|
data = request.get_json() or {}
|
|
new_content = data.get('content', '').strip()
|
|
|
|
if not new_content or len(new_content) < 3:
|
|
return jsonify({'success': False, 'error': 'Treść musi mieć co najmniej 3 znaki'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
reply = db.query(ForumReply).filter(ForumReply.id == reply_id).first()
|
|
if not reply:
|
|
return jsonify({'success': False, 'error': 'Odpowiedź nie istnieje'}), 404
|
|
|
|
# Check ownership (unless moderator)
|
|
if reply.author_id != current_user.id and not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
# Check time limit (unless moderator)
|
|
if not current_user.can_moderate_forum() and not _can_edit_content(reply.created_at):
|
|
return jsonify({'success': False, 'error': 'Minął limit czasu edycji (24h)'}), 403
|
|
|
|
# Check if topic is locked
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == reply.topic_id).first()
|
|
if topic and topic.is_locked:
|
|
return jsonify({'success': False, 'error': 'Temat jest zamknięty'}), 403
|
|
|
|
# Save edit history
|
|
history = ForumEditHistory(
|
|
content_type='reply',
|
|
reply_id=reply.id,
|
|
editor_id=current_user.id,
|
|
old_content=reply.content,
|
|
new_content=new_content,
|
|
edit_reason=data.get('reason', '')
|
|
)
|
|
db.add(history)
|
|
|
|
# Update reply
|
|
reply.content = new_content
|
|
reply.edited_at = datetime.now()
|
|
reply.edited_by = current_user.id
|
|
reply.edit_count = (reply.edit_count or 0) + 1
|
|
db.commit()
|
|
|
|
logger.info(f"User {current_user.email} edited reply #{reply_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Odpowiedź zaktualizowana',
|
|
'edit_count': reply.edit_count,
|
|
'edited_at': reply.edited_at.isoformat()
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/forum/reply/<int:reply_id>/delete', methods=['POST'])
|
|
@login_required
|
|
@forum_access_required
|
|
def delete_own_reply(reply_id):
|
|
"""Soft delete own reply (if no child responses exist)"""
|
|
db = SessionLocal()
|
|
try:
|
|
reply = db.query(ForumReply).filter(ForumReply.id == reply_id).first()
|
|
if not reply:
|
|
return jsonify({'success': False, 'error': 'Odpowiedź nie istnieje'}), 404
|
|
|
|
# Check ownership
|
|
if reply.author_id != current_user.id and not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
# Check if topic is locked
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == reply.topic_id).first()
|
|
if topic and topic.is_locked:
|
|
return jsonify({'success': False, 'error': 'Temat jest zamknięty'}), 403
|
|
|
|
# Soft delete
|
|
reply.is_deleted = True
|
|
reply.deleted_at = datetime.now()
|
|
reply.deleted_by = current_user.id
|
|
db.commit()
|
|
|
|
logger.info(f"User {current_user.email} soft-deleted reply #{reply_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Odpowiedź usunięta'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/forum/topic/<int:topic_id>/react', methods=['POST'])
|
|
@login_required
|
|
@forum_access_required
|
|
def react_to_topic(topic_id):
|
|
"""Add or remove reaction from topic"""
|
|
data = request.get_json() or {}
|
|
reaction = data.get('reaction')
|
|
|
|
if reaction not in AVAILABLE_REACTIONS:
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowa reakcja'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
# Get current reactions (handle None)
|
|
reactions = topic.reactions or {}
|
|
if not isinstance(reactions, dict):
|
|
reactions = {}
|
|
|
|
# Toggle reaction
|
|
user_id = current_user.id
|
|
if reaction not in reactions:
|
|
reactions[reaction] = []
|
|
|
|
if user_id in reactions[reaction]:
|
|
reactions[reaction].remove(user_id)
|
|
action = 'removed'
|
|
else:
|
|
# Remove user from other reactions first (one reaction per user)
|
|
for r in AVAILABLE_REACTIONS:
|
|
if r in reactions and user_id in reactions[r]:
|
|
reactions[r].remove(user_id)
|
|
reactions[reaction].append(user_id)
|
|
action = 'added'
|
|
|
|
# Clean up empty reaction lists
|
|
reactions = {k: v for k, v in reactions.items() if v}
|
|
|
|
topic.reactions = reactions
|
|
db.commit()
|
|
|
|
# Build names for tooltips
|
|
all_uids = set()
|
|
for uid_list in reactions.values():
|
|
all_uids.update(uid_list)
|
|
user_names = {}
|
|
if all_uids:
|
|
users = db.query(User.id, User.name, User.email).filter(User.id.in_(all_uids)).all()
|
|
user_names = {u.id: (u.name or u.email.split('@')[0]) for u in users}
|
|
reaction_names = {k: [user_names.get(uid, '?') for uid in v] for k, v in reactions.items()}
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'action': action,
|
|
'reactions': {k: len(v) for k, v in reactions.items()},
|
|
'reaction_names': reaction_names,
|
|
'user_reaction': reaction if action == 'added' else None
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/forum/reply/<int:reply_id>/react', methods=['POST'])
|
|
@login_required
|
|
@forum_access_required
|
|
def react_to_reply(reply_id):
|
|
"""Add or remove reaction from reply"""
|
|
data = request.get_json() or {}
|
|
reaction = data.get('reaction')
|
|
|
|
if reaction not in AVAILABLE_REACTIONS:
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowa reakcja'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
reply = db.query(ForumReply).filter(ForumReply.id == reply_id).first()
|
|
if not reply:
|
|
return jsonify({'success': False, 'error': 'Odpowiedź nie istnieje'}), 404
|
|
|
|
# Get current reactions (handle None)
|
|
reactions = reply.reactions or {}
|
|
if not isinstance(reactions, dict):
|
|
reactions = {}
|
|
|
|
# Toggle reaction
|
|
user_id = current_user.id
|
|
if reaction not in reactions:
|
|
reactions[reaction] = []
|
|
|
|
if user_id in reactions[reaction]:
|
|
reactions[reaction].remove(user_id)
|
|
action = 'removed'
|
|
else:
|
|
# Remove user from other reactions first
|
|
for r in AVAILABLE_REACTIONS:
|
|
if r in reactions and user_id in reactions[r]:
|
|
reactions[r].remove(user_id)
|
|
reactions[reaction].append(user_id)
|
|
action = 'added'
|
|
|
|
# Clean up empty reaction lists
|
|
reactions = {k: v for k, v in reactions.items() if v}
|
|
|
|
reply.reactions = reactions
|
|
db.commit()
|
|
|
|
# Send notification to reply author (if adding reaction and not own content)
|
|
if action == 'added' and reply.author_id != current_user.id:
|
|
try:
|
|
reactor_name = current_user.name or current_user.email.split('@')[0]
|
|
create_forum_reaction_notification(
|
|
user_id=reply.author_id,
|
|
reactor_name=reactor_name,
|
|
content_type='reply',
|
|
content_id=reply.id,
|
|
topic_id=reply.topic_id,
|
|
emoji=reaction
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send reaction notification: {e}")
|
|
|
|
# Build names for tooltips
|
|
all_uids = set()
|
|
for uid_list in reactions.values():
|
|
all_uids.update(uid_list)
|
|
user_names = {}
|
|
if all_uids:
|
|
users = db.query(User.id, User.name, User.email).filter(User.id.in_(all_uids)).all()
|
|
user_names = {u.id: (u.name or u.email.split('@')[0]) for u in users}
|
|
reaction_names = {k: [user_names.get(uid, '?') for uid in v] for k, v in reactions.items()}
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'action': action,
|
|
'reactions': {k: len(v) for k, v in reactions.items()},
|
|
'reaction_names': reaction_names,
|
|
'user_reaction': reaction if action == 'added' else None
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/forum/topic/<int:topic_id>/subscribe', methods=['POST'])
|
|
@login_required
|
|
@forum_access_required
|
|
def subscribe_to_topic(topic_id):
|
|
"""Subscribe to topic notifications"""
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
# Check if already subscribed
|
|
existing = db.query(ForumTopicSubscription).filter(
|
|
ForumTopicSubscription.topic_id == topic_id,
|
|
ForumTopicSubscription.user_id == current_user.id
|
|
).first()
|
|
|
|
if existing:
|
|
return jsonify({'success': False, 'error': 'Już obserwujesz ten temat'}), 400
|
|
|
|
subscription = ForumTopicSubscription(
|
|
user_id=current_user.id,
|
|
topic_id=topic_id
|
|
)
|
|
db.add(subscription)
|
|
db.commit()
|
|
|
|
logger.info(f"User {current_user.email} subscribed to topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Zasubskrybowano temat'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/forum/topic/<int:topic_id>/unsubscribe', methods=['POST'])
|
|
@login_required
|
|
@forum_access_required
|
|
def unsubscribe_from_topic(topic_id):
|
|
"""Unsubscribe from topic notifications"""
|
|
db = SessionLocal()
|
|
try:
|
|
subscription = db.query(ForumTopicSubscription).filter(
|
|
ForumTopicSubscription.topic_id == topic_id,
|
|
ForumTopicSubscription.user_id == current_user.id
|
|
).first()
|
|
|
|
if not subscription:
|
|
return jsonify({'success': False, 'error': 'Nie obserwujesz tego tematu'}), 400
|
|
|
|
db.delete(subscription)
|
|
db.commit()
|
|
|
|
logger.info(f"User {current_user.email} unsubscribed from topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Anulowano subskrypcję'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/forum/<int:topic_id>/unsubscribe', methods=['GET'])
|
|
@login_required
|
|
@forum_access_required
|
|
def unsubscribe_from_email(topic_id):
|
|
"""Unsubscribe from topic via email link (GET, requires login)"""
|
|
db = SessionLocal()
|
|
try:
|
|
subscription = db.query(ForumTopicSubscription).filter(
|
|
ForumTopicSubscription.topic_id == topic_id,
|
|
ForumTopicSubscription.user_id == current_user.id
|
|
).first()
|
|
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
topic_title = topic.title if topic else f"#{topic_id}"
|
|
|
|
if subscription:
|
|
db.delete(subscription)
|
|
db.commit()
|
|
flash(f'Przestales obserwowac watek: {topic_title}', 'success')
|
|
else:
|
|
flash('Nie obserwujesz tego watku.', 'info')
|
|
|
|
return redirect(url_for('.forum_topic', topic_id=topic_id))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/forum/report', methods=['POST'])
|
|
@login_required
|
|
@forum_access_required
|
|
def report_content():
|
|
"""Report topic or reply for moderation"""
|
|
data = request.get_json() or {}
|
|
content_type = data.get('content_type')
|
|
content_id = data.get('content_id')
|
|
reason = data.get('reason')
|
|
description = data.get('description', '').strip()
|
|
|
|
if content_type not in ['topic', 'reply']:
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowy typ treści'}), 400
|
|
|
|
if reason not in ForumReport.REASONS:
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowy powód'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Verify content exists
|
|
if content_type == 'topic':
|
|
content = db.query(ForumTopic).filter(ForumTopic.id == content_id).first()
|
|
if not content:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
else:
|
|
content = db.query(ForumReply).filter(ForumReply.id == content_id).first()
|
|
if not content:
|
|
return jsonify({'success': False, 'error': 'Odpowiedź nie istnieje'}), 404
|
|
|
|
# Check if user already reported this content
|
|
existing = db.query(ForumReport).filter(
|
|
ForumReport.reporter_id == current_user.id,
|
|
ForumReport.content_type == content_type,
|
|
(ForumReport.topic_id == content_id if content_type == 'topic' else ForumReport.reply_id == content_id)
|
|
).first()
|
|
|
|
if existing:
|
|
return jsonify({'success': False, 'error': 'Już zgłosiłeś tę treść'}), 400
|
|
|
|
report = ForumReport(
|
|
reporter_id=current_user.id,
|
|
content_type=content_type,
|
|
topic_id=content_id if content_type == 'topic' else None,
|
|
reply_id=content_id if content_type == 'reply' else None,
|
|
reason=reason,
|
|
description=description
|
|
)
|
|
db.add(report)
|
|
db.commit()
|
|
db.refresh(report)
|
|
|
|
# Notify admins about the report
|
|
try:
|
|
admin_users = db.query(User).filter(User.role == 'ADMIN', User.is_active == True).all()
|
|
admin_ids = [u.id for u in admin_users]
|
|
reporter_name = current_user.name or current_user.email.split('@')[0]
|
|
create_forum_report_notification(
|
|
admin_user_ids=admin_ids,
|
|
report_id=report.id,
|
|
content_type=content_type,
|
|
reporter_name=reporter_name
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send report notification: {e}")
|
|
|
|
logger.info(f"User {current_user.email} reported {content_type} #{content_id}: {reason}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Zgłoszenie zostało wysłane'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# EXTENDED ADMIN ROUTES (edit, restore, reports, history)
|
|
# ============================================================
|
|
|
|
@bp.route('/admin/forum/topic/<int:topic_id>/admin-edit', methods=['POST'])
|
|
@login_required
|
|
def admin_edit_topic(topic_id):
|
|
"""Moderator: Edit any topic content"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
new_content = data.get('content', '').strip()
|
|
new_title = data.get('title', '').strip()
|
|
|
|
if not new_content or len(new_content) < 10:
|
|
return jsonify({'success': False, 'error': 'Treść musi mieć co najmniej 10 znaków'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
# Save edit history
|
|
old_content = topic.content
|
|
if new_title:
|
|
old_content = f"[Title: {topic.title}]\n{old_content}"
|
|
new_content_with_title = f"[Title: {new_title}]\n{new_content}"
|
|
else:
|
|
new_content_with_title = new_content
|
|
|
|
history = ForumEditHistory(
|
|
content_type='topic',
|
|
topic_id=topic.id,
|
|
editor_id=current_user.id,
|
|
old_content=old_content,
|
|
new_content=new_content_with_title,
|
|
edit_reason=data.get('reason', 'Edycja admina')
|
|
)
|
|
db.add(history)
|
|
|
|
# Update topic
|
|
if new_title:
|
|
topic.title = new_title
|
|
topic.content = new_content
|
|
topic.edited_at = datetime.now()
|
|
topic.edited_by = current_user.id
|
|
topic.edit_count = (topic.edit_count or 0) + 1
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} edited topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Temat zaktualizowany przez admina'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/reply/<int:reply_id>/admin-edit', methods=['POST'])
|
|
@login_required
|
|
def admin_edit_reply(reply_id):
|
|
"""Moderator: Edit any reply content"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
new_content = data.get('content', '').strip()
|
|
|
|
if not new_content or len(new_content) < 3:
|
|
return jsonify({'success': False, 'error': 'Treść musi mieć co najmniej 3 znaki'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
reply = db.query(ForumReply).filter(ForumReply.id == reply_id).first()
|
|
if not reply:
|
|
return jsonify({'success': False, 'error': 'Odpowiedź nie istnieje'}), 404
|
|
|
|
# Save edit history
|
|
history = ForumEditHistory(
|
|
content_type='reply',
|
|
reply_id=reply.id,
|
|
editor_id=current_user.id,
|
|
old_content=reply.content,
|
|
new_content=new_content,
|
|
edit_reason=data.get('reason', 'Edycja admina')
|
|
)
|
|
db.add(history)
|
|
|
|
# Update reply
|
|
reply.content = new_content
|
|
reply.edited_at = datetime.now()
|
|
reply.edited_by = current_user.id
|
|
reply.edit_count = (reply.edit_count or 0) + 1
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} edited reply #{reply_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Odpowiedź zaktualizowana przez admina'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/reply/<int:reply_id>/solution', methods=['POST'])
|
|
@login_required
|
|
def mark_as_solution(reply_id):
|
|
"""Moderator: Mark reply as solution"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
reply = db.query(ForumReply).filter(ForumReply.id == reply_id).first()
|
|
if not reply:
|
|
return jsonify({'success': False, 'error': 'Odpowiedź nie istnieje'}), 404
|
|
|
|
# Toggle solution status
|
|
if reply.is_solution:
|
|
reply.is_solution = False
|
|
reply.marked_as_solution_by = None
|
|
reply.marked_as_solution_at = None
|
|
message = 'Usunięto oznaczenie rozwiązania'
|
|
else:
|
|
# Remove solution from other replies in the same topic
|
|
db.query(ForumReply).filter(
|
|
ForumReply.topic_id == reply.topic_id,
|
|
ForumReply.is_solution == True
|
|
).update({'is_solution': False, 'marked_as_solution_by': None, 'marked_as_solution_at': None})
|
|
|
|
reply.is_solution = True
|
|
reply.marked_as_solution_by = current_user.id
|
|
reply.marked_as_solution_at = datetime.now()
|
|
message = 'Oznaczono jako rozwiązanie'
|
|
|
|
# Notify topic author
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == reply.topic_id).first()
|
|
if topic and topic.author_id != reply.author_id:
|
|
try:
|
|
create_forum_solution_notification(
|
|
user_id=topic.author_id,
|
|
topic_id=topic.id,
|
|
topic_title=topic.title
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send solution notification: {e}")
|
|
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} {'marked' if reply.is_solution else 'unmarked'} reply #{reply_id} as solution")
|
|
return jsonify({
|
|
'success': True,
|
|
'is_solution': reply.is_solution,
|
|
'message': message
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/topic/<int:topic_id>/restore', methods=['POST'])
|
|
@login_required
|
|
def restore_topic(topic_id):
|
|
"""Moderator: Restore soft-deleted topic"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
if not topic.is_deleted:
|
|
return jsonify({'success': False, 'error': 'Temat nie jest usunięty'}), 400
|
|
|
|
topic.is_deleted = False
|
|
topic.deleted_at = None
|
|
topic.deleted_by = None
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} restored topic #{topic_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Temat przywrócony'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/reply/<int:reply_id>/restore', methods=['POST'])
|
|
@login_required
|
|
def restore_reply(reply_id):
|
|
"""Moderator: Restore soft-deleted reply"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
reply = db.query(ForumReply).filter(ForumReply.id == reply_id).first()
|
|
if not reply:
|
|
return jsonify({'success': False, 'error': 'Odpowiedź nie istnieje'}), 404
|
|
|
|
if not reply.is_deleted:
|
|
return jsonify({'success': False, 'error': 'Odpowiedź nie jest usunięta'}), 400
|
|
|
|
reply.is_deleted = False
|
|
reply.deleted_at = None
|
|
reply.deleted_by = None
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} restored reply #{reply_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Odpowiedź przywrócona'
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/reports')
|
|
@login_required
|
|
def admin_forum_reports():
|
|
"""Moderator: View all reports"""
|
|
if not current_user.can_moderate_forum():
|
|
flash('Brak uprawnień do tej strony.', 'error')
|
|
return redirect(url_for('.forum_index'))
|
|
|
|
status_filter = request.args.get('status', 'pending')
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
query = db.query(ForumReport).order_by(ForumReport.created_at.desc())
|
|
|
|
if status_filter in ['pending', 'reviewed', 'dismissed']:
|
|
query = query.filter(ForumReport.status == status_filter)
|
|
|
|
reports = query.all()
|
|
|
|
# Get stats
|
|
pending_count = db.query(ForumReport).filter(ForumReport.status == 'pending').count()
|
|
reviewed_count = db.query(ForumReport).filter(ForumReport.status == 'reviewed').count()
|
|
dismissed_count = db.query(ForumReport).filter(ForumReport.status == 'dismissed').count()
|
|
|
|
return render_template(
|
|
'admin/forum_reports.html',
|
|
reports=reports,
|
|
status_filter=status_filter,
|
|
pending_count=pending_count,
|
|
reviewed_count=reviewed_count,
|
|
dismissed_count=dismissed_count,
|
|
reason_labels=ForumReport.REASON_LABELS
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/report/<int:report_id>/review', methods=['POST'])
|
|
@login_required
|
|
def review_report(report_id):
|
|
"""Moderator: Review a report"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
new_status = data.get('status')
|
|
review_note = data.get('note', '').strip()
|
|
|
|
if new_status not in ['reviewed', 'dismissed']:
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowy status'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
report = db.query(ForumReport).filter(ForumReport.id == report_id).first()
|
|
if not report:
|
|
return jsonify({'success': False, 'error': 'Zgłoszenie nie istnieje'}), 404
|
|
|
|
report.status = new_status
|
|
report.reviewed_by = current_user.id
|
|
report.reviewed_at = datetime.now()
|
|
report.review_note = review_note
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} reviewed report #{report_id}: {new_status}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f"Zgłoszenie {'rozpatrzone' if new_status == 'reviewed' else 'odrzucone'}"
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/topic/<int:topic_id>/history')
|
|
@login_required
|
|
def topic_edit_history(topic_id):
|
|
"""Moderator: View topic edit history"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
history = db.query(ForumEditHistory).filter(
|
|
ForumEditHistory.content_type == 'topic',
|
|
ForumEditHistory.topic_id == topic_id
|
|
).order_by(ForumEditHistory.created_at.desc()).all()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'history': [{
|
|
'id': h.id,
|
|
'editor': h.editor.full_name if h.editor else 'Nieznany',
|
|
'old_content': h.old_content[:200] + '...' if len(h.old_content) > 200 else h.old_content,
|
|
'new_content': h.new_content[:200] + '...' if len(h.new_content) > 200 else h.new_content,
|
|
'edit_reason': h.edit_reason,
|
|
'created_at': h.created_at.isoformat()
|
|
} for h in history]
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/reply/<int:reply_id>/history')
|
|
@login_required
|
|
def reply_edit_history(reply_id):
|
|
"""Moderator: View reply edit history"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
history = db.query(ForumEditHistory).filter(
|
|
ForumEditHistory.content_type == 'reply',
|
|
ForumEditHistory.reply_id == reply_id
|
|
).order_by(ForumEditHistory.created_at.desc()).all()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'history': [{
|
|
'id': h.id,
|
|
'editor': h.editor.full_name if h.editor else 'Nieznany',
|
|
'old_content': h.old_content[:200] + '...' if len(h.old_content) > 200 else h.old_content,
|
|
'new_content': h.new_content[:200] + '...' if len(h.new_content) > 200 else h.new_content,
|
|
'edit_reason': h.edit_reason,
|
|
'created_at': h.created_at.isoformat()
|
|
} for h in history]
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/deleted')
|
|
@login_required
|
|
def admin_deleted_content():
|
|
"""Moderator: View soft-deleted topics and replies"""
|
|
if not current_user.can_moderate_forum():
|
|
flash('Brak uprawnień do tej strony.', 'error')
|
|
return redirect(url_for('.forum_index'))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
deleted_topics = db.query(ForumTopic).filter(
|
|
ForumTopic.is_deleted == True
|
|
).order_by(ForumTopic.deleted_at.desc()).all()
|
|
|
|
deleted_replies = db.query(ForumReply).filter(
|
|
ForumReply.is_deleted == True
|
|
).order_by(ForumReply.deleted_at.desc()).all()
|
|
|
|
return render_template(
|
|
'admin/forum_deleted.html',
|
|
deleted_topics=deleted_topics,
|
|
deleted_replies=deleted_replies
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# USER STATISTICS
|
|
# ============================================================
|
|
|
|
@bp.route('/forum/user/<int:user_id>/stats')
|
|
@login_required
|
|
@forum_access_required
|
|
def user_forum_stats(user_id):
|
|
"""Get forum statistics for a user (for tooltip display)"""
|
|
from sqlalchemy import func
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Count topics created
|
|
topic_count = db.query(func.count(ForumTopic.id)).filter(
|
|
ForumTopic.author_id == user_id,
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
# Count replies created
|
|
reply_count = db.query(func.count(ForumReply.id)).filter(
|
|
ForumReply.author_id == user_id,
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
# Count solutions marked
|
|
solution_count = db.query(func.count(ForumReply.id)).filter(
|
|
ForumReply.author_id == user_id,
|
|
ForumReply.is_solution == True,
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
# Count reactions received on user's topics and replies
|
|
# Using JSONB - count non-empty reaction arrays
|
|
reactions_received = 0
|
|
|
|
# Get user's topics with reactions
|
|
user_topics = db.query(ForumTopic).filter(
|
|
ForumTopic.author_id == user_id,
|
|
ForumTopic.reactions.isnot(None)
|
|
).all()
|
|
for topic in user_topics:
|
|
if topic.reactions:
|
|
for emoji, user_ids in topic.reactions.items():
|
|
if isinstance(user_ids, list):
|
|
reactions_received += len(user_ids)
|
|
|
|
# Get user's replies with reactions
|
|
user_replies = db.query(ForumReply).filter(
|
|
ForumReply.author_id == user_id,
|
|
ForumReply.reactions.isnot(None)
|
|
).all()
|
|
for reply in user_replies:
|
|
if reply.reactions:
|
|
for emoji, user_ids in reply.reactions.items():
|
|
if isinstance(user_ids, list):
|
|
reactions_received += len(user_ids)
|
|
|
|
# Get user info
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
user_name = user.name if user else 'Nieznany'
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'user_id': user_id,
|
|
'user_name': user_name,
|
|
'stats': {
|
|
'topics': topic_count,
|
|
'replies': reply_count,
|
|
'solutions': solution_count,
|
|
'reactions_received': reactions_received,
|
|
'total_posts': topic_count + reply_count
|
|
}
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error getting user stats: {e}")
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ============================================================
|
|
# ADMIN ANALYTICS & TOOLS
|
|
# ============================================================
|
|
|
|
@bp.route('/admin/forum/analytics')
|
|
@login_required
|
|
def admin_forum_analytics():
|
|
"""Forum analytics dashboard with stats, charts, and rankings"""
|
|
if not current_user.can_moderate_forum():
|
|
flash('Brak uprawnien do tej strony.', 'error')
|
|
return redirect(url_for('.forum_index'))
|
|
|
|
from sqlalchemy import func, distinct
|
|
from datetime import timedelta
|
|
|
|
# Date range from query params (default: last 30 days)
|
|
end_date_str = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d'))
|
|
start_date_str = request.args.get('start_date', (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d'))
|
|
|
|
try:
|
|
end_date = datetime.strptime(end_date_str, '%Y-%m-%d')
|
|
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
|
|
except ValueError:
|
|
end_date = datetime.now()
|
|
start_date = end_date - timedelta(days=30)
|
|
|
|
# Ensure end_date is end of day
|
|
end_date = end_date.replace(hour=23, minute=59, second=59)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Basic stats
|
|
total_topics = db.query(func.count(ForumTopic.id)).filter(
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
total_replies = db.query(func.count(ForumReply.id)).filter(
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
# This month stats
|
|
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
topics_this_month = db.query(func.count(ForumTopic.id)).filter(
|
|
ForumTopic.created_at >= month_start,
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
replies_this_month = db.query(func.count(ForumReply.id)).filter(
|
|
ForumReply.created_at >= month_start,
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
# Active users in last 7 days
|
|
week_ago = datetime.now() - timedelta(days=7)
|
|
active_topic_authors = db.query(distinct(ForumTopic.author_id)).filter(
|
|
ForumTopic.created_at >= week_ago
|
|
).count()
|
|
active_reply_authors = db.query(distinct(ForumReply.author_id)).filter(
|
|
ForumReply.created_at >= week_ago
|
|
).count()
|
|
# Unique active users (simplified - just sum for now)
|
|
active_users_7d = max(active_topic_authors, active_reply_authors)
|
|
|
|
# Unanswered topics count
|
|
unanswered_count = db.query(func.count(ForumTopic.id)).filter(
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)),
|
|
~ForumTopic.id.in_(
|
|
db.query(distinct(ForumReply.topic_id)).filter(
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
)
|
|
)
|
|
).scalar() or 0
|
|
|
|
# Resolved topics
|
|
resolved_count = db.query(func.count(ForumTopic.id)).filter(
|
|
ForumTopic.status == 'resolved',
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
# Average response time (simplified - in hours)
|
|
# For now, just show "< 24h" as placeholder
|
|
avg_response_time = "< 24h"
|
|
|
|
stats = {
|
|
'total_topics': total_topics,
|
|
'total_replies': total_replies,
|
|
'topics_this_month': topics_this_month,
|
|
'replies_this_month': replies_this_month,
|
|
'active_users_7d': active_users_7d,
|
|
'unanswered_topics': unanswered_count,
|
|
'resolved_topics': resolved_count,
|
|
'avg_response_time': avg_response_time
|
|
}
|
|
|
|
# Unanswered topics list
|
|
replied_topic_ids = db.query(distinct(ForumReply.topic_id)).filter(
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
).subquery()
|
|
|
|
unanswered_topics = db.query(ForumTopic).filter(
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)),
|
|
~ForumTopic.id.in_(replied_topic_ids)
|
|
).order_by(ForumTopic.created_at.asc()).limit(20).all()
|
|
|
|
# Add days_waiting attribute
|
|
now = datetime.now()
|
|
for topic in unanswered_topics:
|
|
topic.days_waiting = (now - topic.created_at).days
|
|
|
|
# User rankings
|
|
user_stats = {}
|
|
# Count topics per user
|
|
topic_counts = db.query(
|
|
ForumTopic.author_id,
|
|
func.count(ForumTopic.id).label('count')
|
|
).filter(
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
|
|
).group_by(ForumTopic.author_id).all()
|
|
|
|
for author_id, count in topic_counts:
|
|
if author_id not in user_stats:
|
|
user_stats[author_id] = {'topic_count': 0, 'reply_count': 0, 'solution_count': 0, 'reaction_count': 0}
|
|
user_stats[author_id]['topic_count'] = count
|
|
|
|
# Count replies per user
|
|
reply_counts = db.query(
|
|
ForumReply.author_id,
|
|
func.count(ForumReply.id).label('count')
|
|
).filter(
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
).group_by(ForumReply.author_id).all()
|
|
|
|
for author_id, count in reply_counts:
|
|
if author_id not in user_stats:
|
|
user_stats[author_id] = {'topic_count': 0, 'reply_count': 0, 'solution_count': 0, 'reaction_count': 0}
|
|
user_stats[author_id]['reply_count'] = count
|
|
|
|
# Count solutions per user
|
|
solution_counts = db.query(
|
|
ForumReply.author_id,
|
|
func.count(ForumReply.id).label('count')
|
|
).filter(
|
|
ForumReply.is_solution == True,
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
).group_by(ForumReply.author_id).all()
|
|
|
|
for author_id, count in solution_counts:
|
|
if author_id in user_stats:
|
|
user_stats[author_id]['solution_count'] = count
|
|
|
|
# Get user details and calculate total score
|
|
user_rankings = []
|
|
user_ids = list(user_stats.keys())
|
|
if user_ids:
|
|
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
|
user_map = {u.id: u for u in users}
|
|
|
|
for user_id, stats_data in user_stats.items():
|
|
if user_id in user_map:
|
|
user = user_map[user_id]
|
|
total_score = (
|
|
stats_data['topic_count'] * 2 +
|
|
stats_data['reply_count'] +
|
|
stats_data['solution_count'] * 5
|
|
)
|
|
user_rankings.append({
|
|
'id': user_id,
|
|
'name': user.name,
|
|
'email': user.email,
|
|
'topic_count': stats_data['topic_count'],
|
|
'reply_count': stats_data['reply_count'],
|
|
'solution_count': stats_data['solution_count'],
|
|
'reaction_count': stats_data['reaction_count'],
|
|
'total_score': total_score
|
|
})
|
|
|
|
user_rankings.sort(key=lambda x: x['total_score'], reverse=True)
|
|
user_rankings = user_rankings[:20] # Top 20
|
|
|
|
# Category stats
|
|
category_stats = {}
|
|
for cat in ForumTopic.CATEGORIES:
|
|
count = db.query(func.count(ForumTopic.id)).filter(
|
|
ForumTopic.category == cat,
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
category_stats[cat] = count
|
|
|
|
# Chart data (activity per day)
|
|
chart_labels = []
|
|
chart_topics = []
|
|
chart_replies = []
|
|
|
|
current_date = start_date
|
|
while current_date <= end_date:
|
|
date_str = current_date.strftime('%d.%m')
|
|
chart_labels.append(date_str)
|
|
|
|
day_start = current_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
day_end = current_date.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
|
|
topics_count = db.query(func.count(ForumTopic.id)).filter(
|
|
ForumTopic.created_at >= day_start,
|
|
ForumTopic.created_at <= day_end,
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
chart_topics.append(topics_count)
|
|
|
|
replies_count = db.query(func.count(ForumReply.id)).filter(
|
|
ForumReply.created_at >= day_start,
|
|
ForumReply.created_at <= day_end,
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
chart_replies.append(replies_count)
|
|
|
|
current_date += timedelta(days=1)
|
|
|
|
chart_data = {
|
|
'labels': chart_labels,
|
|
'topics': chart_topics,
|
|
'replies': chart_replies
|
|
}
|
|
|
|
return render_template(
|
|
'admin/forum_analytics.html',
|
|
stats=stats,
|
|
unanswered_topics=unanswered_topics,
|
|
user_rankings=user_rankings,
|
|
category_stats=category_stats,
|
|
chart_data=chart_data,
|
|
start_date=start_date_str,
|
|
end_date=end_date_str,
|
|
category_labels=ForumTopic.CATEGORY_LABELS
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/export-activity')
|
|
@login_required
|
|
def admin_forum_export_activity():
|
|
"""Export forum activity to CSV"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
|
|
|
|
from flask import Response
|
|
from datetime import timedelta
|
|
import csv
|
|
import io
|
|
|
|
# Date range from query params
|
|
end_date_str = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d'))
|
|
start_date_str = request.args.get('start_date', (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d'))
|
|
|
|
try:
|
|
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
|
|
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
|
|
except ValueError:
|
|
end_date = datetime.now()
|
|
start_date = end_date - timedelta(days=30)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Get all topics in date range
|
|
topics = db.query(ForumTopic).filter(
|
|
ForumTopic.created_at >= start_date,
|
|
ForumTopic.created_at <= end_date
|
|
).order_by(ForumTopic.created_at.desc()).all()
|
|
|
|
# Get all replies in date range
|
|
replies = db.query(ForumReply).filter(
|
|
ForumReply.created_at >= start_date,
|
|
ForumReply.created_at <= end_date
|
|
).order_by(ForumReply.created_at.desc()).all()
|
|
|
|
# Create CSV
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
# Header
|
|
writer.writerow([
|
|
'Typ', 'ID', 'Tytul/Temat', 'Autor', 'Email', 'Kategoria',
|
|
'Status', 'Data utworzenia', 'Odpowiedzi', 'Wyswietlenia'
|
|
])
|
|
|
|
# Topics
|
|
for topic in topics:
|
|
reply_count = len([r for r in topic.replies if not r.is_deleted])
|
|
writer.writerow([
|
|
'Temat',
|
|
topic.id,
|
|
topic.title,
|
|
topic.author.name or topic.author.email.split('@')[0],
|
|
topic.author.email,
|
|
ForumTopic.CATEGORY_LABELS.get(topic.category, topic.category),
|
|
ForumTopic.STATUS_LABELS.get(topic.status, topic.status),
|
|
topic.created_at.strftime('%Y-%m-%d %H:%M'),
|
|
reply_count,
|
|
topic.views_count or 0
|
|
])
|
|
|
|
# Replies
|
|
for reply in replies:
|
|
writer.writerow([
|
|
'Odpowiedz',
|
|
reply.id,
|
|
f'Re: {reply.topic.title}' if reply.topic else f'Temat #{reply.topic_id}',
|
|
reply.author.name or reply.author.email.split('@')[0],
|
|
reply.author.email,
|
|
'',
|
|
'Rozwiazanie' if reply.is_solution else '',
|
|
reply.created_at.strftime('%Y-%m-%d %H:%M'),
|
|
'',
|
|
''
|
|
])
|
|
|
|
output.seek(0)
|
|
filename = f'forum_activity_{start_date_str}_{end_date_str}.csv'
|
|
|
|
return Response(
|
|
output.getvalue(),
|
|
mimetype='text/csv',
|
|
headers={
|
|
'Content-Disposition': f'attachment; filename={filename}',
|
|
'Content-Type': 'text/csv; charset=utf-8'
|
|
}
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/topic/<int:topic_id>/move', methods=['POST'])
|
|
@login_required
|
|
def admin_move_topic(topic_id):
|
|
"""Move topic to different category"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
new_category = data.get('category')
|
|
|
|
if not new_category or new_category not in ForumTopic.CATEGORIES:
|
|
return jsonify({'success': False, 'error': 'Nieprawidlowa kategoria'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
|
if not topic:
|
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
|
|
|
old_category = topic.category
|
|
topic.category = new_category
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} moved topic #{topic_id} from {old_category} to {new_category}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f"Temat przeniesiony do: {ForumTopic.CATEGORY_LABELS.get(new_category, new_category)}",
|
|
'old_category': old_category,
|
|
'new_category': new_category
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/merge-topics', methods=['POST'])
|
|
@login_required
|
|
def admin_merge_topics():
|
|
"""Merge multiple topics into one"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
target_id = data.get('target_id')
|
|
source_ids = data.get('source_ids', [])
|
|
|
|
if not target_id or not source_ids:
|
|
return jsonify({'success': False, 'error': 'Brak wymaganych parametrow'}), 400
|
|
|
|
# Ensure target is not in sources
|
|
source_ids = [sid for sid in source_ids if sid != target_id]
|
|
|
|
if not source_ids:
|
|
return jsonify({'success': False, 'error': 'Brak tematow do polaczenia'}), 400
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Get target topic
|
|
target = db.query(ForumTopic).filter(ForumTopic.id == target_id).first()
|
|
if not target:
|
|
return jsonify({'success': False, 'error': 'Temat docelowy nie istnieje'}), 404
|
|
|
|
# Get source topics
|
|
sources = db.query(ForumTopic).filter(ForumTopic.id.in_(source_ids)).all()
|
|
if not sources:
|
|
return jsonify({'success': False, 'error': 'Nie znaleziono tematow zrodlowych'}), 404
|
|
|
|
merged_count = 0
|
|
for source in sources:
|
|
# Create a reply from the source topic content
|
|
merge_note = ForumReply(
|
|
topic_id=target_id,
|
|
author_id=source.author_id,
|
|
content=f"[Polaczony temat: {source.title}]\n\n{source.content}",
|
|
created_at=source.created_at
|
|
)
|
|
db.add(merge_note)
|
|
|
|
# Move all replies from source to target
|
|
for reply in source.replies:
|
|
reply.topic_id = target_id
|
|
|
|
# Soft-delete the source topic
|
|
source.is_deleted = True
|
|
source.deleted_at = datetime.now()
|
|
source.deleted_by = current_user.id
|
|
|
|
merged_count += 1
|
|
|
|
# Update target topic timestamp
|
|
target.updated_at = datetime.now()
|
|
db.commit()
|
|
|
|
logger.info(f"Admin {current_user.email} merged {merged_count} topics into #{target_id}")
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f"Polaczono {merged_count} tematow",
|
|
'merged_count': merged_count,
|
|
'target_id': target_id
|
|
})
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error merging topics: {e}")
|
|
return jsonify({'success': False, 'error': 'Wystapil blad podczas laczenia'}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/search')
|
|
@login_required
|
|
def admin_forum_search():
|
|
"""Search all forum content (including deleted) - moderators only"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
|
|
|
|
query = request.args.get('q', '').strip()
|
|
include_deleted = request.args.get('deleted') == '1'
|
|
|
|
if not query or len(query) < 2:
|
|
return jsonify({
|
|
'success': True,
|
|
'results': [],
|
|
'count': 0,
|
|
'message': 'Wpisz co najmniej 2 znaki'
|
|
})
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
search_term = f'%{query}%'
|
|
results = []
|
|
|
|
# Search topics
|
|
topic_query = db.query(ForumTopic).filter(
|
|
(ForumTopic.title.ilike(search_term)) |
|
|
(ForumTopic.content.ilike(search_term))
|
|
)
|
|
|
|
if not include_deleted:
|
|
topic_query = topic_query.filter(
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
|
|
)
|
|
|
|
topics = topic_query.order_by(ForumTopic.created_at.desc()).limit(50).all()
|
|
|
|
for topic in topics:
|
|
results.append({
|
|
'type': 'topic',
|
|
'id': topic.id,
|
|
'title': topic.title,
|
|
'content_preview': topic.content[:150] + '...' if len(topic.content) > 150 else topic.content,
|
|
'author_name': topic.author.name or topic.author.email.split('@')[0],
|
|
'author_id': topic.author_id,
|
|
'created_at': topic.created_at.isoformat(),
|
|
'is_deleted': topic.is_deleted or False,
|
|
'category': topic.category,
|
|
'category_label': ForumTopic.CATEGORY_LABELS.get(topic.category, topic.category),
|
|
'url': url_for('.forum_topic', topic_id=topic.id)
|
|
})
|
|
|
|
# Search replies
|
|
reply_query = db.query(ForumReply).filter(
|
|
ForumReply.content.ilike(search_term)
|
|
)
|
|
|
|
if not include_deleted:
|
|
reply_query = reply_query.filter(
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
)
|
|
|
|
replies = reply_query.order_by(ForumReply.created_at.desc()).limit(50).all()
|
|
|
|
for reply in replies:
|
|
results.append({
|
|
'type': 'reply',
|
|
'id': reply.id,
|
|
'title': f'Re: {reply.topic.title}' if reply.topic else f'Odpowiedz #{reply.id}',
|
|
'content_preview': reply.content[:150] + '...' if len(reply.content) > 150 else reply.content,
|
|
'author_name': reply.author.name or reply.author.email.split('@')[0],
|
|
'author_id': reply.author_id,
|
|
'created_at': reply.created_at.isoformat(),
|
|
'is_deleted': reply.is_deleted or False,
|
|
'topic_id': reply.topic_id,
|
|
'url': url_for('.forum_topic', topic_id=reply.topic_id) + f'#reply-{reply.id}'
|
|
})
|
|
|
|
# Sort by date
|
|
results.sort(key=lambda x: x['created_at'], reverse=True)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'results': results[:100], # Limit to 100 total results
|
|
'count': len(results),
|
|
'query': query
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/admin/forum/user/<int:user_id>/activity')
|
|
@login_required
|
|
def admin_user_forum_activity(user_id):
|
|
"""Get detailed forum activity for a specific user - moderators only"""
|
|
if not current_user.can_moderate_forum():
|
|
return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403
|
|
|
|
from sqlalchemy import func
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Get user
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
return jsonify({'success': False, 'error': 'Uzytkownik nie istnieje'}), 404
|
|
|
|
# Get user's topics
|
|
topics = db.query(ForumTopic).filter(
|
|
ForumTopic.author_id == user_id
|
|
).order_by(ForumTopic.created_at.desc()).limit(20).all()
|
|
|
|
# Get user's replies
|
|
replies = db.query(ForumReply).filter(
|
|
ForumReply.author_id == user_id
|
|
).order_by(ForumReply.created_at.desc()).limit(30).all()
|
|
|
|
# Stats
|
|
topic_count = db.query(func.count(ForumTopic.id)).filter(
|
|
ForumTopic.author_id == user_id,
|
|
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
reply_count = db.query(func.count(ForumReply.id)).filter(
|
|
ForumReply.author_id == user_id,
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
solution_count = db.query(func.count(ForumReply.id)).filter(
|
|
ForumReply.author_id == user_id,
|
|
ForumReply.is_solution == True,
|
|
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
|
|
).scalar() or 0
|
|
|
|
# Build activity log
|
|
activity = []
|
|
|
|
for topic in topics:
|
|
activity.append({
|
|
'type': 'topic',
|
|
'action': 'Utworzyl temat',
|
|
'title': topic.title,
|
|
'id': topic.id,
|
|
'created_at': topic.created_at.isoformat(),
|
|
'is_deleted': topic.is_deleted or False,
|
|
'category': topic.category,
|
|
'url': url_for('.forum_topic', topic_id=topic.id)
|
|
})
|
|
|
|
for reply in replies:
|
|
activity.append({
|
|
'type': 'reply',
|
|
'action': 'Odpowiedzial w' if reply.is_solution else 'Odpowiedzial w',
|
|
'title': reply.topic.title if reply.topic else f'Temat #{reply.topic_id}',
|
|
'id': reply.id,
|
|
'topic_id': reply.topic_id,
|
|
'created_at': reply.created_at.isoformat(),
|
|
'is_deleted': reply.is_deleted or False,
|
|
'is_solution': reply.is_solution or False,
|
|
'url': url_for('.forum_topic', topic_id=reply.topic_id) + f'#reply-{reply.id}'
|
|
})
|
|
|
|
# Sort by date
|
|
activity.sort(key=lambda x: x['created_at'], reverse=True)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'user': {
|
|
'id': user.id,
|
|
'name': user.name or user.email.split('@')[0],
|
|
'email': user.email
|
|
},
|
|
'stats': {
|
|
'topics': topic_count,
|
|
'replies': reply_count,
|
|
'solutions': solution_count,
|
|
'total_posts': topic_count + reply_count
|
|
},
|
|
'activity': activity[:50] # Limit to 50 most recent
|
|
})
|
|
finally:
|
|
db.close()
|