diff --git a/blueprints/__init__.py b/blueprints/__init__.py index 0523696..8021d1d 100644 --- a/blueprints/__init__.py +++ b/blueprints/__init__.py @@ -196,6 +196,25 @@ def register_blueprints(app): 'admin_forum_delete_topic': 'forum.admin_forum_delete_topic', 'admin_forum_delete_reply': 'forum.admin_forum_delete_reply', '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") except ImportError as e: diff --git a/blueprints/forum/routes.py b/blueprints/forum/routes.py index 67ac89e..655671a 100644 --- a/blueprints/forum/routes.py +++ b/blueprints/forum/routes.py @@ -12,8 +12,21 @@ from flask import render_template, request, redirect, url_for, flash, jsonify from flask_login import login_required, current_user from . import bp -from database import SessionLocal, ForumTopic, ForumReply, ForumAttachment +from database import ( + SessionLocal, ForumTopic, ForumReply, ForumAttachment, + ForumTopicSubscription, ForumReport, ForumEditHistory, User +) from utils.helpers import sanitize_input +from utils.notifications import ( + create_forum_reply_notification, + create_forum_reaction_notification, + create_forum_solution_notification, + create_forum_report_notification +) + +# Constants +EDIT_TIME_LIMIT_HOURS = 24 +AVAILABLE_REACTIONS = ['👍', '❤️', '🎉'] # Logger logger = logging.getLogger(__name__) @@ -42,8 +55,10 @@ def forum_index(): db = SessionLocal() try: - # Build query with optional filters - query = db.query(ForumTopic) + # Build query with optional filters (exclude soft-deleted) + query = db.query(ForumTopic).filter( + (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) + ) if category_filter and category_filter in ForumTopic.CATEGORIES: query = query.filter(ForumTopic.category == category_filter) @@ -115,6 +130,14 @@ def forum_new_topic(): db.commit() db.refresh(topic) + # Auto-subscribe author to their own topic + subscription = ForumTopicSubscription( + user_id=current_user.id, + topic_id=topic.id + ) + db.add(subscription) + db.commit() + # Handle file upload if FILE_UPLOAD_AVAILABLE and 'attachment' in request.files: file = request.files['attachment'] @@ -159,14 +182,32 @@ def forum_topic(topic_id): flash('Temat nie istnieje.', 'error') return redirect(url_for('.forum_index')) + # Check if topic is soft-deleted (only admins can view) + if topic.is_deleted and not current_user.is_admin: + flash('Temat nie istnieje.', 'error') + return redirect(url_for('.forum_index')) + # Increment view count (handle NULL) topic.views_count = (topic.views_count or 0) + 1 db.commit() + # Check subscription status + is_subscribed = db.query(ForumTopicSubscription).filter( + ForumTopicSubscription.topic_id == topic_id, + ForumTopicSubscription.user_id == current_user.id + ).first() is not None + + # Filter soft-deleted replies for non-admins + visible_replies = [r for r in topic.replies + if not r.is_deleted or current_user.is_admin] + return render_template('forum/topic.html', topic=topic, + visible_replies=visible_replies, + is_subscribed=is_subscribed, category_labels=ForumTopic.CATEGORY_LABELS, - status_labels=ForumTopic.STATUS_LABELS) + status_labels=ForumTopic.STATUS_LABELS, + available_reactions=AVAILABLE_REACTIONS) finally: db.close() @@ -243,6 +284,27 @@ def forum_reply(topic_id): topic.updated_at = datetime.now() db.commit() + # Send notifications to subscribers (except the replier) + try: + subscriptions = db.query(ForumTopicSubscription).filter( + ForumTopicSubscription.topic_id == topic_id, + ForumTopicSubscription.user_id != current_user.id, + ForumTopicSubscription.notify_app == True + ).all() + + subscriber_ids = [s.user_id for s in subscriptions] + if subscriber_ids: + replier_name = current_user.name or current_user.email.split('@')[0] + create_forum_reply_notification( + topic_id=topic_id, + topic_title=topic.title, + replier_name=replier_name, + reply_id=reply.id, + subscriber_ids=subscriber_ids + ) + except Exception as e: + logger.warning(f"Failed to send reply notifications: {e}") + flash('Odpowiedź dodana.', 'success') return redirect(url_for('.forum_topic', topic_id=topic_id)) finally: @@ -448,3 +510,796 @@ def admin_forum_change_status(topic_id): }) finally: db.close() + + +# ============================================================ +# USER FORUM ACTIONS (edit, delete, reactions, subscriptions) +# ============================================================ + +def _can_edit_content(content_created_at): + """Check if content is within editable time window (24h)""" + if not content_created_at: + return False + from datetime import timedelta + return datetime.now() - content_created_at < timedelta(hours=EDIT_TIME_LIMIT_HOURS) + + +@bp.route('/forum/topic//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//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//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//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//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//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//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//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//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//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//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//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//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//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//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() diff --git a/database.py b/database.py index 7de34b5..7900e4e 100644 --- a/database.py +++ b/database.py @@ -228,7 +228,8 @@ class User(Base, UserMixin): # Relationships 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_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): return f'' @@ -916,6 +917,19 @@ class ForumTopic(Base): is_ai_generated = Column(Boolean, default=False) 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 created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) @@ -942,9 +956,12 @@ class ForumTopic(Base): # Relationships author = relationship('User', foreign_keys=[author_id], back_populates='forum_topics') 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') attachments = relationship('ForumAttachment', back_populates='topic', cascade='all, delete-orphan', primaryjoin="and_(ForumAttachment.topic_id==ForumTopic.id, ForumAttachment.attachment_type=='topic')") + subscriptions = relationship('ForumTopicSubscription', back_populates='topic', cascade='all, delete-orphan') @property def reply_count(self): @@ -975,13 +992,34 @@ class ForumReply(Base): content = Column(Text, nullable=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 created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) # Relationships 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', 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" +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): """API cost tracking""" __tablename__ = 'ai_api_costs' diff --git a/database/migrations/024_forum_modernization.sql b/database/migrations/024_forum_modernization.sql new file mode 100644 index 0000000..fb57021 --- /dev/null +++ b/database/migrations/024_forum_modernization.sql @@ -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'); diff --git a/templates/admin/forum.html b/templates/admin/forum.html index 685d9a0..e083b9f 100755 --- a/templates/admin/forum.html +++ b/templates/admin/forum.html @@ -348,7 +348,19 @@ {% block content %}

