feat: Add forum admin tools (analytics, search, move, merge)
New admin features: - Analytics dashboard with stats, charts (Chart.js), user rankings - CSV export of forum activity with date range - Topic category move functionality - Merge multiple topics into one - Admin search across all posts (including deleted) - User activity log with stats New endpoints: - GET /admin/forum/analytics - GET /admin/forum/export-activity - POST /admin/forum/topic/<id>/move - POST /admin/forum/merge-topics - GET /admin/forum/search - GET /admin/forum/user/<id>/activity Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c5f724f954
commit
e8586b3e4e
@ -217,6 +217,13 @@ def register_blueprints(app):
|
||||
'admin_deleted_content': 'forum.admin_deleted_content',
|
||||
'user_forum_stats': 'forum.user_forum_stats',
|
||||
'admin_forum_bulk_action': 'forum.admin_forum_bulk_action',
|
||||
# Admin analytics & tools
|
||||
'admin_forum_analytics': 'forum.admin_forum_analytics',
|
||||
'admin_forum_export_activity': 'forum.admin_forum_export_activity',
|
||||
'admin_move_topic': 'forum.admin_move_topic',
|
||||
'admin_merge_topics': 'forum.admin_merge_topics',
|
||||
'admin_forum_search': 'forum.admin_forum_search',
|
||||
'admin_user_forum_activity': 'forum.admin_user_forum_activity',
|
||||
})
|
||||
logger.info("Created forum endpoint aliases")
|
||||
except ImportError as e:
|
||||
|
||||
@ -1517,3 +1517,624 @@ def user_forum_stats(user_id):
|
||||
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.is_admin:
|
||||
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.is_admin:
|
||||
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/<int:topic_id>/move', methods=['POST'])
|
||||
@login_required
|
||||
def admin_move_topic(topic_id):
|
||||
"""Move topic to different category"""
|
||||
if not current_user.is_admin:
|
||||
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.is_admin:
|
||||
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) - admin only"""
|
||||
if not current_user.is_admin:
|
||||
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/<int:user_id>/activity')
|
||||
@login_required
|
||||
def admin_user_forum_activity(user_id):
|
||||
"""Get detailed forum activity for a specific user - admin only"""
|
||||
if not current_user.is_admin:
|
||||
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()
|
||||
|
||||
@ -414,7 +414,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div style="display: flex; gap: var(--spacing-md); margin-bottom: var(--spacing-xl);">
|
||||
<div style="display: flex; gap: var(--spacing-md); margin-bottom: var(--spacing-xl); flex-wrap: wrap;">
|
||||
<a href="{{ url_for('admin_forum_analytics') }}" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: var(--spacing-xs);">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
Analityka
|
||||
</a>
|
||||
<a href="{{ url_for('admin_forum_reports') }}" class="btn btn-outline" style="display: inline-flex; align-items: center; gap: var(--spacing-xs);">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"/></svg>
|
||||
Zgłoszenia
|
||||
@ -425,6 +429,25 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Admin Search Section -->
|
||||
<div class="section" style="margin-bottom: var(--spacing-xl);">
|
||||
<h2 style="margin-bottom: var(--spacing-md);">Wyszukiwarka</h2>
|
||||
<div style="display: flex; gap: var(--spacing-md); align-items: center; flex-wrap: wrap;">
|
||||
<input type="text" id="adminSearchQuery" class="form-input" placeholder="Szukaj we wszystkich postach..." style="flex: 1; min-width: 200px;">
|
||||
<label style="display: flex; align-items: center; gap: var(--spacing-xs); font-size: var(--font-size-sm);">
|
||||
<input type="checkbox" id="includeDeleted"> Uwzględnij usunięte
|
||||
</label>
|
||||
<button class="btn btn-primary" onclick="adminSearch()">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
Szukaj
|
||||
</button>
|
||||
</div>
|
||||
<div id="searchResults" style="display: none; margin-top: var(--spacing-lg);">
|
||||
<h3 style="font-size: var(--font-size-base); margin-bottom: var(--spacing-md);">Wyniki wyszukiwania (<span id="resultCount">0</span>)</h3>
|
||||
<div id="searchResultsList" style="max-height: 400px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
@ -512,6 +535,9 @@
|
||||
<option value="resolved">Rozwiązany</option>
|
||||
<option value="rejected">Odrzucony</option>
|
||||
</select>
|
||||
<button class="bulk-btn" onclick="openMergeModal()" title="Połącz zaznaczone tematy">
|
||||
🔗 Połącz
|
||||
</button>
|
||||
<button class="bulk-btn danger" onclick="bulkAction('delete')" title="Usuń zaznaczone">
|
||||
🗑️ Usuń
|
||||
</button>
|
||||
@ -579,6 +605,20 @@
|
||||
<path d="M7 11V7a5 5 0 0110 0v4"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon"
|
||||
onclick="openMoveModal({{ topic.id }}, '{{ topic.title|e }}', '{{ topic.category or 'question' }}')"
|
||||
title="Przenies do innej kategorii">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon"
|
||||
onclick="showUserActivity({{ topic.author_id }})"
|
||||
title="Aktywnosc autora">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon danger"
|
||||
onclick="deleteTopic({{ topic.id }}, '{{ topic.title|e }}')"
|
||||
title="Usun temat">
|
||||
@ -674,6 +714,86 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move Topic Modal -->
|
||||
<div class="modal-overlay" id="moveTopicModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">Przenies temat</div>
|
||||
<div class="modal-body">
|
||||
<p id="moveModalTopicTitle" style="margin-bottom: var(--spacing-md); color: var(--text-secondary);"></p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nowa kategoria</label>
|
||||
<select class="form-select" id="newCategorySelect">
|
||||
<option value="feature_request">Propozycja funkcji</option>
|
||||
<option value="bug">Blad</option>
|
||||
<option value="question">Pytanie</option>
|
||||
<option value="announcement">Ogloszenie</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" onclick="closeMoveModal()">Anuluj</button>
|
||||
<button class="btn btn-primary" onclick="saveMoveCategory()">Przenies</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merge Topics Modal -->
|
||||
<div class="modal-overlay" id="mergeTopicsModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">Polacz tematy</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: var(--spacing-md); color: var(--text-secondary);">
|
||||
Wybierz temat docelowy. Pozostale tematy zostana do niego przeniesione.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Temat docelowy</label>
|
||||
<select class="form-select" id="targetTopicSelect"></select>
|
||||
</div>
|
||||
<div id="mergeInfo" style="font-size: var(--font-size-sm); color: var(--text-muted); margin-top: var(--spacing-md);"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" onclick="closeMergeModal()">Anuluj</button>
|
||||
<button class="btn btn-primary" onclick="executeMerge()">Polacz</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Activity Modal -->
|
||||
<div class="modal-overlay" id="userActivityModal">
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
Aktywnosc uzytkownika: <span id="activityUserName"></span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="activityStats" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--spacing-md); margin-bottom: var(--spacing-lg);">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--primary);" id="statTopics">0</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Tematy</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--success);" id="statReplies">0</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Odpowiedzi</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--warning);" id="statSolutions">0</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Rozwiazania</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--info);" id="statTotal">0</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Lacznie</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 style="margin-bottom: var(--spacing-sm);">Historia aktywnosci</h4>
|
||||
<div id="activityLog" style="max-height: 300px; overflow-y: auto;">
|
||||
<p class="text-muted">Ladowanie...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" onclick="closeActivityModal()">Zamknij</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
|
||||
|
||||
<style>
|
||||
@ -683,6 +803,56 @@
|
||||
.toast.warning { border-left-color: var(--warning); }
|
||||
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
|
||||
|
||||
/* Search results */
|
||||
.search-result-item {
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
.search-result-item:hover { background: var(--background); }
|
||||
.search-result-item.deleted { opacity: 0.6; }
|
||||
.search-result-item .result-type {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background);
|
||||
}
|
||||
.search-result-item .result-type.topic { background: #dbeafe; color: #1e40af; }
|
||||
.search-result-item .result-type.reply { background: #dcfce7; color: #166534; }
|
||||
.search-result-item .result-content { flex: 1; }
|
||||
.search-result-item .result-title { font-weight: 500; color: var(--text-primary); text-decoration: none; }
|
||||
.search-result-item .result-title:hover { color: var(--primary); }
|
||||
.search-result-item .result-preview { font-size: var(--font-size-sm); color: var(--text-secondary); margin-top: var(--spacing-xs); }
|
||||
.search-result-item .result-meta { font-size: var(--font-size-xs); color: var(--text-muted); margin-top: var(--spacing-xs); }
|
||||
|
||||
/* Activity log */
|
||||
.activity-item {
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.activity-item:last-child { border-bottom: none; }
|
||||
.activity-item .activity-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.activity-item .activity-icon.topic { background: #dbeafe; }
|
||||
.activity-item .activity-icon.reply { background: #dcfce7; }
|
||||
.activity-item .activity-icon.solution { background: #fef3c7; }
|
||||
.activity-item a { color: var(--text-primary); text-decoration: none; }
|
||||
.activity-item a:hover { color: var(--primary); }
|
||||
.activity-item .activity-date { font-size: var(--font-size-xs); color: var(--text-muted); margin-left: auto; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -1001,4 +1171,270 @@
|
||||
showToast('Błąd połączenia', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ADMIN SEARCH
|
||||
// ============================================================
|
||||
|
||||
async function adminSearch() {
|
||||
const query = document.getElementById('adminSearchQuery').value.trim();
|
||||
const includeDeleted = document.getElementById('includeDeleted').checked;
|
||||
|
||||
if (query.length < 2) {
|
||||
showToast('Wpisz co najmniej 2 znaki', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/admin/forum/search?q=${encodeURIComponent(query)}${includeDeleted ? '&deleted=1' : ''}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
displaySearchResults(data.results, data.count);
|
||||
} else {
|
||||
showToast(data.error || 'Błąd wyszukiwania', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Błąd połączenia', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function displaySearchResults(results, count) {
|
||||
const container = document.getElementById('searchResults');
|
||||
const list = document.getElementById('searchResultsList');
|
||||
const countSpan = document.getElementById('resultCount');
|
||||
|
||||
countSpan.textContent = count;
|
||||
|
||||
if (results.length === 0) {
|
||||
list.innerHTML = '<p class="text-muted" style="padding: var(--spacing-lg);">Brak wyników</p>';
|
||||
} else {
|
||||
list.innerHTML = results.map(r => `
|
||||
<div class="search-result-item ${r.is_deleted ? 'deleted' : ''}">
|
||||
<span class="result-type ${r.type}">${r.type === 'topic' ? 'Temat' : 'Odpowiedź'}</span>
|
||||
<div class="result-content">
|
||||
<a href="${r.url}" class="result-title">${r.title}</a>
|
||||
${r.is_deleted ? '<span style="color: var(--error); font-size: var(--font-size-xs);"> (usunięty)</span>' : ''}
|
||||
<div class="result-preview">${r.content_preview}</div>
|
||||
<div class="result-meta">
|
||||
${r.author_name} • ${new Date(r.created_at).toLocaleString('pl-PL')}
|
||||
${r.category_label ? ` • ${r.category_label}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
container.style.display = 'block';
|
||||
}
|
||||
|
||||
// Search on Enter key
|
||||
document.getElementById('adminSearchQuery').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') adminSearch();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// MOVE TOPIC
|
||||
// ============================================================
|
||||
|
||||
let currentMoveTopicId = null;
|
||||
|
||||
function openMoveModal(topicId, title, currentCategory) {
|
||||
currentMoveTopicId = topicId;
|
||||
document.getElementById('moveModalTopicTitle').textContent = title;
|
||||
document.getElementById('newCategorySelect').value = currentCategory;
|
||||
document.getElementById('moveTopicModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeMoveModal() {
|
||||
document.getElementById('moveTopicModal').classList.remove('active');
|
||||
currentMoveTopicId = null;
|
||||
}
|
||||
|
||||
async function saveMoveCategory() {
|
||||
if (!currentMoveTopicId) return;
|
||||
|
||||
const newCategory = document.getElementById('newCategorySelect').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/forum/topic/${currentMoveTopicId}/move`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ category: newCategory })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast(data.error || 'Wystąpił błąd', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Błąd połączenia', 'error');
|
||||
}
|
||||
|
||||
closeMoveModal();
|
||||
}
|
||||
|
||||
document.getElementById('moveTopicModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'moveTopicModal') closeMoveModal();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// MERGE TOPICS
|
||||
// ============================================================
|
||||
|
||||
function openMergeModal() {
|
||||
const topicIds = getSelectedTopicIds();
|
||||
if (topicIds.length < 2) {
|
||||
showToast('Zaznacz co najmniej 2 tematy do połączenia', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const select = document.getElementById('targetTopicSelect');
|
||||
select.innerHTML = '';
|
||||
|
||||
// Populate select with selected topics
|
||||
topicIds.forEach(id => {
|
||||
const row = document.querySelector(`tr[data-topic-id="${id}"]`);
|
||||
const title = row ? row.querySelector('.topic-title a').textContent : `Temat #${id}`;
|
||||
const option = document.createElement('option');
|
||||
option.value = id;
|
||||
option.textContent = title;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
document.getElementById('mergeInfo').textContent = `${topicIds.length} tematów zaznaczonych. Wybierz temat docelowy - pozostałe zostaną do niego przeniesione.`;
|
||||
document.getElementById('mergeTopicsModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeMergeModal() {
|
||||
document.getElementById('mergeTopicsModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async function executeMerge() {
|
||||
const topicIds = getSelectedTopicIds();
|
||||
const targetId = parseInt(document.getElementById('targetTopicSelect').value);
|
||||
const sourceIds = topicIds.filter(id => id !== targetId);
|
||||
|
||||
if (sourceIds.length === 0) {
|
||||
showToast('Nie można połączyć - brak tematów źródłowych', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await showConfirm(`Połączyć ${sourceIds.length} tematów z wybranym tematem docelowym?<br><br><small>Ta operacja przeniesie wszystkie odpowiedzi i usunie tematy źródłowe.</small>`, {
|
||||
icon: '🔗',
|
||||
title: 'Łączenie tematów',
|
||||
okText: 'Połącz'
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/forum/merge-topics', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target_id: targetId,
|
||||
source_ids: sourceIds
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast(data.error || 'Wystąpił błąd', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Błąd połączenia', 'error');
|
||||
}
|
||||
|
||||
closeMergeModal();
|
||||
}
|
||||
|
||||
document.getElementById('mergeTopicsModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'mergeTopicsModal') closeMergeModal();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// USER ACTIVITY
|
||||
// ============================================================
|
||||
|
||||
async function showUserActivity(userId) {
|
||||
document.getElementById('userActivityModal').classList.add('active');
|
||||
document.getElementById('activityLog').innerHTML = '<p class="text-muted">Ładowanie...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/forum/user/${userId}/activity`, {
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
displayUserActivity(data);
|
||||
} else {
|
||||
showToast(data.error || 'Błąd ładowania aktywności', 'error');
|
||||
closeActivityModal();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Błąd połączenia', 'error');
|
||||
closeActivityModal();
|
||||
}
|
||||
}
|
||||
|
||||
function displayUserActivity(data) {
|
||||
document.getElementById('activityUserName').textContent = data.user.name;
|
||||
document.getElementById('statTopics').textContent = data.stats.topics;
|
||||
document.getElementById('statReplies').textContent = data.stats.replies;
|
||||
document.getElementById('statSolutions').textContent = data.stats.solutions;
|
||||
document.getElementById('statTotal').textContent = data.stats.total_posts;
|
||||
|
||||
const log = document.getElementById('activityLog');
|
||||
if (data.activity.length === 0) {
|
||||
log.innerHTML = '<p class="text-muted">Brak aktywności</p>';
|
||||
} else {
|
||||
log.innerHTML = data.activity.map(a => `
|
||||
<div class="activity-item">
|
||||
<span class="activity-icon ${a.type} ${a.is_solution ? 'solution' : ''}">
|
||||
${a.type === 'topic' ? '📝' : (a.is_solution ? '✓' : '💬')}
|
||||
</span>
|
||||
<div>
|
||||
<span>${a.action}</span>
|
||||
<a href="${a.url}">${a.title}</a>
|
||||
${a.is_deleted ? '<span style="color: var(--error);"> (usunięty)</span>' : ''}
|
||||
</div>
|
||||
<span class="activity-date">${new Date(a.created_at).toLocaleString('pl-PL')}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function closeActivityModal() {
|
||||
document.getElementById('userActivityModal').classList.remove('active');
|
||||
}
|
||||
|
||||
document.getElementById('userActivityModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'userActivityModal') closeActivityModal();
|
||||
});
|
||||
|
||||
// Close all modals on Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeMoveModal();
|
||||
closeMergeModal();
|
||||
closeActivityModal();
|
||||
}
|
||||
});
|
||||
{% endblock %}
|
||||
|
||||
541
templates/admin/forum_analytics.html
Normal file
541
templates/admin/forum_analytics.html
Normal file
@ -0,0 +1,541 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Analityka Forum - Norda Biznes Partner{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.admin-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.highlight {
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stat-sublabel {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stat-card.warning .stat-value { color: var(--warning); }
|
||||
.stat-card.success .stat-value { color: var(--success); }
|
||||
.stat-card.info .stat-value { color: var(--info); }
|
||||
|
||||
.section {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section h2 .badge {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: var(--spacing-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.topic-link {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.topic-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-category {
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.badge-feature_request { background: #dbeafe; color: #1e40af; border-color: #93c5fd; }
|
||||
.badge-bug { background: #fee2e2; color: #991b1b; border-color: #fca5a5; }
|
||||
.badge-question { background: #dcfce7; color: #166534; border-color: #86efac; }
|
||||
.badge-announcement { background: #fef3c7; color: #92400e; border-color: #fcd34d; }
|
||||
|
||||
.user-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.rank-position {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.rank-1 { background: #fef3c7; color: #92400e; }
|
||||
.rank-2 { background: #e5e7eb; color: #374151; }
|
||||
.rank-3 { background: #fde68a; color: #78350f; }
|
||||
.rank-default { background: var(--background); color: var(--text-secondary); }
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.activity-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.activity-indicator .icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.activity-indicator .icon.topics { background: var(--primary); }
|
||||
.activity-indicator .icon.replies { background: var(--success); }
|
||||
|
||||
/* Date range picker */
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
background: var(--background);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.date-range input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Stats comparison */
|
||||
.stat-compare {
|
||||
font-size: var(--font-size-xs);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stat-compare.up { color: var(--success); }
|
||||
.stat-compare.down { color: var(--error); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-header">
|
||||
<div>
|
||||
<h1>Analityka Forum</h1>
|
||||
<p class="text-muted">Statystyki, trendy i aktywnosc uzytkownikow</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('admin_forum') }}" class="btn btn-outline">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||
Moderacja
|
||||
</a>
|
||||
<a href="{{ url_for('admin_forum_export_activity') }}?start_date={{ start_date }}&end_date={{ end_date }}" class="btn btn-primary">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
Eksportuj CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div class="section" style="padding: var(--spacing-md) var(--spacing-lg);">
|
||||
<form method="GET" style="display: flex; align-items: center; gap: var(--spacing-lg); flex-wrap: wrap;">
|
||||
<div class="date-range">
|
||||
<label>Od:</label>
|
||||
<input type="date" name="start_date" value="{{ start_date }}" onchange="this.form.submit()">
|
||||
</div>
|
||||
<div class="date-range">
|
||||
<label>Do:</label>
|
||||
<input type="date" name="end_date" value="{{ end_date }}" onchange="this.form.submit()">
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--spacing-sm);">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="setDateRange(7)">7 dni</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="setDateRange(30)">30 dni</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="setDateRange(90)">90 dni</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Overview Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card highlight">
|
||||
<div class="stat-value">{{ stats.total_topics }}</div>
|
||||
<div class="stat-label">Tematow lacznie</div>
|
||||
<div class="stat-sublabel">+{{ stats.topics_this_month }} w tym miesiacu</div>
|
||||
</div>
|
||||
<div class="stat-card highlight">
|
||||
<div class="stat-value">{{ stats.total_replies }}</div>
|
||||
<div class="stat-label">Odpowiedzi lacznie</div>
|
||||
<div class="stat-sublabel">+{{ stats.replies_this_month }} w tym miesiacu</div>
|
||||
</div>
|
||||
<div class="stat-card info">
|
||||
<div class="stat-value">{{ stats.active_users_7d }}</div>
|
||||
<div class="stat-label">Aktywni (7 dni)</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.avg_response_time }}</div>
|
||||
<div class="stat-label">Sredni czas odpowiedzi</div>
|
||||
<div class="stat-sublabel">do pierwszej odpowiedzi</div>
|
||||
</div>
|
||||
<div class="stat-card {% if stats.unanswered_topics > 0 %}warning{% else %}success{% endif %}">
|
||||
<div class="stat-value">{{ stats.unanswered_topics }}</div>
|
||||
<div class="stat-label">Bez odpowiedzi</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="stat-value">{{ stats.resolved_topics }}</div>
|
||||
<div class="stat-label">Rozwiazanych</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Chart -->
|
||||
<div class="section">
|
||||
<h2>
|
||||
Aktywnosc w czasie
|
||||
<div style="display: flex; gap: var(--spacing-md);">
|
||||
<span class="activity-indicator"><span class="icon topics"></span> Tematy</span>
|
||||
<span class="activity-indicator"><span class="icon replies"></span> Odpowiedzi</span>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="chart-container">
|
||||
<canvas id="activityChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Topics Without Replies -->
|
||||
<div class="section">
|
||||
<h2>
|
||||
Tematy bez odpowiedzi
|
||||
<span class="badge {% if unanswered_topics|length > 0 %}badge-bug{% else %}badge-question{% endif %}">{{ unanswered_topics|length }}</span>
|
||||
</h2>
|
||||
{% if unanswered_topics %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tytul</th>
|
||||
<th>Kategoria</th>
|
||||
<th>Autor</th>
|
||||
<th>Data</th>
|
||||
<th>Oczekuje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for topic in unanswered_topics %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('forum_topic', topic_id=topic.id) }}" class="topic-link">
|
||||
{{ topic.title }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-category badge-{{ topic.category or 'question' }}">
|
||||
{{ category_labels.get(topic.category, 'Pytanie') }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ topic.author.name or topic.author.email.split('@')[0] }}</td>
|
||||
<td>{{ topic.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td>{{ topic.days_waiting }} dni</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<p>Wszystkie tematy maja odpowiedzi!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- User Rankings -->
|
||||
<div class="section">
|
||||
<h2>Ranking uzytkownikow</h2>
|
||||
{% if user_rankings %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Uzytkownik</th>
|
||||
<th>Tematy</th>
|
||||
<th>Odpowiedzi</th>
|
||||
<th>Rozwiazania</th>
|
||||
<th>Reakcje</th>
|
||||
<th>Suma</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in user_rankings %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="rank-position {% if loop.index <= 3 %}rank-{{ loop.index }}{% else %}rank-default{% endif %}">
|
||||
{{ loop.index }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="user-rank">
|
||||
<div class="user-avatar">{{ (user.name or user.email)[0]|upper }}</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ user.name or user.email.split('@')[0] }}</span>
|
||||
<span class="user-email">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ user.topic_count }}</td>
|
||||
<td>{{ user.reply_count }}</td>
|
||||
<td>{{ user.solution_count }}</td>
|
||||
<td>{{ user.reaction_count }}</td>
|
||||
<td><strong>{{ user.total_score }}</strong></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<p>Brak danych o aktywnosci uzytkownikow</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Category Stats -->
|
||||
<div class="section">
|
||||
<h2>Statystyki kategorii</h2>
|
||||
<div class="stats-grid" style="margin-bottom: 0;">
|
||||
{% for cat, count in category_stats.items() %}
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size: var(--font-size-2xl);">{{ count }}</div>
|
||||
<div class="stat-label">
|
||||
<span class="badge badge-category badge-{{ cat }}">{{ category_labels.get(cat, cat) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
const chartData = {{ chart_data|tojson|safe }};
|
||||
|
||||
// Activity Chart
|
||||
const ctx = document.getElementById('activityChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: chartData.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tematy',
|
||||
data: chartData.topics,
|
||||
borderColor: 'rgb(79, 70, 229)',
|
||||
backgroundColor: 'rgba(79, 70, 229, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Odpowiedzi',
|
||||
data: chartData.replies,
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Date range helpers
|
||||
function setDateRange(days) {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - days);
|
||||
|
||||
document.querySelector('input[name="start_date"]').value = formatDate(start);
|
||||
document.querySelector('input[name="end_date"]').value = formatDate(end);
|
||||
document.querySelector('form').submit();
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user