nordabiz/blueprints/forum/routes.py
Maciej Pienczyn f22342ea37 feat: Add forum modernization with reactions, subscriptions, and moderation
- Add edit tracking (24h limit), soft delete, and JSONB reactions to ForumTopic/ForumReply
- Create ForumTopicSubscription, ForumReport, ForumEditHistory models
- Add 15 new API endpoints for user actions and admin moderation
- Implement reactions (👍❤️🎉), topic subscriptions, content reporting
- Add solution marking, restore deleted content, edit history for admins
- Create forum_reports.html and forum_deleted.html admin templates
- Integrate notifications for replies, reactions, solutions, and reports
- Add SQL migration 024_forum_modernization.sql

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:55:40 +01:00

1306 lines
45 KiB
Python

"""
Forum Routes
============
Forum topics, replies, and admin moderation.
"""
import logging
from datetime import datetime
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
)
from utils.helpers import sanitize_input
from utils.notifications import (
create_forum_reply_notification,
create_forum_reaction_notification,
create_forum_solution_notification,
create_forum_report_notification
)
# 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
def forum_index():
"""Forum - list of topics with category/status filters"""
page = request.args.get('page', 1, type=int)
per_page = 20
category_filter = request.args.get('category', '')
status_filter = request.args.get('status', '')
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)
# 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,
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
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:
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')
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
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 admins can view)
if topic.is_deleted and not current_user.is_admin:
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
db.commit()
# Check subscription status
is_subscribed = db.query(ForumTopicSubscription).filter(
ForumTopicSubscription.topic_id == topic_id,
ForumTopicSubscription.user_id == current_user.id
).first() is not None
# Filter soft-deleted replies for non-admins
visible_replies = [r for r in topic.replies
if not r.is_deleted or current_user.is_admin]
return render_template('forum/topic.html',
topic=topic,
visible_replies=visible_replies,
is_subscribed=is_subscribed,
category_labels=ForumTopic.CATEGORY_LABELS,
status_labels=ForumTopic.STATUS_LABELS,
available_reactions=AVAILABLE_REACTIONS)
finally:
db.close()
@bp.route('/forum/<int:topic_id>/odpowiedz', methods=['POST'])
@login_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))
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()
# Send notifications to subscribers (except the replier)
try:
subscriptions = 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 subscriptions]
if subscriber_ids:
replier_name = current_user.name or current_user.email.split('@')[0]
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}")
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.is_admin:
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.is_admin:
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.is_admin:
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.is_admin:
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
db.delete(topic) # Cascade deletes replies
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.is_admin:
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
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 (admin only)"""
if not current_user.is_admin:
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()
# ============================================================
# 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
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 admin)
if topic.author_id != current_user.id and not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
# Check time limit (unless admin)
if not current_user.is_admin 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
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 admin)
if reply.author_id != current_user.id and not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
# Check time limit (unless admin)
if not current_user.is_admin 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
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.is_admin:
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
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()
return jsonify({
'success': True,
'action': action,
'reactions': {k: len(v) for k, v in reactions.items()},
'user_reaction': reaction if action == 'added' else None
})
finally:
db.close()
@bp.route('/forum/reply/<int:reply_id>/react', methods=['POST'])
@login_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}")
return jsonify({
'success': True,
'action': action,
'reactions': {k: len(v) for k, v in reactions.items()},
'user_reaction': reaction if action == 'added' else None
})
finally:
db.close()
@bp.route('/forum/topic/<int:topic_id>/subscribe', methods=['POST'])
@login_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
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/report', methods=['POST'])
@login_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.is_admin == True, 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):
"""Admin: Edit any topic content"""
if not current_user.is_admin:
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):
"""Admin: Edit any reply content"""
if not current_user.is_admin:
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):
"""Admin: Mark reply as solution"""
if not current_user.is_admin:
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):
"""Admin: Restore soft-deleted topic"""
if not current_user.is_admin:
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):
"""Admin: Restore soft-deleted reply"""
if not current_user.is_admin:
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():
"""Admin: View all reports"""
if not current_user.is_admin:
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):
"""Admin: Review a report"""
if not current_user.is_admin:
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):
"""Admin: View topic edit history"""
if not current_user.is_admin:
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):
"""Admin: View reply edit history"""
if not current_user.is_admin:
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():
"""Admin: View soft-deleted topics and replies"""
if not current_user.is_admin:
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()