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>
This commit is contained in:
Maciej Pienczyn 2026-01-31 18:55:40 +01:00
parent 9dc5d58982
commit f22342ea37
9 changed files with 2375 additions and 14 deletions

View File

@ -196,6 +196,25 @@ def register_blueprints(app):
'admin_forum_delete_topic': 'forum.admin_forum_delete_topic', 'admin_forum_delete_topic': 'forum.admin_forum_delete_topic',
'admin_forum_delete_reply': 'forum.admin_forum_delete_reply', 'admin_forum_delete_reply': 'forum.admin_forum_delete_reply',
'admin_forum_change_status': 'forum.admin_forum_change_status', 'admin_forum_change_status': 'forum.admin_forum_change_status',
# New forum modernization endpoints
'edit_topic': 'forum.edit_topic',
'edit_reply': 'forum.edit_reply',
'delete_own_reply': 'forum.delete_own_reply',
'react_to_topic': 'forum.react_to_topic',
'react_to_reply': 'forum.react_to_reply',
'subscribe_to_topic': 'forum.subscribe_to_topic',
'unsubscribe_from_topic': 'forum.unsubscribe_from_topic',
'report_content': 'forum.report_content',
'admin_edit_topic': 'forum.admin_edit_topic',
'admin_edit_reply': 'forum.admin_edit_reply',
'mark_as_solution': 'forum.mark_as_solution',
'restore_topic': 'forum.restore_topic',
'restore_reply': 'forum.restore_reply',
'admin_forum_reports': 'forum.admin_forum_reports',
'review_report': 'forum.review_report',
'topic_edit_history': 'forum.topic_edit_history',
'reply_edit_history': 'forum.reply_edit_history',
'admin_deleted_content': 'forum.admin_deleted_content',
}) })
logger.info("Created forum endpoint aliases") logger.info("Created forum endpoint aliases")
except ImportError as e: except ImportError as e:

View File

@ -12,8 +12,21 @@ from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from . import bp from . import bp
from database import SessionLocal, ForumTopic, ForumReply, ForumAttachment from database import (
SessionLocal, ForumTopic, ForumReply, ForumAttachment,
ForumTopicSubscription, ForumReport, ForumEditHistory, User
)
from utils.helpers import sanitize_input 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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -42,8 +55,10 @@ def forum_index():
db = SessionLocal() db = SessionLocal()
try: try:
# Build query with optional filters # Build query with optional filters (exclude soft-deleted)
query = db.query(ForumTopic) query = db.query(ForumTopic).filter(
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
)
if category_filter and category_filter in ForumTopic.CATEGORIES: if category_filter and category_filter in ForumTopic.CATEGORIES:
query = query.filter(ForumTopic.category == category_filter) query = query.filter(ForumTopic.category == category_filter)
@ -115,6 +130,14 @@ def forum_new_topic():
db.commit() db.commit()
db.refresh(topic) 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 # Handle file upload
if FILE_UPLOAD_AVAILABLE and 'attachment' in request.files: if FILE_UPLOAD_AVAILABLE and 'attachment' in request.files:
file = request.files['attachment'] file = request.files['attachment']
@ -159,14 +182,32 @@ def forum_topic(topic_id):
flash('Temat nie istnieje.', 'error') flash('Temat nie istnieje.', 'error')
return redirect(url_for('.forum_index')) 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) # Increment view count (handle NULL)
topic.views_count = (topic.views_count or 0) + 1 topic.views_count = (topic.views_count or 0) + 1
db.commit() 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', return render_template('forum/topic.html',
topic=topic, topic=topic,
visible_replies=visible_replies,
is_subscribed=is_subscribed,
category_labels=ForumTopic.CATEGORY_LABELS, category_labels=ForumTopic.CATEGORY_LABELS,
status_labels=ForumTopic.STATUS_LABELS) status_labels=ForumTopic.STATUS_LABELS,
available_reactions=AVAILABLE_REACTIONS)
finally: finally:
db.close() db.close()
@ -243,6 +284,27 @@ def forum_reply(topic_id):
topic.updated_at = datetime.now() topic.updated_at = datetime.now()
db.commit() 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') flash('Odpowiedź dodana.', 'success')
return redirect(url_for('.forum_topic', topic_id=topic_id)) return redirect(url_for('.forum_topic', topic_id=topic_id))
finally: finally:
@ -448,3 +510,796 @@ def admin_forum_change_status(topic_id):
}) })
finally: finally:
db.close() 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()

View File

