From c5f724f95442a22ca7324461e059436fdd0cbd03 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Sat, 31 Jan 2026 19:11:29 +0100 Subject: [PATCH] feat: Add forum search, markdown, user stats, and admin bulk actions New features implemented: - Forum search with title/content filtering - Solution filter (topics with marked solutions) - Quote reply functionality with @mention - @mentions parsing and notifications - Simple markdown formatting (bold, italic, code, quotes, lists) - User forum statistics tooltip (topics, replies, solutions, reactions) - Admin bulk actions (pin/unpin, lock/unlock, status change, delete) Files changed: - blueprints/forum/routes.py: user_forum_stats, admin_forum_bulk_action endpoints - templates/forum/topic.html: user stats tooltips, markdown CSS - templates/forum/index.html: search box, solution filter - templates/admin/forum.html: bulk selection checkboxes and action bar - utils/markdown.py: simple forum markdown parser - utils/notifications.py: @mention notification parsing Co-Authored-By: Claude Opus 4.5 --- app.py | 4 + blueprints/__init__.py | 2 + blueprints/forum/routes.py | 218 +++++++++++++++++++++++++++++++- templates/admin/forum.html | 210 ++++++++++++++++++++++++++++++ templates/forum/index.html | 35 +++-- templates/forum/topic.html | 253 ++++++++++++++++++++++++++++++++++++- utils/markdown.py | 126 ++++++++++++++++++ utils/notifications.py | 67 ++++++++++ 8 files changed, 900 insertions(+), 15 deletions(-) create mode 100644 utils/markdown.py diff --git a/app.py b/app.py index e0ca62f..6c7c299 100644 --- a/app.py +++ b/app.py @@ -244,6 +244,10 @@ def ensure_url_filter(url): return f'https://{url}' return url +# Register forum markdown filter +from utils.markdown import register_markdown_filter +register_markdown_filter(app) + # Initialize extensions from centralized extensions.py from extensions import csrf, limiter, login_manager diff --git a/blueprints/__init__.py b/blueprints/__init__.py index 8021d1d..8bef3a4 100644 --- a/blueprints/__init__.py +++ b/blueprints/__init__.py @@ -215,6 +215,8 @@ def register_blueprints(app): 'topic_edit_history': 'forum.topic_edit_history', 'reply_edit_history': 'forum.reply_edit_history', 'admin_deleted_content': 'forum.admin_deleted_content', + 'user_forum_stats': 'forum.user_forum_stats', + 'admin_forum_bulk_action': 'forum.admin_forum_bulk_action', }) logger.info("Created forum endpoint aliases") except ImportError as e: diff --git a/blueprints/forum/routes.py b/blueprints/forum/routes.py index 655671a..eeecb08 100644 --- a/blueprints/forum/routes.py +++ b/blueprints/forum/routes.py @@ -21,7 +21,8 @@ from utils.notifications import ( create_forum_reply_notification, create_forum_reaction_notification, create_forum_solution_notification, - create_forum_report_notification + create_forum_report_notification, + parse_mentions_and_notify ) # Constants @@ -47,11 +48,13 @@ except ImportError: @bp.route('/forum') @login_required def forum_index(): - """Forum - list of topics with category/status filters""" + """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: @@ -66,6 +69,25 @@ def forum_index(): 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(), @@ -84,6 +106,8 @@ def forum_index(): 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, @@ -160,6 +184,20 @@ def forum_new_topic(): 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: @@ -305,6 +343,20 @@ def forum_reply(topic_id): except Exception as e: logger.warning(f"Failed to send reply 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}") + flash('Odpowiedź dodana.', 'success') return redirect(url_for('.forum_topic', topic_id=topic_id)) finally: @@ -512,6 +564,88 @@ def admin_forum_change_status(topic_id): db.close() +@bp.route('/admin/forum/bulk-action', methods=['POST']) +@login_required +def admin_forum_bulk_action(): + """Perform bulk action on multiple topics (admin only)""" + if not current_user.is_admin: + 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) # ============================================================ @@ -1303,3 +1437,83 @@ def admin_deleted_content(): ) finally: db.close() + + +# ============================================================ +# USER STATISTICS +# ============================================================ + +@bp.route('/forum/user//stats') +@login_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.full_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() diff --git a/templates/admin/forum.html b/templates/admin/forum.html index e083b9f..ed5f8e3 100755 --- a/templates/admin/forum.html +++ b/templates/admin/forum.html @@ -342,6 +342,68 @@ display: none; } } + + /* Bulk actions */ + .bulk-checkbox { + width: 18px; + height: 18px; + cursor: pointer; + } + + .bulk-actions-bar { + display: none; + position: sticky; + top: 0; + background: var(--primary); + color: white; + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--radius); + margin-bottom: var(--spacing-md); + justify-content: space-between; + align-items: center; + z-index: 100; + box-shadow: var(--shadow); + } + + .bulk-actions-bar.visible { + display: flex; + } + + .bulk-actions-bar .selected-count { + font-weight: 600; + } + + .bulk-actions-bar .bulk-actions { + display: flex; + gap: var(--spacing-sm); + } + + .bulk-actions-bar .bulk-btn { + background: rgba(255,255,255,0.2); + border: none; + color: white; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius); + cursor: pointer; + font-size: var(--font-size-sm); + transition: var(--transition); + } + + .bulk-actions-bar .bulk-btn:hover { + background: rgba(255,255,255,0.3); + } + + .bulk-actions-bar .bulk-btn.danger { + background: var(--error); + } + + .bulk-actions-bar .bulk-btn.danger:hover { + background: #c53030; + } + + .topics-table tr.selected { + background: #dbeafe; + } {% endblock %} @@ -426,10 +488,41 @@

