""" Forum Routes ============ Forum topics, replies, and admin moderation. """ import logging from datetime import datetime, timedelta from flask import render_template, request, redirect, url_for, flash, jsonify from flask_login import login_required, current_user from . import bp from database import ( SessionLocal, ForumTopic, ForumReply, ForumAttachment, ForumTopicSubscription, ForumReport, ForumEditHistory, User, ForumTopicRead, ForumReplyRead ) from utils.helpers import sanitize_input from utils.decorators import forum_access_required from utils.notifications import ( create_forum_reply_notification, create_forum_reaction_notification, create_forum_solution_notification, create_forum_report_notification, parse_mentions_and_notify, send_forum_reply_email ) # Constants EDIT_TIME_LIMIT_HOURS = 24 AVAILABLE_REACTIONS = ['👍', '❤️'] # Logger logger = logging.getLogger(__name__) # Import FileUploadService (may not be available in all environments) try: from file_upload_service import FileUploadService FILE_UPLOAD_AVAILABLE = True except ImportError: FILE_UPLOAD_AVAILABLE = False logger.warning("FileUploadService not available for forum") # ============================================================ # PUBLIC FORUM ROUTES # ============================================================ @bp.route('/forum') @login_required @forum_access_required def forum_index(): """Forum - list of topics with category/status/solution filters and search""" page = request.args.get('page', 1, type=int) per_page = 20 category_filter = request.args.get('category', '') status_filter = request.args.get('status', '') has_solution = request.args.get('has_solution', '') search_query = request.args.get('q', '').strip() db = SessionLocal() try: # Build query with optional filters (exclude soft-deleted) query = db.query(ForumTopic).filter( (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) ) if category_filter and category_filter in ForumTopic.CATEGORIES: query = query.filter(ForumTopic.category == category_filter) if status_filter and status_filter in ForumTopic.STATUSES: query = query.filter(ForumTopic.status == status_filter) # Filter by has solution if has_solution == '1': # Topics that have at least one reply marked as solution from sqlalchemy import exists query = query.filter( exists().where( (ForumReply.topic_id == ForumTopic.id) & (ForumReply.is_solution == True) ) ) # Search in title and content if search_query: search_term = f'%{search_query}%' query = query.filter( (ForumTopic.title.ilike(search_term)) | (ForumTopic.content.ilike(search_term)) ) # Order by pinned first, then by last activity query = query.order_by( ForumTopic.is_pinned.desc(), ForumTopic.updated_at.desc() ) total_topics = query.count() topics = query.limit(per_page).offset((page - 1) * per_page).all() return render_template( 'forum/index.html', topics=topics, page=page, per_page=per_page, total_topics=total_topics, total_pages=(total_topics + per_page - 1) // per_page, category_filter=category_filter, status_filter=status_filter, has_solution=has_solution, search_query=search_query, categories=ForumTopic.CATEGORIES, statuses=ForumTopic.STATUSES, category_labels=ForumTopic.CATEGORY_LABELS, status_labels=ForumTopic.STATUS_LABELS ) finally: db.close() @bp.route('/forum/nowy', methods=['GET', 'POST']) @login_required @forum_access_required def forum_new_topic(): """Create new forum topic with category and attachments""" if request.method == 'POST': title = sanitize_input(request.form.get('title', ''), 255) content = request.form.get('content', '').strip() category = request.form.get('category', 'question') # Validate category if category not in ForumTopic.CATEGORIES: category = 'question' if not title or len(title) < 5: flash('Tytuł musi mieć co najmniej 5 znaków.', 'error') return render_template('forum/new_topic.html', categories=ForumTopic.CATEGORIES, category_labels=ForumTopic.CATEGORY_LABELS) if not content or len(content) < 10: flash('Treść musi mieć co najmniej 10 znaków.', 'error') return render_template('forum/new_topic.html', categories=ForumTopic.CATEGORIES, category_labels=ForumTopic.CATEGORY_LABELS) db = SessionLocal() try: # Duplicate submission protection: same author, same title+content, within 60 seconds recent_duplicate = db.query(ForumTopic).filter( ForumTopic.author_id == current_user.id, ForumTopic.title == title, ForumTopic.content == content, ForumTopic.created_at >= datetime.now() - timedelta(seconds=60) ).first() if recent_duplicate: return redirect(url_for('.forum_topic', topic_id=recent_duplicate.id)) topic = ForumTopic( title=title, content=content, author_id=current_user.id, category=category ) db.add(topic) db.commit() db.refresh(topic) # Auto-subscribe author to their own topic subscription = ForumTopicSubscription( user_id=current_user.id, topic_id=topic.id ) db.add(subscription) db.commit() # Handle file upload if FILE_UPLOAD_AVAILABLE and 'attachment' in request.files: file = request.files['attachment'] if file and file.filename: is_valid, error_msg = FileUploadService.validate_file(file) if is_valid: stored_filename, rel_path, file_size, mime_type = FileUploadService.save_file(file, 'topic') attachment = ForumAttachment( attachment_type='topic', topic_id=topic.id, original_filename=file.filename, stored_filename=stored_filename, file_extension=stored_filename.rsplit('.', 1)[-1], file_size=file_size, mime_type=mime_type, uploaded_by=current_user.id ) db.add(attachment) db.commit() else: flash(f'Załącznik: {error_msg}', 'warning') # Parse @mentions and send notifications try: author_name = current_user.name or current_user.email.split('@')[0] parse_mentions_and_notify( content=content, author_id=current_user.id, author_name=author_name, topic_id=topic.id, content_type='topic', content_id=topic.id ) except Exception as e: logger.warning(f"Failed to parse mentions in new topic: {e}") flash('Temat został utworzony.', 'success') return redirect(url_for('.forum_topic', topic_id=topic.id)) finally: db.close() return render_template('forum/new_topic.html', categories=ForumTopic.CATEGORIES, category_labels=ForumTopic.CATEGORY_LABELS) @bp.route('/forum/') @login_required @forum_access_required def forum_topic(topic_id): """View forum topic with replies""" db = SessionLocal() try: topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first() if not topic: flash('Temat nie istnieje.', 'error') return redirect(url_for('.forum_index')) # Check if topic is soft-deleted (only moderators can view) if topic.is_deleted and not current_user.can_moderate_forum(): 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 # Record topic read by current user existing_topic_read = db.query(ForumTopicRead).filter( ForumTopicRead.topic_id == topic.id, ForumTopicRead.user_id == current_user.id ).first() if not existing_topic_read: db.add(ForumTopicRead(topic_id=topic.id, user_id=current_user.id)) # Filter soft-deleted replies for non-moderators visible_replies = [r for r in topic.replies if not r.is_deleted or current_user.can_moderate_forum()] # Record read for all visible replies for reply in visible_replies: existing_reply_read = db.query(ForumReplyRead).filter( ForumReplyRead.reply_id == reply.id, ForumReplyRead.user_id == current_user.id ).first() if not existing_reply_read: db.add(ForumReplyRead(reply_id=reply.id, user_id=current_user.id)) db.commit() # Get topic readers from sqlalchemy import desc topic_readers = db.query(ForumTopicRead).filter( ForumTopicRead.topic_id == topic.id ).order_by(desc(ForumTopicRead.read_at)).all() # Check subscription status is_subscribed = db.query(ForumTopicSubscription).filter( ForumTopicSubscription.topic_id == topic_id, ForumTopicSubscription.user_id == current_user.id ).first() is not None # Build reaction user names map (user_id -> name) for tooltips reaction_user_ids = set() if topic.reactions: for uid_list in topic.reactions.values(): if isinstance(uid_list, list): reaction_user_ids.update(uid_list) for reply in visible_replies: if reply.reactions: for uid_list in reply.reactions.values(): if isinstance(uid_list, list): reaction_user_ids.update(uid_list) reaction_user_names = {} if reaction_user_ids: users = db.query(User.id, User.name, User.email).filter(User.id.in_(reaction_user_ids)).all() reaction_user_names = {u.id: (u.name or u.email.split('@')[0]) for u in users} return render_template('forum/topic.html', topic=topic, visible_replies=visible_replies, topic_readers=topic_readers, is_subscribed=is_subscribed, category_labels=ForumTopic.CATEGORY_LABELS, status_labels=ForumTopic.STATUS_LABELS, available_reactions=AVAILABLE_REACTIONS, reaction_user_names=reaction_user_names) finally: db.close() @bp.route('/forum//odpowiedz', methods=['POST']) @login_required @forum_access_required def forum_reply(topic_id): """Add reply to forum topic with optional attachment""" content = request.form.get('content', '').strip() if not content or len(content) < 3: flash('Odpowiedź musi mieć co najmniej 3 znaki.', 'error') return redirect(url_for('.forum_topic', topic_id=topic_id)) db = SessionLocal() try: topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first() if not topic: flash('Temat nie istnieje.', 'error') return redirect(url_for('.forum_index')) if topic.is_locked: flash('Ten temat jest zamknięty.', 'error') return redirect(url_for('.forum_topic', topic_id=topic_id)) # Duplicate submission protection: same user, same content, within 30 seconds recent_duplicate = db.query(ForumReply).filter( ForumReply.topic_id == topic_id, ForumReply.author_id == current_user.id, ForumReply.content == content, ForumReply.created_at >= datetime.now() - timedelta(seconds=30) ).first() if recent_duplicate: return redirect(url_for('.forum_topic', topic_id=topic_id)) reply = ForumReply( topic_id=topic_id, author_id=current_user.id, content=content ) db.add(reply) db.commit() db.refresh(reply) # Handle multiple file uploads (max 10) if FILE_UPLOAD_AVAILABLE: MAX_ATTACHMENTS = 10 files = request.files.getlist('attachments[]') if not files: # Fallback for single file upload (backward compatibility) files = request.files.getlist('attachment') uploaded_count = 0 errors = [] for file in files[:MAX_ATTACHMENTS]: if file and file.filename: is_valid, error_msg = FileUploadService.validate_file(file) if is_valid: stored_filename, rel_path, file_size, mime_type = FileUploadService.save_file(file, 'reply') attachment = ForumAttachment( attachment_type='reply', reply_id=reply.id, original_filename=file.filename, stored_filename=stored_filename, file_extension=stored_filename.rsplit('.', 1)[-1], file_size=file_size, mime_type=mime_type, uploaded_by=current_user.id ) db.add(attachment) uploaded_count += 1 else: errors.append(f'{file.filename}: {error_msg}') if uploaded_count > 0: db.commit() if errors: flash(f'Niektóre załączniki nie zostały dodane: {"; ".join(errors)}', 'warning') # Update topic updated_at topic.updated_at = datetime.now() db.commit() # Auto-subscribe replier to topic (if not already subscribed) try: existing_sub = db.query(ForumTopicSubscription).filter( ForumTopicSubscription.topic_id == topic_id, ForumTopicSubscription.user_id == current_user.id ).first() if not existing_sub: db.add(ForumTopicSubscription( topic_id=topic_id, user_id=current_user.id, notify_email=True, notify_app=True )) db.commit() except Exception as e: logger.warning(f"Failed to auto-subscribe replier: {e}") # Send in-app notifications to subscribers (except the replier) replier_name = current_user.name or current_user.email.split('@')[0] try: app_subs = 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 app_subs] if subscriber_ids: 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}") # Send email notifications to subscribers with notify_email=True try: email_subs = db.query(ForumTopicSubscription).filter( ForumTopicSubscription.topic_id == topic_id, ForumTopicSubscription.user_id != current_user.id, ForumTopicSubscription.notify_email == True ).all() if email_subs: subscriber_emails = [] for sub in email_subs: user = db.query(User).filter(User.id == sub.user_id).first() if user and user.email: subscriber_emails.append({ 'email': user.email, 'name': user.name or user.email.split('@')[0] }) if subscriber_emails: send_forum_reply_email( topic_id=topic_id, topic_title=topic.title, replier_name=replier_name, reply_content=content, subscriber_emails=subscriber_emails, reply_id=reply.id ) except Exception as e: logger.warning(f"Failed to send email notifications: {e}") # Parse @mentions and send notifications try: author_name = current_user.name or current_user.email.split('@')[0] parse_mentions_and_notify( content=content, author_id=current_user.id, author_name=author_name, topic_id=topic_id, content_type='reply', content_id=reply.id ) except Exception as e: logger.warning(f"Failed to parse mentions: {e}") # Web Push: autor wątku (forum_reply) + cytowani (forum_quote) try: from blueprints.push.push_service import send_push import re as _re topic_url = f'/forum/{topic_id}#reply-{reply.id}' plain = _re.sub(r'<[^>]+>', '', content or '').strip() # Usuń cytat bloki z podglądu (linie zaczynające się od '>') plain_lines = [ln for ln in plain.splitlines() if not ln.lstrip().startswith('>')] clean = ' '.join(plain_lines).strip() preview = (clean[:80] + '…') if len(clean) > 80 else clean notified_user_ids = set() # Autor wątku (o ile nie jest sam replierem) if topic.author_id and topic.author_id != current_user.id: t_author = db.query(User).filter(User.id == topic.author_id).first() if t_author and t_author.notify_push_forum_reply is not False: send_push( user_id=t_author.id, title=f'Nowa odpowiedź: {topic.title[:60]}', body=f'{replier_name}: {preview}' if preview else f'{replier_name} odpowiedział', url=topic_url, tag=f'forum-reply-{topic_id}', ) notified_user_ids.add(t_author.id) # Cytowani autorzy — pattern "> **Imię Nazwisko** napisał(a):" quoted_names = set(_re.findall(r'>\s*\*\*([^*\n]+?)\*\*\s*napisał', content)) for qname in quoted_names: qname = qname.strip() if not qname: continue quoted_users = db.query(User).filter(User.name == qname).all() if len(quoted_users) != 1: continue # niejednoznaczne — pomijamy quoted = quoted_users[0] if quoted.id == current_user.id or quoted.id in notified_user_ids: continue if quoted.notify_push_forum_quote is not False: send_push( user_id=quoted.id, title=f'Zacytowano Twoją wypowiedź: {topic.title[:50]}', body=f'{replier_name}: {preview}' if preview else f'{replier_name} zacytował', url=topic_url, tag=f'forum-quote-{reply.id}-{quoted.id}', ) notified_user_ids.add(quoted.id) # Email dla cytowanego (jeśli flag=True, default TRUE dla quote) if quoted.email and quoted.notify_email_forum_quote is not False: try: from utils.notifications import send_forum_reply_email send_forum_reply_email( topic_id=topic_id, topic_title=topic.title, replier_name=replier_name, reply_content=content, subscriber_emails=[{'email': quoted.email, 'name': quoted.name or quoted.email}], reply_id=reply.id, ) except Exception as e: logger.warning(f"Failed to send forum quote email: {e}") except Exception as e: logger.warning(f"Forum push trigger error: {e}") flash('Odpowiedź dodana.', 'success') return redirect(url_for('.forum_topic', topic_id=topic_id)) finally: db.close() # ============================================================ # ADMIN FORUM ROUTES # ============================================================ @bp.route('/admin/forum') @login_required def admin_forum(): """Admin panel for forum moderation""" if not current_user.can_moderate_forum(): flash('Brak uprawnień do tej strony.', 'error') return redirect(url_for('.forum_index')) db = SessionLocal() try: # Get all topics with stats topics = db.query(ForumTopic).order_by( ForumTopic.created_at.desc() ).all() # Get recent replies recent_replies = db.query(ForumReply).order_by( ForumReply.created_at.desc() ).limit(50).all() # Stats total_topics = len(topics) total_replies = db.query(ForumReply).count() pinned_count = sum(1 for t in topics if t.is_pinned) locked_count = sum(1 for t in topics if t.is_locked) # Category and status stats category_counts = {} status_counts = {} for t in topics: cat = t.category or 'question' status = t.status or 'new' category_counts[cat] = category_counts.get(cat, 0) + 1 status_counts[status] = status_counts.get(status, 0) + 1 return render_template( 'admin/forum.html', topics=topics, recent_replies=recent_replies, total_topics=total_topics, total_replies=total_replies, pinned_count=pinned_count, locked_count=locked_count, category_counts=category_counts, status_counts=status_counts, categories=ForumTopic.CATEGORIES, statuses=ForumTopic.STATUSES, category_labels=ForumTopic.CATEGORY_LABELS, status_labels=ForumTopic.STATUS_LABELS ) finally: db.close() @bp.route('/admin/forum/topic//pin', methods=['POST']) @login_required def admin_forum_pin(topic_id): """Toggle topic pin status""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 db = SessionLocal() try: topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first() if not topic: return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404 topic.is_pinned = not topic.is_pinned db.commit() logger.info(f"Admin {current_user.email} {'pinned' if topic.is_pinned else 'unpinned'} topic #{topic_id}") return jsonify({ 'success': True, 'is_pinned': topic.is_pinned, 'message': f"Temat {'przypięty' if topic.is_pinned else 'odpięty'}" }) finally: db.close() @bp.route('/admin/forum/topic//lock', methods=['POST']) @login_required def admin_forum_lock(topic_id): """Toggle topic lock status""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 db = SessionLocal() try: topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first() if not topic: return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404 topic.is_locked = not topic.is_locked db.commit() logger.info(f"Admin {current_user.email} {'locked' if topic.is_locked else 'unlocked'} topic #{topic_id}") return jsonify({ 'success': True, 'is_locked': topic.is_locked, 'message': f"Temat {'zamknięty' if topic.is_locked else 'otwarty'}" }) finally: db.close() @bp.route('/admin/forum/topic//delete', methods=['POST']) @login_required def admin_forum_delete_topic(topic_id): """Delete topic and all its replies""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 db = SessionLocal() try: topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first() if not topic: return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404 topic_title = topic.title # Manually delete dependent records (FK may lack CASCADE in DB) from database import ForumTopicRead, ForumReplyRead, ForumAttachment, ForumReport, ForumEditHistory, ForumReply, ForumTopicSubscription for reply in topic.replies: db.query(ForumReplyRead).filter(ForumReplyRead.reply_id == reply.id).delete() db.query(ForumAttachment).filter(ForumAttachment.reply_id == reply.id).delete() db.query(ForumReport).filter(ForumReport.reply_id == reply.id).delete() db.query(ForumEditHistory).filter(ForumEditHistory.reply_id == reply.id).delete() db.query(ForumReply).filter(ForumReply.topic_id == topic_id).delete() db.query(ForumTopicRead).filter(ForumTopicRead.topic_id == topic_id).delete() db.query(ForumAttachment).filter(ForumAttachment.topic_id == topic_id).delete() db.query(ForumReport).filter(ForumReport.topic_id == topic_id).delete() db.query(ForumEditHistory).filter(ForumEditHistory.topic_id == topic_id).delete() db.query(ForumTopicSubscription).filter(ForumTopicSubscription.topic_id == topic_id).delete() db.delete(topic) db.commit() logger.info(f"Admin {current_user.email} deleted topic #{topic_id}: {topic_title}") return jsonify({ 'success': True, 'message': 'Temat usunięty' }) finally: db.close() @bp.route('/admin/forum/reply//delete', methods=['POST']) @login_required def admin_forum_delete_reply(reply_id): """Delete a reply""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 db = SessionLocal() try: reply = db.query(ForumReply).filter(ForumReply.id == reply_id).first() if not reply: return jsonify({'success': False, 'error': 'Odpowiedź nie istnieje'}), 404 topic_id = reply.topic_id # Delete dependent records first (reply reads, attachments, reports, edit history) db.query(ForumReplyRead).filter(ForumReplyRead.reply_id == reply_id).delete() db.query(ForumAttachment).filter(ForumAttachment.reply_id == reply_id).delete() db.query(ForumReport).filter(ForumReport.reply_id == reply_id).delete() db.query(ForumEditHistory).filter(ForumEditHistory.reply_id == reply_id).delete() db.delete(reply) db.commit() logger.info(f"Admin {current_user.email} deleted reply #{reply_id} from topic #{topic_id}") return jsonify({ 'success': True, 'message': 'Odpowiedź usunięta' }) finally: db.close() @bp.route('/admin/forum/topic//status', methods=['POST']) @login_required def admin_forum_change_status(topic_id): """Change topic status (moderators only)""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 data = request.get_json() or {} new_status = data.get('status') note = data.get('note', '').strip() if not new_status or new_status not in ForumTopic.STATUSES: return jsonify({'success': False, 'error': 'Nieprawidłowy status'}), 400 db = SessionLocal() try: topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first() if not topic: return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404 old_status = topic.status topic.status = new_status topic.status_changed_by = current_user.id topic.status_changed_at = datetime.now() if note: topic.status_note = note db.commit() logger.info(f"Admin {current_user.email} changed topic #{topic_id} status: {old_status} -> {new_status}") return jsonify({ 'success': True, 'status': new_status, 'status_label': ForumTopic.STATUS_LABELS.get(new_status, new_status), 'message': f"Status zmieniony na: {ForumTopic.STATUS_LABELS.get(new_status, new_status)}" }) finally: db.close() @bp.route('/admin/forum/bulk-action', methods=['POST']) @login_required def admin_forum_bulk_action(): """Perform bulk action on multiple topics (moderators only)""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 data = request.get_json() or {} topic_ids = data.get('topic_ids', []) action = data.get('action') if not topic_ids or not isinstance(topic_ids, list): return jsonify({'success': False, 'error': 'Nie wybrano tematów'}), 400 if action not in ['pin', 'unpin', 'lock', 'unlock', 'status', 'delete']: return jsonify({'success': False, 'error': 'Nieprawidłowa akcja'}), 400 db = SessionLocal() try: topics = db.query(ForumTopic).filter(ForumTopic.id.in_(topic_ids)).all() if not topics: return jsonify({'success': False, 'error': 'Nie znaleziono tematów'}), 404 count = len(topics) if action == 'pin': for topic in topics: topic.is_pinned = True message = f'Przypięto {count} tematów' elif action == 'unpin': for topic in topics: topic.is_pinned = False message = f'Odpięto {count} tematów' elif action == 'lock': for topic in topics: topic.is_locked = True message = f'Zablokowano {count} tematów' elif action == 'unlock': for topic in topics: topic.is_locked = False message = f'Odblokowano {count} tematów' elif action == 'status': new_status = data.get('status') if not new_status or new_status not in ForumTopic.STATUSES: return jsonify({'success': False, 'error': 'Nieprawidłowy status'}), 400 for topic in topics: topic.status = new_status topic.status_changed_by = current_user.id topic.status_changed_at = datetime.now() status_label = ForumTopic.STATUS_LABELS.get(new_status, new_status) message = f'Zmieniono status {count} tematów na: {status_label}' elif action == 'delete': # Soft delete topics for topic in topics: topic.is_deleted = True topic.deleted_at = datetime.now() topic.deleted_by = current_user.id message = f'Usunięto {count} tematów' db.commit() logger.info(f"Admin {current_user.email} performed bulk action '{action}' on {count} topics: {topic_ids}") return jsonify({ 'success': True, 'message': message, 'affected_count': count }) except Exception as e: db.rollback() logger.error(f"Error in bulk action: {e}") return jsonify({'success': False, 'error': 'Wystąpił błąd'}), 500 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 @forum_access_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 moderator) if topic.author_id != current_user.id and not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 # Check time limit (unless moderator) if not current_user.can_moderate_forum() 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 @forum_access_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 moderator) if reply.author_id != current_user.id and not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 # Check time limit (unless moderator) if not current_user.can_moderate_forum() 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 @forum_access_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.can_moderate_forum(): 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 @forum_access_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() # Build names for tooltips all_uids = set() for uid_list in reactions.values(): all_uids.update(uid_list) user_names = {} if all_uids: users = db.query(User.id, User.name, User.email).filter(User.id.in_(all_uids)).all() user_names = {u.id: (u.name or u.email.split('@')[0]) for u in users} reaction_names = {k: [user_names.get(uid, '?') for uid in v] for k, v in reactions.items()} return jsonify({ 'success': True, 'action': action, 'reactions': {k: len(v) for k, v in reactions.items()}, 'reaction_names': reaction_names, 'user_reaction': reaction if action == 'added' else None }) finally: db.close() @bp.route('/forum/reply//react', methods=['POST']) @login_required @forum_access_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}") # Build names for tooltips all_uids = set() for uid_list in reactions.values(): all_uids.update(uid_list) user_names = {} if all_uids: users = db.query(User.id, User.name, User.email).filter(User.id.in_(all_uids)).all() user_names = {u.id: (u.name or u.email.split('@')[0]) for u in users} reaction_names = {k: [user_names.get(uid, '?') for uid in v] for k, v in reactions.items()} return jsonify({ 'success': True, 'action': action, 'reactions': {k: len(v) for k, v in reactions.items()}, 'reaction_names': reaction_names, 'user_reaction': reaction if action == 'added' else None }) finally: db.close() @bp.route('/forum/topic//subscribe', methods=['POST']) @login_required @forum_access_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 @forum_access_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//unsubscribe', methods=['GET']) @login_required @forum_access_required def unsubscribe_from_email(topic_id): """Unsubscribe from topic via email link (GET, requires login)""" db = SessionLocal() try: subscription = db.query(ForumTopicSubscription).filter( ForumTopicSubscription.topic_id == topic_id, ForumTopicSubscription.user_id == current_user.id ).first() topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first() topic_title = topic.title if topic else f"#{topic_id}" if subscription: db.delete(subscription) db.commit() flash(f'Przestales obserwowac watek: {topic_title}', 'success') else: flash('Nie obserwujesz tego watku.', 'info') return redirect(url_for('.forum_topic', topic_id=topic_id)) finally: db.close() @bp.route('/forum/report', methods=['POST']) @login_required @forum_access_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.role == 'ADMIN', 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): """Moderator: Edit any topic content""" if not current_user.can_moderate_forum(): 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): """Moderator: Edit any reply content""" if not current_user.can_moderate_forum(): 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): """Moderator: Mark reply as solution""" if not current_user.can_moderate_forum(): 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): """Moderator: Restore soft-deleted topic""" if not current_user.can_moderate_forum(): 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): """Moderator: Restore soft-deleted reply""" if not current_user.can_moderate_forum(): 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(): """Moderator: View all reports""" if not current_user.can_moderate_forum(): 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): """Moderator: Review a report""" if not current_user.can_moderate_forum(): 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): """Moderator: View topic edit history""" if not current_user.can_moderate_forum(): 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): """Moderator: View reply edit history""" if not current_user.can_moderate_forum(): 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(): """Moderator: View soft-deleted topics and replies""" if not current_user.can_moderate_forum(): 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() # ============================================================ # USER STATISTICS # ============================================================ @bp.route('/forum/user//stats') @login_required @forum_access_required def user_forum_stats(user_id): """Get forum statistics for a user (for tooltip display)""" from sqlalchemy import func db = SessionLocal() try: # Count topics created topic_count = db.query(func.count(ForumTopic.id)).filter( ForumTopic.author_id == user_id, (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) ).scalar() or 0 # Count replies created reply_count = db.query(func.count(ForumReply.id)).filter( ForumReply.author_id == user_id, (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ).scalar() or 0 # Count solutions marked solution_count = db.query(func.count(ForumReply.id)).filter( ForumReply.author_id == user_id, ForumReply.is_solution == True, (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ).scalar() or 0 # Count reactions received on user's topics and replies # Using JSONB - count non-empty reaction arrays reactions_received = 0 # Get user's topics with reactions user_topics = db.query(ForumTopic).filter( ForumTopic.author_id == user_id, ForumTopic.reactions.isnot(None) ).all() for topic in user_topics: if topic.reactions: for emoji, user_ids in topic.reactions.items(): if isinstance(user_ids, list): reactions_received += len(user_ids) # Get user's replies with reactions user_replies = db.query(ForumReply).filter( ForumReply.author_id == user_id, ForumReply.reactions.isnot(None) ).all() for reply in user_replies: if reply.reactions: for emoji, user_ids in reply.reactions.items(): if isinstance(user_ids, list): reactions_received += len(user_ids) # Get user info user = db.query(User).filter(User.id == user_id).first() user_name = user.name if user else 'Nieznany' return jsonify({ 'success': True, 'user_id': user_id, 'user_name': user_name, 'stats': { 'topics': topic_count, 'replies': reply_count, 'solutions': solution_count, 'reactions_received': reactions_received, 'total_posts': topic_count + reply_count } }) except Exception as e: logger.error(f"Error getting user stats: {e}") return jsonify({'success': False, 'error': str(e)}), 500 finally: db.close() # ============================================================ # ADMIN ANALYTICS & TOOLS # ============================================================ @bp.route('/admin/forum/analytics') @login_required def admin_forum_analytics(): """Forum analytics dashboard with stats, charts, and rankings""" if not current_user.can_moderate_forum(): flash('Brak uprawnien do tej strony.', 'error') return redirect(url_for('.forum_index')) from sqlalchemy import func, distinct from datetime import timedelta # Date range from query params (default: last 30 days) end_date_str = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d')) start_date_str = request.args.get('start_date', (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')) try: end_date = datetime.strptime(end_date_str, '%Y-%m-%d') start_date = datetime.strptime(start_date_str, '%Y-%m-%d') except ValueError: end_date = datetime.now() start_date = end_date - timedelta(days=30) # Ensure end_date is end of day end_date = end_date.replace(hour=23, minute=59, second=59) db = SessionLocal() try: # Basic stats total_topics = db.query(func.count(ForumTopic.id)).filter( (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) ).scalar() or 0 total_replies = db.query(func.count(ForumReply.id)).filter( (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ).scalar() or 0 # This month stats month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) topics_this_month = db.query(func.count(ForumTopic.id)).filter( ForumTopic.created_at >= month_start, (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) ).scalar() or 0 replies_this_month = db.query(func.count(ForumReply.id)).filter( ForumReply.created_at >= month_start, (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ).scalar() or 0 # Active users in last 7 days week_ago = datetime.now() - timedelta(days=7) active_topic_authors = db.query(distinct(ForumTopic.author_id)).filter( ForumTopic.created_at >= week_ago ).count() active_reply_authors = db.query(distinct(ForumReply.author_id)).filter( ForumReply.created_at >= week_ago ).count() # Unique active users (simplified - just sum for now) active_users_7d = max(active_topic_authors, active_reply_authors) # Unanswered topics count unanswered_count = db.query(func.count(ForumTopic.id)).filter( (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)), ~ForumTopic.id.in_( db.query(distinct(ForumReply.topic_id)).filter( (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ) ) ).scalar() or 0 # Resolved topics resolved_count = db.query(func.count(ForumTopic.id)).filter( ForumTopic.status == 'resolved', (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) ).scalar() or 0 # Average response time (simplified - in hours) # For now, just show "< 24h" as placeholder avg_response_time = "< 24h" stats = { 'total_topics': total_topics, 'total_replies': total_replies, 'topics_this_month': topics_this_month, 'replies_this_month': replies_this_month, 'active_users_7d': active_users_7d, 'unanswered_topics': unanswered_count, 'resolved_topics': resolved_count, 'avg_response_time': avg_response_time } # Unanswered topics list replied_topic_ids = db.query(distinct(ForumReply.topic_id)).filter( (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ).subquery() unanswered_topics = db.query(ForumTopic).filter( (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)), ~ForumTopic.id.in_(replied_topic_ids) ).order_by(ForumTopic.created_at.asc()).limit(20).all() # Add days_waiting attribute now = datetime.now() for topic in unanswered_topics: topic.days_waiting = (now - topic.created_at).days # User rankings user_stats = {} # Count topics per user topic_counts = db.query( ForumTopic.author_id, func.count(ForumTopic.id).label('count') ).filter( (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) ).group_by(ForumTopic.author_id).all() for author_id, count in topic_counts: if author_id not in user_stats: user_stats[author_id] = {'topic_count': 0, 'reply_count': 0, 'solution_count': 0, 'reaction_count': 0} user_stats[author_id]['topic_count'] = count # Count replies per user reply_counts = db.query( ForumReply.author_id, func.count(ForumReply.id).label('count') ).filter( (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ).group_by(ForumReply.author_id).all() for author_id, count in reply_counts: if author_id not in user_stats: user_stats[author_id] = {'topic_count': 0, 'reply_count': 0, 'solution_count': 0, 'reaction_count': 0} user_stats[author_id]['reply_count'] = count # Count solutions per user solution_counts = db.query( ForumReply.author_id, func.count(ForumReply.id).label('count') ).filter( ForumReply.is_solution == True, (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ).group_by(ForumReply.author_id).all() for author_id, count in solution_counts: if author_id in user_stats: user_stats[author_id]['solution_count'] = count # Get user details and calculate total score user_rankings = [] user_ids = list(user_stats.keys()) if user_ids: users = db.query(User).filter(User.id.in_(user_ids)).all() user_map = {u.id: u for u in users} for user_id, stats_data in user_stats.items(): if user_id in user_map: user = user_map[user_id] total_score = ( stats_data['topic_count'] * 2 + stats_data['reply_count'] + stats_data['solution_count'] * 5 ) user_rankings.append({ 'id': user_id, 'name': user.name, 'email': user.email, 'topic_count': stats_data['topic_count'], 'reply_count': stats_data['reply_count'], 'solution_count': stats_data['solution_count'], 'reaction_count': stats_data['reaction_count'], 'total_score': total_score }) user_rankings.sort(key=lambda x: x['total_score'], reverse=True) user_rankings = user_rankings[:20] # Top 20 # Category stats category_stats = {} for cat in ForumTopic.CATEGORIES: count = db.query(func.count(ForumTopic.id)).filter( ForumTopic.category == cat, (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) ).scalar() or 0 category_stats[cat] = count # Chart data (activity per day) chart_labels = [] chart_topics = [] chart_replies = [] current_date = start_date while current_date <= end_date: date_str = current_date.strftime('%d.%m') chart_labels.append(date_str) day_start = current_date.replace(hour=0, minute=0, second=0, microsecond=0) day_end = current_date.replace(hour=23, minute=59, second=59, microsecond=999999) topics_count = db.query(func.count(ForumTopic.id)).filter( ForumTopic.created_at >= day_start, ForumTopic.created_at <= day_end, (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) ).scalar() or 0 chart_topics.append(topics_count) replies_count = db.query(func.count(ForumReply.id)).filter( ForumReply.created_at >= day_start, ForumReply.created_at <= day_end, (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ).scalar() or 0 chart_replies.append(replies_count) current_date += timedelta(days=1) chart_data = { 'labels': chart_labels, 'topics': chart_topics, 'replies': chart_replies } return render_template( 'admin/forum_analytics.html', stats=stats, unanswered_topics=unanswered_topics, user_rankings=user_rankings, category_stats=category_stats, chart_data=chart_data, start_date=start_date_str, end_date=end_date_str, category_labels=ForumTopic.CATEGORY_LABELS ) finally: db.close() @bp.route('/admin/forum/export-activity') @login_required def admin_forum_export_activity(): """Export forum activity to CSV""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403 from flask import Response from datetime import timedelta import csv import io # Date range from query params end_date_str = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d')) start_date_str = request.args.get('start_date', (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')) try: end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) start_date = datetime.strptime(start_date_str, '%Y-%m-%d') except ValueError: end_date = datetime.now() start_date = end_date - timedelta(days=30) db = SessionLocal() try: # Get all topics in date range topics = db.query(ForumTopic).filter( ForumTopic.created_at >= start_date, ForumTopic.created_at <= end_date ).order_by(ForumTopic.created_at.desc()).all() # Get all replies in date range replies = db.query(ForumReply).filter( ForumReply.created_at >= start_date, ForumReply.created_at <= end_date ).order_by(ForumReply.created_at.desc()).all() # Create CSV output = io.StringIO() writer = csv.writer(output) # Header writer.writerow([ 'Typ', 'ID', 'Tytul/Temat', 'Autor', 'Email', 'Kategoria', 'Status', 'Data utworzenia', 'Odpowiedzi', 'Wyswietlenia' ]) # Topics for topic in topics: reply_count = len([r for r in topic.replies if not r.is_deleted]) writer.writerow([ 'Temat', topic.id, topic.title, topic.author.name or topic.author.email.split('@')[0], topic.author.email, ForumTopic.CATEGORY_LABELS.get(topic.category, topic.category), ForumTopic.STATUS_LABELS.get(topic.status, topic.status), topic.created_at.strftime('%Y-%m-%d %H:%M'), reply_count, topic.views_count or 0 ]) # Replies for reply in replies: writer.writerow([ 'Odpowiedz', reply.id, f'Re: {reply.topic.title}' if reply.topic else f'Temat #{reply.topic_id}', reply.author.name or reply.author.email.split('@')[0], reply.author.email, '', 'Rozwiazanie' if reply.is_solution else '', reply.created_at.strftime('%Y-%m-%d %H:%M'), '', '' ]) output.seek(0) filename = f'forum_activity_{start_date_str}_{end_date_str}.csv' return Response( output.getvalue(), mimetype='text/csv', headers={ 'Content-Disposition': f'attachment; filename={filename}', 'Content-Type': 'text/csv; charset=utf-8' } ) finally: db.close() @bp.route('/admin/forum/topic//move', methods=['POST']) @login_required def admin_move_topic(topic_id): """Move topic to different category""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403 data = request.get_json() or {} new_category = data.get('category') if not new_category or new_category not in ForumTopic.CATEGORIES: return jsonify({'success': False, 'error': 'Nieprawidlowa kategoria'}), 400 db = SessionLocal() try: topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first() if not topic: return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404 old_category = topic.category topic.category = new_category db.commit() logger.info(f"Admin {current_user.email} moved topic #{topic_id} from {old_category} to {new_category}") return jsonify({ 'success': True, 'message': f"Temat przeniesiony do: {ForumTopic.CATEGORY_LABELS.get(new_category, new_category)}", 'old_category': old_category, 'new_category': new_category }) finally: db.close() @bp.route('/admin/forum/merge-topics', methods=['POST']) @login_required def admin_merge_topics(): """Merge multiple topics into one""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403 data = request.get_json() or {} target_id = data.get('target_id') source_ids = data.get('source_ids', []) if not target_id or not source_ids: return jsonify({'success': False, 'error': 'Brak wymaganych parametrow'}), 400 # Ensure target is not in sources source_ids = [sid for sid in source_ids if sid != target_id] if not source_ids: return jsonify({'success': False, 'error': 'Brak tematow do polaczenia'}), 400 db = SessionLocal() try: # Get target topic target = db.query(ForumTopic).filter(ForumTopic.id == target_id).first() if not target: return jsonify({'success': False, 'error': 'Temat docelowy nie istnieje'}), 404 # Get source topics sources = db.query(ForumTopic).filter(ForumTopic.id.in_(source_ids)).all() if not sources: return jsonify({'success': False, 'error': 'Nie znaleziono tematow zrodlowych'}), 404 merged_count = 0 for source in sources: # Create a reply from the source topic content merge_note = ForumReply( topic_id=target_id, author_id=source.author_id, content=f"[Polaczony temat: {source.title}]\n\n{source.content}", created_at=source.created_at ) db.add(merge_note) # Move all replies from source to target for reply in source.replies: reply.topic_id = target_id # Soft-delete the source topic source.is_deleted = True source.deleted_at = datetime.now() source.deleted_by = current_user.id merged_count += 1 # Update target topic timestamp target.updated_at = datetime.now() db.commit() logger.info(f"Admin {current_user.email} merged {merged_count} topics into #{target_id}") return jsonify({ 'success': True, 'message': f"Polaczono {merged_count} tematow", 'merged_count': merged_count, 'target_id': target_id }) except Exception as e: db.rollback() logger.error(f"Error merging topics: {e}") return jsonify({'success': False, 'error': 'Wystapil blad podczas laczenia'}), 500 finally: db.close() @bp.route('/admin/forum/search') @login_required def admin_forum_search(): """Search all forum content (including deleted) - moderators only""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403 query = request.args.get('q', '').strip() include_deleted = request.args.get('deleted') == '1' if not query or len(query) < 2: return jsonify({ 'success': True, 'results': [], 'count': 0, 'message': 'Wpisz co najmniej 2 znaki' }) db = SessionLocal() try: search_term = f'%{query}%' results = [] # Search topics topic_query = db.query(ForumTopic).filter( (ForumTopic.title.ilike(search_term)) | (ForumTopic.content.ilike(search_term)) ) if not include_deleted: topic_query = topic_query.filter( (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) ) topics = topic_query.order_by(ForumTopic.created_at.desc()).limit(50).all() for topic in topics: results.append({ 'type': 'topic', 'id': topic.id, 'title': topic.title, 'content_preview': topic.content[:150] + '...' if len(topic.content) > 150 else topic.content, 'author_name': topic.author.name or topic.author.email.split('@')[0], 'author_id': topic.author_id, 'created_at': topic.created_at.isoformat(), 'is_deleted': topic.is_deleted or False, 'category': topic.category, 'category_label': ForumTopic.CATEGORY_LABELS.get(topic.category, topic.category), 'url': url_for('.forum_topic', topic_id=topic.id) }) # Search replies reply_query = db.query(ForumReply).filter( ForumReply.content.ilike(search_term) ) if not include_deleted: reply_query = reply_query.filter( (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ) replies = reply_query.order_by(ForumReply.created_at.desc()).limit(50).all() for reply in replies: results.append({ 'type': 'reply', 'id': reply.id, 'title': f'Re: {reply.topic.title}' if reply.topic else f'Odpowiedz #{reply.id}', 'content_preview': reply.content[:150] + '...' if len(reply.content) > 150 else reply.content, 'author_name': reply.author.name or reply.author.email.split('@')[0], 'author_id': reply.author_id, 'created_at': reply.created_at.isoformat(), 'is_deleted': reply.is_deleted or False, 'topic_id': reply.topic_id, 'url': url_for('.forum_topic', topic_id=reply.topic_id) + f'#reply-{reply.id}' }) # Sort by date results.sort(key=lambda x: x['created_at'], reverse=True) return jsonify({ 'success': True, 'results': results[:100], # Limit to 100 total results 'count': len(results), 'query': query }) finally: db.close() @bp.route('/admin/forum/user//activity') @login_required def admin_user_forum_activity(user_id): """Get detailed forum activity for a specific user - moderators only""" if not current_user.can_moderate_forum(): return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403 from sqlalchemy import func db = SessionLocal() try: # Get user user = db.query(User).filter(User.id == user_id).first() if not user: return jsonify({'success': False, 'error': 'Uzytkownik nie istnieje'}), 404 # Get user's topics topics = db.query(ForumTopic).filter( ForumTopic.author_id == user_id ).order_by(ForumTopic.created_at.desc()).limit(20).all() # Get user's replies replies = db.query(ForumReply).filter( ForumReply.author_id == user_id ).order_by(ForumReply.created_at.desc()).limit(30).all() # Stats topic_count = db.query(func.count(ForumTopic.id)).filter( ForumTopic.author_id == user_id, (ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None)) ).scalar() or 0 reply_count = db.query(func.count(ForumReply.id)).filter( ForumReply.author_id == user_id, (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ).scalar() or 0 solution_count = db.query(func.count(ForumReply.id)).filter( ForumReply.author_id == user_id, ForumReply.is_solution == True, (ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None)) ).scalar() or 0 # Build activity log activity = [] for topic in topics: activity.append({ 'type': 'topic', 'action': 'Utworzyl temat', 'title': topic.title, 'id': topic.id, 'created_at': topic.created_at.isoformat(), 'is_deleted': topic.is_deleted or False, 'category': topic.category, 'url': url_for('.forum_topic', topic_id=topic.id) }) for reply in replies: activity.append({ 'type': 'reply', 'action': 'Odpowiedzial w' if reply.is_solution else 'Odpowiedzial w', 'title': reply.topic.title if reply.topic else f'Temat #{reply.topic_id}', 'id': reply.id, 'topic_id': reply.topic_id, 'created_at': reply.created_at.isoformat(), 'is_deleted': reply.is_deleted or False, 'is_solution': reply.is_solution or False, 'url': url_for('.forum_topic', topic_id=reply.topic_id) + f'#reply-{reply.id}' }) # Sort by date activity.sort(key=lambda x: x['created_at'], reverse=True) return jsonify({ 'success': True, 'user': { 'id': user.id, 'name': user.name or user.email.split('@')[0], 'email': user.email }, 'stats': { 'topics': topic_count, 'replies': reply_count, 'solutions': solution_count, 'total_posts': topic_count + reply_count }, 'activity': activity[:50] # Limit to 50 most recent }) finally: db.close()