@ -228,7 +228,8 @@ class User(Base, UserMixin):
# Relationships # Relationships
conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan') conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan')
forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumTopic.author_id') forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumTopic.author_id')
forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan') forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumReply.author_id')
forum_subscriptions = relationship('ForumTopicSubscription', back_populates='user', cascade='all, delete-orphan')
def __repr__(self): def __repr__(self):
return f'<User {self.email}>' return f'<User {self.email}>'
@ -916,6 +917,19 @@ class ForumTopic(Base):
is_ai_generated = Column(Boolean, default=False) is_ai_generated = Column(Boolean, default=False)
views_count = Column(Integer, default=0) views_count = Column(Integer, default=0)
# Edit tracking
edited_at = Column(DateTime)
edited_by = Column(Integer, ForeignKey('users.id'))
edit_count = Column(Integer, default=0)
# Soft delete
is_deleted = Column(Boolean, default=False)
deleted_at = Column(DateTime)
deleted_by = Column(Integer, ForeignKey('users.id'))
# Reactions (JSONB: {"👍": [user_ids], "❤️": [user_ids], "🎉": [user_ids]})
reactions = Column(PG_JSONB, default={})
# Timestamps # Timestamps
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@ -942,9 +956,12 @@ class ForumTopic(Base):
# Relationships # Relationships
author = relationship('User', foreign_keys=[author_id], back_populates='forum_topics') author = relationship('User', foreign_keys=[author_id], back_populates='forum_topics')
status_changer = relationship('User', foreign_keys=[status_changed_by]) status_changer = relationship('User', foreign_keys=[status_changed_by])
editor = relationship('User', foreign_keys=[edited_by])
deleter = relationship('User', foreign_keys=[deleted_by])
replies = relationship('ForumReply', back_populates='topic', cascade='all, delete-orphan', order_by='ForumReply.created_at') replies = relationship('ForumReply', back_populates='topic', cascade='all, delete-orphan', order_by='ForumReply.created_at')
attachments = relationship('ForumAttachment', back_populates='topic', cascade='all, delete-orphan', attachments = relationship('ForumAttachment', back_populates='topic', cascade='all, delete-orphan',
primaryjoin="and_(ForumAttachment.topic_id==ForumTopic.id, ForumAttachment.attachment_type=='topic')") primaryjoin="and_(ForumAttachment.topic_id==ForumTopic.id, ForumAttachment.attachment_type=='topic')")
subscriptions = relationship('ForumTopicSubscription', back_populates='topic', cascade='all, delete-orphan')
@property @property
def reply_count(self): def reply_count(self):
@ -975,13 +992,34 @@ class ForumReply(Base):
content = Column(Text, nullable=False) content = Column(Text, nullable=False)
is_ai_generated = Column(Boolean, default=False) is_ai_generated = Column(Boolean, default=False)
# Edit tracking
edited_at = Column(DateTime)
edited_by = Column(Integer, ForeignKey('users.id'))
edit_count = Column(Integer, default=0)
# Soft delete
is_deleted = Column(Boolean, default=False)
deleted_at = Column(DateTime)
deleted_by = Column(Integer, ForeignKey('users.id'))
# Reactions (JSONB: {"👍": [user_ids], "❤️": [user_ids], "🎉": [user_ids]})
reactions = Column(PG_JSONB, default={})
# Solution marking
is_solution = Column(Boolean, default=False)
marked_as_solution_by = Column(Integer, ForeignKey('users.id'))
marked_as_solution_at = Column(DateTime)
# Timestamps # Timestamps
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships # Relationships
topic = relationship('ForumTopic', back_populates='replies') topic = relationship('ForumTopic', back_populates='replies')
author = relationship('User', back_populates='forum_replies') author = relationship('User', foreign_keys=[author_id], back_populates='forum_replies')
editor = relationship('User', foreign_keys=[edited_by])
deleter = relationship('User', foreign_keys=[deleted_by])
solution_marker = relationship('User', foreign_keys=[marked_as_solution_by])
attachments = relationship('ForumAttachment', back_populates='reply', cascade='all, delete-orphan', attachments = relationship('ForumAttachment', back_populates='reply', cascade='all, delete-orphan',
primaryjoin="and_(ForumAttachment.reply_id==ForumReply.id, ForumAttachment.attachment_type=='reply')") primaryjoin="and_(ForumAttachment.reply_id==ForumReply.id, ForumAttachment.attachment_type=='reply')")
@ -1042,6 +1080,91 @@ class ForumAttachment(Base):
return f"{self.file_size / (1024 * 1024):.1f} MB" return f"{self.file_size / (1024 * 1024):.1f} MB"
class ForumTopicSubscription(Base):
"""Forum topic subscriptions for notifications"""
__tablename__ = 'forum_topic_subscriptions'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'), nullable=False)
notify_email = Column(Boolean, default=True)
notify_app = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (UniqueConstraint('user_id', 'topic_id', name='uq_forum_subscription_user_topic'),)
# Relationships
user = relationship('User', back_populates='forum_subscriptions')
topic = relationship('ForumTopic', back_populates='subscriptions')
class ForumReport(Base):
"""Forum content reports for moderation"""
__tablename__ = 'forum_reports'
id = Column(Integer, primary_key=True)
reporter_id = Column(Integer, ForeignKey('users.id'), nullable=False)
# Polymorphic relationship (topic or reply)
content_type = Column(String(20), nullable=False) # 'topic' or 'reply'
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'))
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'))
reason = Column(String(50), nullable=False) # spam, offensive, off-topic, other
description = Column(Text)
status = Column(String(20), default='pending') # pending, reviewed, dismissed
reviewed_by = Column(Integer, ForeignKey('users.id'))
reviewed_at = Column(DateTime)
review_note = Column(Text)
created_at = Column(DateTime, default=datetime.now)
# Constants
REASONS = ['spam', 'offensive', 'off-topic', 'other']
REASON_LABELS = {
'spam': 'Spam',
'offensive': 'Obraźliwe treści',
'off-topic': 'Nie na temat',
'other': 'Inne'
}
STATUSES = ['pending', 'reviewed', 'dismissed']
# Relationships
reporter = relationship('User', foreign_keys=[reporter_id])
reviewer = relationship('User', foreign_keys=[reviewed_by])
topic = relationship('ForumTopic')
reply = relationship('ForumReply')
@property
def reason_label(self):
return self.REASON_LABELS.get(self.reason, self.reason)
class ForumEditHistory(Base):
"""Forum edit history for audit trail"""
__tablename__ = 'forum_edit_history'
id = Column(Integer, primary_key=True)
# Polymorphic relationship (topic or reply)
content_type = Column(String(20), nullable=False) # 'topic' or 'reply'
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'))
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'))
editor_id = Column(Integer, ForeignKey('users.id'), nullable=False)
old_content = Column(Text, nullable=False)
new_content = Column(Text, nullable=False)
edit_reason = Column(String(255))
created_at = Column(DateTime, default=datetime.now)
# Relationships
editor = relationship('User')
topic = relationship('ForumTopic')
reply = relationship('ForumReply')
class AIAPICostLog(Base): class AIAPICostLog(Base):
"""API cost tracking""" """API cost tracking"""
__tablename__ = 'ai_api_costs' __tablename__ = 'ai_api_costs'

View File