Tematy

+ + +
+ 0 zaznaczonych +
+ + + + + + +
+
+ {% if topics %} + @@ -441,6 +534,9 @@ {% for topic in topics %} +
Tytul Kategoria Autor
+ +
{{ topic.title }} @@ -791,4 +887,118 @@ showMessage('Błąd połączenia', 'error'); } } + + // ============================================================ + // BULK ACTIONS + // ============================================================ + + function getSelectedTopicIds() { + return Array.from(document.querySelectorAll('.topic-checkbox:checked')).map(cb => parseInt(cb.value)); + } + + function updateBulkSelection() { + const selected = getSelectedTopicIds(); + const bar = document.getElementById('bulkActionsBar'); + const count = document.getElementById('selectedCount'); + const selectAll = document.getElementById('selectAll'); + + count.textContent = selected.length; + + if (selected.length > 0) { + bar.classList.add('visible'); + } else { + bar.classList.remove('visible'); + } + + // Update select all checkbox state + const allCheckboxes = document.querySelectorAll('.topic-checkbox'); + selectAll.checked = allCheckboxes.length > 0 && selected.length === allCheckboxes.length; + selectAll.indeterminate = selected.length > 0 && selected.length < allCheckboxes.length; + + // Highlight selected rows + document.querySelectorAll('.topics-table tbody tr').forEach(row => { + const checkbox = row.querySelector('.topic-checkbox'); + if (checkbox && checkbox.checked) { + row.classList.add('selected'); + } else { + row.classList.remove('selected'); + } + }); + } + + function toggleSelectAll() { + const selectAll = document.getElementById('selectAll'); + document.querySelectorAll('.topic-checkbox').forEach(cb => { + cb.checked = selectAll.checked; + }); + updateBulkSelection(); + } + + async function bulkAction(action) { + const topicIds = getSelectedTopicIds(); + if (topicIds.length === 0) { + showToast('Zaznacz tematy', 'warning'); + return; + } + + let confirmMessage = ''; + let payload = { topic_ids: topicIds, action: action }; + + switch (action) { + case 'pin': + confirmMessage = `Przypnij ${topicIds.length} tematów?`; + break; + case 'unpin': + confirmMessage = `Odepnij ${topicIds.length} tematów?`; + break; + case 'lock': + confirmMessage = `Zablokuj ${topicIds.length} tematów?`; + break; + case 'unlock': + confirmMessage = `Odblokuj ${topicIds.length} tematów?`; + break; + case 'status': + const status = document.getElementById('bulkStatusSelect').value; + if (!status) return; + payload.status = status; + confirmMessage = `Zmień status ${topicIds.length} tematów na "${status}"?`; + document.getElementById('bulkStatusSelect').value = ''; + break; + case 'delete': + confirmMessage = `Uwaga! Ta operacja jest nieodwracalna.