Moderacja Forum

-

Zarzadzaj tematami i odpowiedziami na forum

+

Zarządzaj tematami i odpowiedziami na forum

+
+ + + diff --git a/templates/admin/forum_deleted.html b/templates/admin/forum_deleted.html new file mode 100644 index 0000000..3224829 --- /dev/null +++ b/templates/admin/forum_deleted.html @@ -0,0 +1,230 @@ +{% extends "base.html" %} + +{% block title %}Usunięte Treści Forum - Norda Biznes Partner{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + + +
+

Usunięte Treści Forum

+
+ +
+

Usunięte Tematy ({{ deleted_topics|length }})

+ + {% if deleted_topics %} + {% for topic in deleted_topics %} +
+
+
+ {{ topic.title }} +
+ Autor: {{ topic.author.name or topic.author.email.split('@')[0] }} + • Utworzono: {{ topic.created_at.strftime('%d.%m.%Y %H:%M') }} +
+
+ +
+
+ {{ topic.content[:300] }}{% if topic.content|length > 300 %}...{% endif %} +
+
+ 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 %} +
+
+ {% endfor %} + {% else %} +
+ Brak usuniętych tematów. +
+ {% endif %} +
+ +
+

Usunięte Odpowiedzi ({{ deleted_replies|length }})