@ -0,0 +1,168 @@
-- Forum Modernization Migration
-- Author: Claude Code
-- Date: 2026-01-31
-- Description: Adds edit tracking, soft delete, reactions, subscriptions, reports, and edit history
-- ============================================================
-- PHASE 1: Add new columns to forum_topics
-- ============================================================
-- Edit tracking
ALTER TABLE forum_topics ADD COLUMN IF NOT EXISTS edited_at TIMESTAMP;
ALTER TABLE forum_topics ADD COLUMN IF NOT EXISTS edited_by INTEGER REFERENCES users(id);
ALTER TABLE forum_topics ADD COLUMN IF NOT EXISTS edit_count INTEGER DEFAULT 0;
-- Soft delete
ALTER TABLE forum_topics ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE forum_topics ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
ALTER TABLE forum_topics ADD COLUMN IF NOT EXISTS deleted_by INTEGER REFERENCES users(id);
-- Reactions (JSONB)
ALTER TABLE forum_topics ADD COLUMN IF NOT EXISTS reactions JSONB DEFAULT '{}';
-- ============================================================
-- PHASE 2: Add new columns to forum_replies
-- ============================================================
-- Edit tracking
ALTER TABLE forum_replies ADD COLUMN IF NOT EXISTS edited_at TIMESTAMP;
ALTER TABLE forum_replies ADD COLUMN IF NOT EXISTS edited_by INTEGER REFERENCES users(id);
ALTER TABLE forum_replies ADD COLUMN IF NOT EXISTS edit_count INTEGER DEFAULT 0;
-- Soft delete
ALTER TABLE forum_replies ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE forum_replies ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
ALTER TABLE forum_replies ADD COLUMN IF NOT EXISTS deleted_by INTEGER REFERENCES users(id);
-- Reactions (JSONB)
ALTER TABLE forum_replies ADD COLUMN IF NOT EXISTS reactions JSONB DEFAULT '{}';
-- Solution marking
ALTER TABLE forum_replies ADD COLUMN IF NOT EXISTS is_solution BOOLEAN DEFAULT FALSE;
ALTER TABLE forum_replies ADD COLUMN IF NOT EXISTS marked_as_solution_by INTEGER REFERENCES users(id);
ALTER TABLE forum_replies ADD COLUMN IF NOT EXISTS marked_as_solution_at TIMESTAMP;
-- ============================================================
-- PHASE 3: Create forum_topic_subscriptions table
-- ============================================================
CREATE TABLE IF NOT EXISTS forum_topic_subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
topic_id INTEGER NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
notify_email BOOLEAN DEFAULT TRUE,
notify_app BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_forum_subscription_user_topic UNIQUE (user_id, topic_id)
);
-- Indexes for subscriptions
CREATE INDEX IF NOT EXISTS idx_forum_subscriptions_user ON forum_topic_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_forum_subscriptions_topic ON forum_topic_subscriptions(topic_id);
-- ============================================================
-- PHASE 4: Create forum_reports table
-- ============================================================
CREATE TABLE IF NOT EXISTS forum_reports (
id SERIAL PRIMARY KEY,
reporter_id INTEGER NOT NULL REFERENCES users(id),
-- Polymorphic relationship
content_type VARCHAR(20) NOT NULL CHECK (content_type IN ('topic', 'reply')),
topic_id INTEGER REFERENCES forum_topics(id) ON DELETE CASCADE,
reply_id INTEGER REFERENCES forum_replies(id) ON DELETE CASCADE,
reason VARCHAR(50) NOT NULL CHECK (reason IN ('spam', 'offensive', 'off-topic', 'other')),
description TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'dismissed')),
reviewed_by INTEGER REFERENCES users(id),
reviewed_at TIMESTAMP,
review_note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Ensure at least one of topic_id or reply_id is set
CONSTRAINT chk_forum_report_content CHECK (
(content_type = 'topic' AND topic_id IS NOT NULL AND reply_id IS NULL) OR
(content_type = 'reply' AND reply_id IS NOT NULL)
)
);
-- Indexes for reports
CREATE INDEX IF NOT EXISTS idx_forum_reports_status ON forum_reports(status);
CREATE INDEX IF NOT EXISTS idx_forum_reports_reporter ON forum_reports(reporter_id);
CREATE INDEX IF NOT EXISTS idx_forum_reports_topic ON forum_reports(topic_id);
CREATE INDEX IF NOT EXISTS idx_forum_reports_reply ON forum_reports(reply_id);
-- ============================================================
-- PHASE 5: Create forum_edit_history table
-- ============================================================
CREATE TABLE IF NOT EXISTS forum_edit_history (
id SERIAL PRIMARY KEY,
-- Polymorphic relationship
content_type VARCHAR(20) NOT NULL CHECK (content_type IN ('topic', 'reply')),
topic_id INTEGER REFERENCES forum_topics(id) ON DELETE CASCADE,
reply_id INTEGER REFERENCES forum_replies(id) ON DELETE CASCADE,
editor_id INTEGER NOT NULL REFERENCES users(id),
old_content TEXT NOT NULL,
new_content TEXT NOT NULL,
edit_reason VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Ensure at least one of topic_id or reply_id is set
CONSTRAINT chk_forum_edit_history_content CHECK (
(content_type = 'topic' AND topic_id IS NOT NULL AND reply_id IS NULL) OR
(content_type = 'reply' AND reply_id IS NOT NULL)
)
);
-- Indexes for edit history
CREATE INDEX IF NOT EXISTS idx_forum_edit_history_topic ON forum_edit_history(topic_id);
CREATE INDEX IF NOT EXISTS idx_forum_edit_history_reply ON forum_edit_history(reply_id);
CREATE INDEX IF NOT EXISTS idx_forum_edit_history_editor ON forum_edit_history(editor_id);
-- ============================================================
-- PHASE 6: Additional indexes for performance
-- ============================================================
-- Index for soft-deleted topics (admin queries)
CREATE INDEX IF NOT EXISTS idx_forum_topics_is_deleted ON forum_topics(is_deleted) WHERE is_deleted = TRUE;
-- Index for soft-deleted replies (admin queries)
CREATE INDEX IF NOT EXISTS idx_forum_replies_is_deleted ON forum_replies(is_deleted) WHERE is_deleted = TRUE;
-- Index for solution replies
CREATE INDEX IF NOT EXISTS idx_forum_replies_is_solution ON forum_replies(is_solution) WHERE is_solution = TRUE;
-- ============================================================
-- PHASE 7: Grant permissions
-- ============================================================
GRANT ALL ON TABLE forum_topic_subscriptions TO nordabiz_app;
GRANT ALL ON TABLE forum_reports TO nordabiz_app;
GRANT ALL ON TABLE forum_edit_history TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE forum_topic_subscriptions_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE forum_reports_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE forum_edit_history_id_seq TO nordabiz_app;
-- ============================================================
-- VERIFICATION QUERIES
-- ============================================================
-- Verify new columns in forum_topics
-- SELECT column_name, data_type FROM information_schema.columns
-- WHERE table_name = 'forum_topics' AND column_name IN ('edited_at', 'edited_by', 'edit_count', 'is_deleted', 'deleted_at', 'deleted_by', 'reactions');
-- Verify new columns in forum_replies
-- SELECT column_name, data_type FROM information_schema.columns
-- WHERE table_name = 'forum_replies' AND column_name IN ('edited_at', 'edited_by', 'edit_count', 'is_deleted', 'deleted_at', 'deleted_by', 'reactions', 'is_solution', 'marked_as_solution_by', 'marked_as_solution_at');
-- Verify new tables
-- SELECT table_name FROM information_schema.tables WHERE table_name IN ('forum_topic_subscriptions', 'forum_reports', 'forum_edit_history');