Usunąć ${topicIds.length} tematów wraz ze wszystkimi odpowiedziami?`; + break; + default: + return; + } + + const confirmed = await showConfirm(confirmMessage, { + title: 'Akcja zbiorcza', + icon: action === 'delete' ? '⚠️' : '❓', + okText: action === 'delete' ? 'Usuń' : 'OK', + okClass: action === 'delete' ? 'btn-danger' : 'btn-primary' + }); + + if (!confirmed) return; + + try { + const response = await fetch('/admin/forum/bulk-action', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + if (data.success) { + showToast(data.message || 'Operacja wykonana', 'success'); + setTimeout(() => location.reload(), 1000); + } else { + showToast(data.error || 'Wystąpił błąd', 'error'); + } + } catch (error) { + showToast('Błąd połączenia', 'error'); + } + } {% endblock %} diff --git a/templates/forum/index.html b/templates/forum/index.html index 594e451..b6b67cf 100755 --- a/templates/forum/index.html +++ b/templates/forum/index.html @@ -355,19 +355,29 @@
- +
- -
- Kategoria: - Wszystkie +
+ + + {% if search_query %} + + {% endif %} +
- --> + +
- 💬 Uwagi rozwojowe - Zapraszamy do podzielenia się swoimi opiniami + Wszystkie + ✓ Z rozwiązaniem
+ +
+{% if search_query %} +
+ Wyniki wyszukiwania dla: {{ search_query }} ({{ total_topics }} wyników) +
+{% endif %} + {% if topics %}
{% for topic in topics %} @@ -394,6 +410,9 @@ {{ status_labels.get(topic.status, 'Nowy') }} + {% if topic.replies and topic.replies|selectattr('is_solution')|list %} + ✓ Rozwiązanie + {% endif %} {{ topic.title }}
diff --git a/templates/forum/topic.html b/templates/forum/topic.html index 0554161..d83e0b0 100755 --- a/templates/forum/topic.html +++ b/templates/forum/topic.html @@ -144,7 +144,126 @@ .topic-content { line-height: 1.8; color: var(--text-primary); - white-space: pre-wrap; + } + + /* Markdown styles */ + .forum-quote { + border-left: 3px solid var(--primary); + background: var(--background); + padding: var(--spacing-sm) var(--spacing-md); + margin: var(--spacing-sm) 0; + font-style: italic; + color: var(--text-secondary); + } + + .forum-list { + margin: var(--spacing-sm) 0; + padding-left: var(--spacing-xl); + } + + .forum-list li { + margin: var(--spacing-xs) 0; + } + + .forum-code { + background: var(--background); + padding: 2px 6px; + border-radius: 3px; + font-family: monospace; + font-size: 0.9em; + } + + .forum-code-block { + background: #1e293b; + color: #e2e8f0; + padding: var(--spacing-md); + border-radius: var(--radius); + overflow-x: auto; + margin: var(--spacing-sm) 0; + } + + .forum-code-block code { + font-family: monospace; + font-size: var(--font-size-sm); + } + + .forum-link { + color: var(--primary); + text-decoration: underline; + } + + .forum-mention { + background: #dbeafe; + color: #1e40af; + padding: 1px 4px; + border-radius: 3px; + font-weight: 500; + } + + /* User stats tooltip */ + .user-stats-trigger { + cursor: pointer; + position: relative; + font-weight: 500; + } + + .user-stats-trigger:hover { + color: var(--primary); + } + + .user-stats-tooltip { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: var(--card-bg, #fff); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; + min-width: 200px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 1000; + font-size: 13px; + margin-bottom: 8px; + } + + .user-stats-tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: var(--border); + } + + .user-stats-tooltip .stats-header { + font-weight: 600; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + } + + .user-stats-tooltip .stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + } + + .user-stats-tooltip .stat-item { + display: flex; + justify-content: space-between; + color: var(--text-secondary); + } + + .user-stats-tooltip .stat-value { + font-weight: 600; + color: var(--text-primary); + } + + .user-stats-tooltip .loading { + color: var(--text-secondary); + text-align: center; } /* Attachments */ @@ -865,7 +984,9 @@ - {{ topic.author.name or topic.author.email.split('@')[0] }} + + {{ topic.author.name or topic.author.email.split('@')[0] }} + {% if topic.is_ai_generated %} @@ -895,7 +1016,7 @@
-
{{ topic.content }}
+
{{ topic.content|forum_markdown }}
@@ -954,7 +1075,9 @@
{{ (reply.author.name or reply.author.email)[0].upper() }}
- {{ reply.author.name or reply.author.email.split('@')[0] }} + + {{ reply.author.name or reply.author.email.split('@')[0] }} + {% if reply.is_ai_generated %} @@ -995,7 +1118,7 @@ {% if reply.is_deleted %}
[Ta odpowiedź została usunięta]
{% else %} -
{{ reply.content }}
+
{{ reply.content|forum_markdown }}
{% if reply.attachments %}
@@ -1039,6 +1162,10 @@ Usuń {% endif %} +