+ + {% if deleted_replies %} + {% for reply in deleted_replies %} +
+
+
+
+ W temacie: {{ reply.topic.title }} +
Autor: {{ reply.author.name or reply.author.email.split('@')[0] }} + • Utworzono: {{ reply.created_at.strftime('%d.%m.%Y %H:%M') }} +
+
+ +
+
+ {{ reply.content[:300] }}{% if reply.content|length > 300 %}...{% endif %} +
+
+ 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 %} +
+
+ {% endfor %} + {% else %} +
+ Brak usuniętych odpowiedzi. +
+ {% endif %} +
+{% 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 %} diff --git a/templates/admin/forum_reports.html b/templates/admin/forum_reports.html new file mode 100644 index 0000000..0a834fd --- /dev/null +++ b/templates/admin/forum_reports.html @@ -0,0 +1,289 @@ +{% extends "base.html" %} + +{% block title %}Zgłoszenia Forum - Norda Biznes Partner{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + + +
+

Zgłoszenia Forum

+
+ + + +{% if reports %} +
+ {% for report in reports %} +
+
+
+ {{ reason_labels.get(report.reason, report.reason) }} + + Zgłoszone przez {{ report.reporter.name or report.reporter.email.split('@')[0] }} + • {{ report.created_at.strftime('%d.%m.%Y %H:%M') }} + +
+ + {{ report.content_type|capitalize }} #{{ report.topic_id or report.reply_id }} + +
+ + {% if report.description %} +
+
Opis zgłoszenia:
+ {{ report.description }} +
+ {% endif %} + +
+
Zgłoszona treść:
+ {% if report.content_type == 'topic' and report.topic %} + {{ report.topic.title }}
+ {{ report.topic.content[:300] }}{% if report.topic.content|length > 300 %}...{% endif %} +
Zobacz temat → + {% elif report.content_type == 'reply' and report.reply %} + {{ report.reply.content[:300] }}{% if report.reply.content|length > 300 %}...{% endif %} +
Zobacz odpowiedź → + {% else %} + Treść niedostępna + {% endif %} +
+ + {% if report.status == 'pending' %} +
+ + +
+ {% else %} +
+ {% 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 %} +
Notatka: {{ report.review_note }} + {% endif %} +
+ {% endif %} +
+ {% endfor %} +
+{% else %} +
+
+ Brak zgłoszeń w tej kategorii. +
+
+{% 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 %} diff --git a/templates/forum/topic.html b/templates/forum/topic.html index 031343b..0554161 100755 --- a/templates/forum/topic.html +++ b/templates/forum/topic.html @@ -609,6 +609,203 @@ width: 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; + } {% endblock %} @@ -682,6 +879,9 @@ {{ topic.created_at.strftime('%d.%m.%Y %H:%M') }} + {% if topic.edited_at %} + (edytowano {{ topic.edited_at.strftime('%d.%m.%Y %H:%M') }}) + {% endif %} @@ -690,9 +890,40 @@ {{ topic.views_count }} wyświetleń + -
{{ topic.content }}
+
{{ topic.content }}
+ + +
+ {% 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, [])) %} + + {% endfor %} +
+ + + {% if not topic.is_locked %} + + {% endif %} {% if topic.attachments %} {% for attachment in topic.attachments %} @@ -711,13 +942,13 @@

- Odpowiedzi ({{ topic.replies|length }}) + Odpowiedzi ({{ visible_replies|length }})

- {% if topic.replies %} + {% if visible_replies %}
- {% for reply in topic.replies %} -
+ {% for reply in visible_replies %} +
@@ -730,19 +961,40 @@ AI {% endif %} + {% if reply.is_solution %} + ✓ Rozwiązanie + {% endif %}
- {{ reply.created_at.strftime('%d.%m.%Y %H:%M') }} + + {{ reply.created_at.strftime('%d.%m.%Y %H:%M') }} + {% if reply.edited_at %} + (edytowano) + {% endif %} + {% if current_user.is_authenticated and current_user.is_admin %}
+ + + {% if reply.is_deleted %} + + {% else %} + {% endif %}
{% endif %}
+ + {% if reply.is_deleted %} +
[Ta odpowiedź została usunięta]
+ {% else %}
{{ reply.content }}
{% if reply.attachments %} @@ -761,6 +1013,39 @@
{% endif %} + + +
+ {% 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, [])) %} + + {% endfor %} +
+ + + {% if not topic.is_locked %} + + {% endif %} + {% endif %} {% endfor %} @@ -817,6 +1102,41 @@ + +
+
+

Edytuj treść

+ + + + +
+
+ + +
+
+

Zgłoś treść

+ + + + + +
+
+