View File

@ -348,7 +348,19 @@
{% block content %} {% block content %}
<div class="admin-header"> <div class="admin-header">
<h1>Moderacja Forum</h1> <h1>Moderacja Forum</h1>
<p class="text-muted">Zarzadzaj tematami i odpowiedziami na forum</p> <p class="text-muted">Zarządzaj tematami i odpowiedziami na forum</p>
</div>
<!-- Quick Actions -->
<div style="display: flex; gap: var(--spacing-md); margin-bottom: var(--spacing-xl);">
<a href="{{ url_for('admin_forum_reports') }}" class="btn btn-outline" style="display: inline-flex; align-items: center; gap: var(--spacing-xs);">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"/></svg>
Zgłoszenia
</a>
<a href="{{ url_for('admin_deleted_content') }}" class="btn btn-outline" style="display: inline-flex; align-items: center; gap: var(--spacing-xs);">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Usunięte treści
</a>
</div> </div>
<!-- Stats Grid --> <!-- Stats Grid -->

View File

@ -0,0 +1,230 @@
{% extends "base.html" %}
{% block title %}Usunięte Treści Forum - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-lg);
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.deleted-item {
padding: var(--spacing-lg);
border: 1px dashed #fecaca;
border-radius: var(--radius);
background: #fef2f2;
margin-bottom: var(--spacing-md);
}
.deleted-item:last-child {
margin-bottom: 0;
}
.deleted-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-md);
}
.deleted-meta {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.deleted-content {
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.deleted-info {
font-size: var(--font-size-sm);
color: #dc2626;
font-style: italic;
}
.btn-restore {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
cursor: pointer;
border: 1px solid #86efac;
background: #dcfce7;
color: #166534;
transition: all 0.2s;
}
.btn-restore:hover {
background: #bbf7d0;
}
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
.back-link {
margin-bottom: var(--spacing-lg);
}
.back-link a {
color: var(--text-secondary);
text-decoration: none;
}
.back-link a:hover {
color: var(--primary);
}
</style>
{% endblock %}
{% block content %}
<div class="back-link">
<a href="{{ url_for('admin_forum') }}">&larr; Powrót do moderacji forum</a>
</div>
<div class="admin-header">
<h1>Usunięte Treści Forum</h1>
</div>
<div class="section">
<h2>Usunięte Tematy ({{ deleted_topics|length }})</h2>
{% if deleted_topics %}
{% for topic in deleted_topics %}
<div class="deleted-item" id="topic-{{ topic.id }}">
<div class="deleted-header">
<div>
<strong>{{ topic.title }}</strong>
<div class="deleted-meta">
Autor: {{ topic.author.name or topic.author.email.split('@')[0] }}
&bull; Utworzono: {{ topic.created_at.strftime('%d.%m.%Y %H:%M') }}
</div>
</div>
<button class="btn-restore" onclick="restoreTopic({{ topic.id }})">
↩ Przywróć
</button>
</div>
<div class="deleted-content">
{{ topic.content[:300] }}{% if topic.content|length > 300 %}...{% endif %}
</div>
<div class="deleted-info">
Usunięto: {{ topic.deleted_at.strftime('%d.%m.%Y %H:%M') if topic.deleted_at else 'brak daty' }}
{% if topic.deleter %}przez {{ topic.deleter.name or topic.deleter.email.split('@')[0] }}{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
Brak usuniętych tematów.
</div>
{% endif %}
</div>
<div class="section">
<h2>Usunięte Odpowiedzi ({{ deleted_replies|length }})</h2>
{% if deleted_replies %}
{% for reply in deleted_replies %}
<div class="deleted-item" id="reply-{{ reply.id }}">
<div class="deleted-header">
<div>
<div class="deleted-meta">
W temacie: <a href="{{ url_for('forum_topic', topic_id=reply.topic_id) }}" target="_blank">{{ reply.topic.title }}</a>
<br>Autor: {{ reply.author.name or reply.author.email.split('@')[0] }}
&bull; Utworzono: {{ reply.created_at.strftime('%d.%m.%Y %H:%M') }}
</div>
</div>
<button class="btn-restore" onclick="restoreReply({{ reply.id }})">
↩ Przywróć
</button>
</div>
<div class="deleted-content">
{{ reply.content[:300] }}{% if reply.content|length > 300 %}...{% endif %}
</div>
<div class="deleted-info">
Usunięto: {{ reply.deleted_at.strftime('%d.%m.%Y %H:%M') if reply.deleted_at else 'brak daty' }}
{% if reply.deleter %}przez {{ reply.deleter.name or reply.deleter.email.split('@')[0] }}{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
Brak usuniętych odpowiedzi.
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
function restoreTopic(topicId) {
if (!confirm('Przywrócić ten temat?')) return;
fetch(`/admin/forum/topic/${topicId}/restore`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('topic-' + topicId).remove();
} else {
alert(data.error || 'Błąd');
}
})
.catch(err => {
alert('Błąd połączenia');
});
}
function restoreReply(replyId) {
if (!confirm('Przywrócić tę odpowiedź?')) return;
fetch(`/admin/forum/reply/${replyId}/restore`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('reply-' + replyId).remove();
} else {
alert(data.error || 'Błąd');
}
})
.catch(err => {
alert('Błąd połączenia');
});
}
{% endblock %}

