- Create blueprints/forum/ with 10 routes: - forum_index, forum_new_topic, forum_topic, forum_reply - admin_forum, admin_forum_pin, admin_forum_lock - admin_forum_delete_topic, admin_forum_delete_reply - admin_forum_change_status - Register forum blueprint with backward-compatible aliases - Remove dead code from app.py (-422 lines) - app.py: 13,820 → 13,398 lines (-3.1%) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
451 lines
15 KiB
Python
451 lines
15 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
|
|
from utils.helpers import sanitize_input
|
|
|
|
# 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
|
|
query = db.query(ForumTopic)
|
|
|
|
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)
|
|
|
|
# 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'))
|
|
|
|
# Increment view count (handle NULL)
|
|
topic.views_count = (topic.views_count or 0) + 1
|
|
db.commit()
|
|
|
|
return render_template('forum/topic.html',
|
|
topic=topic,
|
|
category_labels=ForumTopic.CATEGORY_LABELS,
|
|
status_labels=ForumTopic.STATUS_LABELS)
|
|
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()
|
|
|
|
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()
|