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:
parent
9dc5d58982
commit
f22342ea37
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
127
database.py
127
database.py
@ -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'
|
||||||
|
|||||||
168
database/migrations/024_forum_modernization.sql
Normal file
168
database/migrations/024_forum_modernization.sql
Normal 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');
|
||||||
@ -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 -->
|
||||||
|
|||||||
230
templates/admin/forum_deleted.html
Normal file
230
templates/admin/forum_deleted.html
Normal 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') }}">← 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] }}
|
||||||
|
• 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] }}
|
||||||
|
• 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 %}
|
||||||
289
templates/admin/forum_reports.html
Normal file
289
templates/admin/forum_reports.html
Normal 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') }}">← 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] }}
|
||||||
|
• {{ 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 →</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ź →</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] }}
|
||||||
|
• {{ 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 %}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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'
|
||||||
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user