View File

@ -0,0 +1,289 @@
{% extends "base.html" %}
{% block title %}Zgłoszenia Forum - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.stats-bar {
display: flex;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-pill {
background: var(--surface);
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: 20px;
box-shadow: var(--shadow);
font-size: var(--font-size-sm);
}
.stat-pill.active {
background: var(--primary);
color: white;
}
.stat-pill .count {
font-weight: 700;
margin-left: var(--spacing-xs);
}
.reports-list {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.report-card {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border);
}
.report-card:last-child {
border-bottom: none;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-md);
}
.report-meta {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.report-reason {
display: inline-block;
padding: 4px 10px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
}
.reason-spam { background: #fef2f2; color: #991b1b; }
.reason-offensive { background: #fef3c7; color: #92400e; }
.reason-off-topic { background: #eff6ff; color: #1e40af; }
.reason-other { background: #f5f5f5; color: #525252; }
.report-content {
background: var(--background);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
}
.report-content .label {
font-weight: 600;
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.report-actions {
display: flex;
gap: var(--spacing-sm);
}
.btn-review {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
cursor: pointer;
border: 1px solid;
transition: all 0.2s;
}
.btn-review.accept {
background: #dcfce7;
color: #166534;
border-color: #86efac;
}
.btn-review.accept:hover {
background: #bbf7d0;
}
.btn-review.dismiss {
background: #fee2e2;
color: #991b1b;
border-color: #fecaca;
}
.btn-review.dismiss:hover {
background: #fecaca;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
.nav-tabs {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
}
.nav-tabs a {
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius);
text-decoration: none;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.nav-tabs a.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.back-link {
margin-bottom: var(--spacing-lg);
}
.back-link a {
color: var(--text-secondary);
text-decoration: none;
}
.back-link a:hover {
color: var(--primary);
}
</style>
{% endblock %}
{% block content %}
<div class="back-link">
<a href="{{ url_for('admin_forum') }}">&larr; Powrót do moderacji forum</a>
</div>
<div class="admin-header">
<h1>Zgłoszenia Forum</h1>
</div>
<div class="nav-tabs">
<a href="{{ url_for('admin_forum_reports', status='pending') }}" class="{% if status_filter == 'pending' %}active{% endif %}">
Oczekujące ({{ pending_count }})
</a>
<a href="{{ url_for('admin_forum_reports', status='reviewed') }}" class="{% if status_filter == 'reviewed' %}active{% endif %}">
Rozpatrzone ({{ reviewed_count }})
</a>
<a href="{{ url_for('admin_forum_reports', status='dismissed') }}" class="{% if status_filter == 'dismissed' %}active{% endif %}">
Odrzucone ({{ dismissed_count }})
</a>
</div>
{% if reports %}
<div class="reports-list">
{% for report in reports %}
<div class="report-card" id="report-{{ report.id }}">
<div class="report-header">
<div>
<span class="report-reason reason-{{ report.reason }}">{{ reason_labels.get(report.reason, report.reason) }}</span>
<span class="report-meta">
Zgłoszone przez {{ report.reporter.name or report.reporter.email.split('@')[0] }}
&bull; {{ report.created_at.strftime('%d.%m.%Y %H:%M') }}
</span>
</div>
<span class="report-meta">
{{ report.content_type|capitalize }} #{{ report.topic_id or report.reply_id }}
</span>
</div>
{% if report.description %}
<div class="report-content">
<div class="label">Opis zgłoszenia:</div>
{{ report.description }}
</div>
{% endif %}
<div class="report-content">
<div class="label">Zgłoszona treść:</div>
{% if report.content_type == 'topic' and report.topic %}
<strong>{{ report.topic.title }}</strong><br>
{{ report.topic.content[:300] }}{% if report.topic.content|length > 300 %}...{% endif %}
<br><a href="{{ url_for('forum_topic', topic_id=report.topic.id) }}" target="_blank">Zobacz temat &rarr;</a>
{% elif report.content_type == 'reply' and report.reply %}
{{ report.reply.content[:300] }}{% if report.reply.content|length > 300 %}...{% endif %}
<br><a href="{{ url_for('forum_topic', topic_id=report.reply.topic_id) }}#reply-{{ report.reply.id }}" target="_blank">Zobacz odpowiedź &rarr;</a>
{% else %}
<em>Treść niedostępna</em>
{% endif %}
</div>
{% if report.status == 'pending' %}
<div class="report-actions">
<button class="btn-review accept" onclick="reviewReport({{ report.id }}, 'reviewed')">
✓ Rozpatrzone
</button>
<button class="btn-review dismiss" onclick="reviewReport({{ report.id }}, 'dismissed')">
✕ Odrzuć
</button>
</div>
{% else %}
<div class="report-meta">
{% if report.reviewed_by %}
Rozpatrzone przez {{ report.reviewer.name or report.reviewer.email.split('@')[0] }}
&bull; {{ report.reviewed_at.strftime('%d.%m.%Y %H:%M') }}
{% endif %}
{% if report.review_note %}
<br>Notatka: {{ report.review_note }}
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="reports-list">
<div class="empty-state">
Brak zgłoszeń w tej kategorii.
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
function reviewReport(reportId, status) {
const note = status === 'dismissed' ? prompt('Opcjonalna notatka (powód odrzucenia):') : '';
fetch(`/admin/forum/report/${reportId}/review`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ status: status, note: note || '' })
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('report-' + reportId).remove();
location.reload();
} else {
alert(data.error || 'Błąd');
}
})
.catch(err => {
alert('Błąd połączenia');
});
}
{% endblock %}

View File

@ -609,6 +609,203 @@
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
/* User actions */
.user-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
padding-top: var(--spacing-md);
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
transition: all 0.2s;
}
.action-btn:hover {
background: var(--background);
color: var(--text-primary);
}
.action-btn svg {
width: 14px;
height: 14px;
}
.action-btn.danger {
color: #dc2626;
}
.action-btn.danger:hover {
background: #fef2f2;
border-color: #fecaca;
}
/* Reactions bar */
.reactions-bar {
display: flex;
gap: var(--spacing-xs);
margin-top: var(--spacing-md);
}
.reaction-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 20px;
font-size: var(--font-size-sm);
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
transition: all 0.2s;
}
.reaction-btn:hover {
background: var(--background);
transform: scale(1.05);
}
.reaction-btn.active {
background: #eff6ff;
border-color: #3b82f6;
}
.reaction-btn .count {
font-weight: 600;
color: var(--text-secondary);
}
/* Subscribe button */
.subscribe-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
transition: all 0.2s;
}
.subscribe-btn:hover {
background: var(--background);
}
.subscribe-btn.subscribed {
background: #dcfce7;
border-color: #86efac;
color: #166534;
}
/* Edited badge */
.edited-badge {
font-size: var(--font-size-xs);
color: var(--text-muted);
font-style: italic;
}
/* Solution badge */
.solution-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #dcfce7;
color: #166534;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
}
/* Deleted overlay */
.reply-card.deleted {
opacity: 0.6;
background: #fef2f2;
border: 1px dashed #fecaca;
}
.deleted-notice {
color: #dc2626;
font-style: italic;
font-size: var(--font-size-sm);
}
/* Edit/Report modal */
.form-modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
justify-content: center;
align-items: center;
}
.form-modal-overlay.active {
display: flex;
}
.form-modal {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
}
.form-modal h3 {
margin-bottom: var(--spacing-md);
}
.form-modal textarea {
width: 100%;
min-height: 150px;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
resize: vertical;
margin-bottom: var(--spacing-md);
}
.form-modal select {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
margin-bottom: var(--spacing-md);
}
.form-modal .modal-actions {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
}
</style> </style>
{% endblock %} {% endblock %}
@ -682,6 +879,9 @@
<polyline points="12 6 12 12 16 14"></polyline> <polyline points="12 6 12 12 16 14"></polyline>
</svg> </svg>
{{ topic.created_at.strftime('%d.%m.%Y %H:%M') }} {{ topic.created_at.strftime('%d.%m.%Y %H:%M') }}
{% if topic.edited_at %}
<span class="edited-badge">(edytowano {{ topic.edited_at.strftime('%d.%m.%Y %H:%M') }})</span>
{% endif %}
</span> </span>
<span> <span>
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
@ -690,9 +890,40 @@
</svg> </svg>
{{ topic.views_count }} wyświetleń {{ topic.views_count }} wyświetleń
</span> </span>
<button type="button" class="subscribe-btn {% if is_subscribed %}subscribed{% endif %}" id="subscribeBtn" onclick="toggleSubscribe({{ topic.id }})">
{% if is_subscribed %}🔔 Obserwujesz{% else %}🔕 Obserwuj{% endif %}
</button>
</div> </div>
<div class="topic-content">{{ topic.content }}</div> <div class="topic-content" id="topicContent">{{ topic.content }}</div>
<!-- Reactions bar for topic -->
<div class="reactions-bar" id="topicReactions" data-content-type="topic" data-content-id="{{ topic.id }}">
{% set topic_reactions = topic.reactions or {} %}
{% for emoji in available_reactions %}
{% set count = (topic_reactions.get(emoji, [])|length) %}
{% set user_reacted = current_user.id in (topic_reactions.get(emoji, [])) %}
<button type="button" class="reaction-btn {% if user_reacted %}active{% endif %}" onclick="toggleReaction('topic', {{ topic.id }}, '{{ emoji }}')">
{{ emoji }} <span class="count">{{ count }}</span>
</button>
{% endfor %}
</div>
<!-- User actions for topic -->
{% if not topic.is_locked %}
<div class="user-actions">
{% if topic.author_id == current_user.id or current_user.is_admin %}
<button type="button" class="action-btn" onclick="openEditModal('topic', {{ topic.id }}, document.getElementById('topicContent').innerText)">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edytuj
</button>
{% endif %}
<button type="button" class="action-btn" onclick="openReportModal('topic', {{ topic.id }})">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"/></svg>
Zgłoś
</button>
</div>
{% endif %}
{% if topic.attachments %} {% if topic.attachments %}
{% for attachment in topic.attachments %} {% for attachment in topic.attachments %}
@ -711,13 +942,13 @@
<section class="replies-section"> <section class="replies-section">
<h2 class="replies-header"> <h2 class="replies-header">
Odpowiedzi ({{ topic.replies|length }}) Odpowiedzi ({{ visible_replies|length }})
</h2> </h2>
{% if topic.replies %} {% if visible_replies %}
<div class="replies-list"> <div class="replies-list">
{% for reply in topic.replies %} {% for reply in visible_replies %}
<article class="reply-card"> <article class="reply-card {% if reply.is_deleted %}deleted{% endif %}" id="reply-{{ reply.id }}">
<div class="reply-header"> <div class="reply-header">
<div class="reply-author"> <div class="reply-author">
<div class="reply-avatar"> <div class="reply-avatar">
@ -730,19 +961,40 @@
AI AI
</span> </span>
{% endif %} {% endif %}
{% if reply.is_solution %}
<span class="solution-badge" title="Oznaczone jako rozwiązanie">✓ Rozwiązanie</span>
{% endif %}
</div> </div>
<span>{{ reply.created_at.strftime('%d.%m.%Y %H:%M') }}</span> <span>
{{ reply.created_at.strftime('%d.%m.%Y %H:%M') }}
{% if reply.edited_at %}
<span class="edited-badge">(edytowano)</span>
{% endif %}
</span>
{% if current_user.is_authenticated and current_user.is_admin %} {% if current_user.is_authenticated and current_user.is_admin %}
<div class="reply-admin-actions"> <div class="reply-admin-actions">
<button type="button" class="admin-btn admin-btn-sm" onclick="toggleSolution({{ reply.id }})" title="{% if reply.is_solution %}Usuń oznaczenie{% else %}Oznacz jako rozwiązanie{% endif %}">
</button>
<button type="button" class="admin-btn admin-btn-sm" onclick="openEditModal('reply', {{ reply.id }}, document.querySelector('#reply-{{ reply.id }} .reply-content').innerText)">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
{% if reply.is_deleted %}
<button type="button" class="admin-btn admin-btn-sm" onclick="restoreReply({{ reply.id }})" title="Przywróć"></button>
{% else %}
<button type="button" class="admin-btn admin-btn-delete admin-btn-sm" onclick="deleteReply({{ reply.id }})" title="Usuń odpowiedź"> <button type="button" class="admin-btn admin-btn-delete admin-btn-sm" onclick="deleteReply({{ reply.id }})" title="Usuń odpowiedź">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/> <path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg> </svg>
Usuń
</button> </button>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if reply.is_deleted %}
<div class="reply-content deleted-notice">[Ta odpowiedź została usunięta]</div>
{% else %}
<div class="reply-content">{{ reply.content }}</div> <div class="reply-content">{{ reply.content }}</div>
{% if reply.attachments %} {% if reply.attachments %}
@ -761,6 +1013,39 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Reactions bar for reply -->
<div class="reactions-bar" data-content-type="reply" data-content-id="{{ reply.id }}">
{% set reply_reactions = reply.reactions or {} %}
{% for emoji in available_reactions %}
{% set count = (reply_reactions.get(emoji, [])|length) %}
{% set user_reacted = current_user.id in (reply_reactions.get(emoji, [])) %}
<button type="button" class="reaction-btn {% if user_reacted %}active{% endif %}" onclick="toggleReaction('reply', {{ reply.id }}, '{{ emoji }}')">
{{ emoji }} <span class="count">{{ count }}</span>
</button>
{% endfor %}
</div>
<!-- User actions for reply -->
{% if not topic.is_locked %}
<div class="user-actions">
{% if reply.author_id == current_user.id %}
<button type="button" class="action-btn" onclick="openEditModal('reply', {{ reply.id }}, document.querySelector('#reply-{{ reply.id }} .reply-content').innerText)">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edytuj
</button>
<button type="button" class="action-btn danger" onclick="deleteOwnReply({{ reply.id }})">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Usuń
</button>
{% endif %}
<button type="button" class="action-btn" onclick="openReportModal('reply', {{ reply.id }})">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"/></svg>
Zgłoś
</button>
</div>
{% endif %}
{% endif %}
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
@ -817,6 +1102,41 @@
</div> </div>
</div> </div>
<!-- Edit modal -->
<div class="form-modal-overlay" id="editModal">
<div class="form-modal">
<h3>Edytuj <span id="editType">treść</span></h3>
<textarea id="editContent" placeholder="Edytuj treść..."></textarea>
<input type="hidden" id="editContentType" value="">
<input type="hidden" id="editContentId" value="">
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeEditModal()">Anuluj</button>
<button type="button" class="btn btn-primary" onclick="saveEdit()">Zapisz</button>
</div>
</div>
</div>
<!-- Report modal -->
<div class="form-modal-overlay" id="reportModal">
<div class="form-modal">
<h3>Zgłoś treść</h3>
<select id="reportReason">
<option value="">Wybierz powód...</option>
<option value="spam">Spam</option>
<option value="offensive">Obraźliwe treści</option>
<option value="off-topic">Nie na temat</option>
<option value="other">Inne</option>
</select>
<textarea id="reportDescription" placeholder="Opcjonalny opis zgłoszenia..."></textarea>
<input type="hidden" id="reportContentType" value="">
<input type="hidden" id="reportContentId" value="">
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeReportModal()">Anuluj</button>
<button type="button" class="btn btn-primary" onclick="submitReport()">Wyślij zgłoszenie</button>
</div>
</div>
</div>
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div> <div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<style> <style>
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: toastIn 0.3s ease; } .toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: toastIn 0.3s ease; }
@ -1067,6 +1387,250 @@
}); });
} }
// ============================================================
// USER ACTIONS: Subscribe, Reactions, Edit, Delete, Report
// ============================================================
function toggleSubscribe(topicId) {
const btn = document.getElementById('subscribeBtn');
const isSubscribed = btn.classList.contains('subscribed');
const endpoint = isSubscribed ? 'unsubscribe' : 'subscribe';
fetch(`/forum/topic/${topicId}/${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
btn.classList.toggle('subscribed');
btn.innerHTML = btn.classList.contains('subscribed') ? '🔔 Obserwujesz' : '🔕 Obserwuj';
showToast(data.message, 'success');
} else {
showToast(data.error || 'Błąd', 'error');
}
})
.catch(err => {
showToast('Błąd połączenia', 'error');
});
}
function toggleReaction(contentType, contentId, emoji) {
fetch(`/forum/${contentType}/${contentId}/react`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ reaction: emoji })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Update UI
const container = document.querySelector(`[data-content-type="${contentType}"][data-content-id="${contentId}"]`);
if (container) {
container.querySelectorAll('.reaction-btn').forEach(btn => {
const btnEmoji = btn.textContent.trim().split(' ')[0];
const count = data.reactions[btnEmoji] || 0;
btn.querySelector('.count').textContent = count;
btn.classList.toggle('active', btnEmoji === data.user_reaction);
});
}
} else {
showToast(data.error || 'Błąd', 'error');
}
})
.catch(err => {
showToast('Błąd połączenia', 'error');
});
}
// Edit modal functions
function openEditModal(contentType, contentId, currentContent) {
document.getElementById('editContentType').value = contentType;
document.getElementById('editContentId').value = contentId;
document.getElementById('editContent').value = currentContent;
document.getElementById('editType').textContent = contentType === 'topic' ? 'temat' : 'odpowiedź';
document.getElementById('editModal').classList.add('active');
}
function closeEditModal() {
document.getElementById('editModal').classList.remove('active');
}
function saveEdit() {
const contentType = document.getElementById('editContentType').value;
const contentId = document.getElementById('editContentId').value;
const newContent = document.getElementById('editContent').value.trim();
if (!newContent) {
showToast('Treść nie może być pusta', 'error');
return;
}
fetch(`/forum/${contentType}/${contentId}/edit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ content: newContent })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
closeEditModal();
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Błąd edycji', 'error');
}
})
.catch(err => {
showToast('Błąd połączenia', 'error');
});
}
// User delete own reply
function deleteOwnReply(replyId) {
showConfirmModal(
'Usuń odpowiedź',
'Czy na pewno chcesz usunąć swoją odpowiedź?',
'',
function() {
fetch(`/forum/reply/${replyId}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Błąd usuwania', 'error');
}
})
.catch(err => {
showToast('Błąd połączenia', 'error');
});
}
);
}
// Report modal functions
function openReportModal(contentType, contentId) {
document.getElementById('reportContentType').value = contentType;
document.getElementById('reportContentId').value = contentId;
document.getElementById('reportReason').value = '';
document.getElementById('reportDescription').value = '';
document.getElementById('reportModal').classList.add('active');
}
function closeReportModal() {
document.getElementById('reportModal').classList.remove('active');
}
function submitReport() {
const contentType = document.getElementById('reportContentType').value;
const contentId = document.getElementById('reportContentId').value;
const reason = document.getElementById('reportReason').value;
const description = document.getElementById('reportDescription').value.trim();
if (!reason) {
showToast('Wybierz powód zgłoszenia', 'error');
return;
}
fetch('/forum/report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
content_type: contentType,
content_id: contentId,
reason: reason,
description: description
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
closeReportModal();
} else {
showToast(data.error || 'Błąd zgłoszenia', 'error');
}
})
.catch(err => {
showToast('Błąd połączenia', 'error');
});
}
// Admin: Toggle solution
function toggleSolution(replyId) {
fetch(`/admin/forum/reply/${replyId}/solution`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Błąd', 'error');
}
})
.catch(err => {
showToast('Błąd połączenia', 'error');
});
}
// Admin: Restore reply
function restoreReply(replyId) {
fetch(`/admin/forum/reply/${replyId}/restore`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Błąd', 'error');
}
})
.catch(err => {
showToast('Błąd połączenia', 'error');
});
}
// Close modals on overlay click
document.getElementById('editModal').addEventListener('click', function(e) {
if (e.target === this) closeEditModal();
});
document.getElementById('reportModal').addEventListener('click', function(e) {
if (e.target === this) closeReportModal();
});
// Multi-file upload handling (only if form exists) // Multi-file upload handling (only if form exists)
const dropzone = document.getElementById('dropzone'); const dropzone = document.getElementById('dropzone');
if (dropzone) { if (dropzone) {

View File

@ -219,3 +219,104 @@ def notify_all_users_announcement(announcement_id, title, category=None):
return 0 return 0
finally: finally:
db.close() db.close()
# ============================================================
# FORUM NOTIFICATIONS
# ============================================================
def create_forum_reply_notification(topic_id, topic_title, replier_name, reply_id, subscriber_ids):
"""
Notify topic subscribers about a new reply.
Args:
topic_id: ID of the forum topic
topic_title: Title of the topic
replier_name: Name of the user who replied
reply_id: ID of the new reply
subscriber_ids: List of user IDs to notify
Returns:
Number of notifications created
"""
count = 0
for user_id in subscriber_ids:
result = create_notification(
user_id=user_id,
title="Nowa odpowiedź na forum",
message=f"{replier_name} odpowiedział w temacie: {topic_title[:50]}{'...' if len(topic_title) > 50 else ''}",
notification_type='message',
related_type='forum_reply',
related_id=reply_id,
action_url=f'/forum/{topic_id}#reply-{reply_id}'
)
if result:
count += 1
logger.info(f"Created {count} forum reply notifications for topic {topic_id}")
return count
def create_forum_reaction_notification(user_id, reactor_name, content_type, content_id, topic_id, emoji):
"""
Notify user when someone reacts to their content.
Args:
user_id: ID of the content author
reactor_name: Name of user who reacted
content_type: 'topic' or 'reply'
content_id: ID of the content
topic_id: ID of the topic
emoji: The reaction emoji
"""
create_notification(
user_id=user_id,
title=f"Nowa reakcja {emoji}",
message=f"{reactor_name} zareagował na Twój{'ą odpowiedź' if content_type == 'reply' else ' temat'}",
notification_type='message',
related_type=f'forum_{content_type}',
related_id=content_id,
action_url=f'/forum/{topic_id}{"#reply-" + str(content_id) if content_type == "reply" else ""}'
)
def create_forum_solution_notification(user_id, topic_id, topic_title):
"""
Notify topic author when their question gets a solution.
Args:
user_id: ID of the topic author
topic_id: ID of the topic
topic_title: Title of the topic
"""
create_notification(
user_id=user_id,
title="Twoje pytanie ma rozwiązanie!",
message=f"Odpowiedź w temacie '{topic_title[:40]}' została oznaczona jako rozwiązanie.",
notification_type='message',
related_type='forum_topic',
related_id=topic_id,
action_url=f'/forum/{topic_id}'
)
def create_forum_report_notification(admin_user_ids, report_id, content_type, reporter_name):
"""
Notify admins about a new forum report.
Args:
admin_user_ids: List of admin user IDs
report_id: ID of the report
content_type: 'topic' or 'reply'
reporter_name: Name of the reporter
"""
for user_id in admin_user_ids:
create_notification(
user_id=user_id,
title="Nowe zgłoszenie na forum",
message=f"{reporter_name} zgłosił {'odpowiedź' if content_type == 'reply' else 'temat'}",
notification_type='alert',
related_type='forum_report',
related_id=report_id,
action_url='/admin/forum/reports'
)