From bc9000aad9f4ee9903cb11118357f3c7726eba11 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 26 Mar 2026 17:34:10 -0400 Subject: [PATCH] Fix article editor --- app/routes/admin.py | 82 +++++++++++++- app/routes/tickets.py | 47 +++++++- app/templates/admin/activity_logs.html | 128 ++++++++++++++++++++-- app/templates/admin/kb_edit.html | 5 +- app/templates/admin/kb_list.html | 60 +++++++++- app/templates/tickets/detail.html | 21 +++- app/templates/tickets/knowledge_base.html | 73 +++++++++++- 7 files changed, 390 insertions(+), 26 deletions(-) diff --git a/app/routes/admin.py b/app/routes/admin.py index 5341d06..4c73fac 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,6 +1,7 @@ import logging import os import uuid +from datetime import datetime, timedelta from functools import wraps from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify, current_app, send_from_directory from flask_login import login_required, current_user @@ -226,8 +227,47 @@ def all_tickets(): @login_required @it_required def kb_list(): - articles = KnowledgeBase.query.order_by(KnowledgeBase.created_at.desc()).all() - return render_template('admin/kb_list.html', articles=articles) + q = request.args.get('q', '').strip() + category = request.args.get('category', '') + author_id = request.args.get('author_id', '', type=str) + published = request.args.get('published', '') # 'yes' | 'no' | '' + + query = KnowledgeBase.query + + if q: + query = query.filter( + KnowledgeBase.title.ilike(f'%{q}%') | + KnowledgeBase.tags.ilike(f'%{q}%') + ) + if category: + query = query.filter_by(category=category) + if author_id: + query = query.filter_by(author_id=int(author_id)) + if published == 'yes': + query = query.filter_by(is_published=True) + elif published == 'no': + query = query.filter_by(is_published=False) + + articles = query.order_by(KnowledgeBase.created_at.desc()).all() + + # Build filter option lists from existing data + categories = sorted({a.category for a in KnowledgeBase.query.with_entities(KnowledgeBase.category).distinct() if a.category}) + authors = User.query.filter( + User.id.in_( + db.session.query(KnowledgeBase.author_id).distinct() + ), + User.is_active == True, + ).order_by(User.full_name).all() + + return render_template('admin/kb_list.html', + articles = articles, + categories = categories, + authors = authors, + q = q, + sel_category = category, + sel_author_id = author_id, + sel_published = published, + ) # ── Helpers ────────────────────────────────────────────────────────────────── @@ -452,14 +492,48 @@ def kb_delete(article_id): # ─── Activity Log ───────────────────────────────────────────────────────────── -@admin_bp.route('/logs') +@admin_bp.route('/logs', methods=['GET', 'POST']) @login_required @admin_required def activity_logs(): + # ── Cleanup action ──────────────────────────────────────────────────────── + if request.method == 'POST': + days = request.form.get('days', type=int) + if days not in (7, 30, 60, 90): + flash('Invalid retention period selected.', 'danger') + return redirect(url_for('admin.activity_logs')) + + cutoff = datetime.utcnow() - timedelta(days=days) + deleted = ActivityLog.query.filter(ActivityLog.created_at < cutoff).delete() + db.session.commit() + log_action(current_user.id, 'activity_log_cleanup', 'activity_log', None, + f'deleted={deleted} older_than={days}_days cutoff={cutoff.strftime("%Y-%m-%d")}') + db.session.commit() + logger.info(f'[ACTIVITY LOG CLEANUP] deleted={deleted} days={days} by user_id={current_user.id}') + flash(f'Deleted {deleted:,} log entr{"y" if deleted == 1 else "ies"} older than {days} days.', 'success') + return redirect(url_for('admin.activity_logs')) + + # ── Stats for the summary bar ───────────────────────────────────────────── + from sqlalchemy import func + total = ActivityLog.query.count() + oldest = db.session.query(func.min(ActivityLog.created_at)).scalar() + counts_by_retention = {} + for days in (7, 30, 60, 90): + cutoff = datetime.utcnow() - timedelta(days=days) + counts_by_retention[days] = ActivityLog.query.filter( + ActivityLog.created_at < cutoff + ).count() + + # ── Paginated log listing ───────────────────────────────────────────────── page = request.args.get('page', 1, type=int) logs = ActivityLog.query.order_by(ActivityLog.created_at.desc()).paginate( page=page, per_page=50) - return render_template('admin/activity_logs.html', logs=logs) + return render_template('admin/activity_logs.html', + logs = logs, + total = total, + oldest = oldest, + counts_by_retention= counts_by_retention, + ) def _roles(): diff --git a/app/routes/tickets.py b/app/routes/tickets.py index 0e6fa19..42a5d70 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -261,8 +261,19 @@ def update_ticket(ticket_id): changes.append(f'priority: {old_priority} → {new_priority}') if new_assigned != old_assigned: + # Resolve user IDs to full names for human-readable history entries. + # None means unassigned. + def _user_label(uid): + if uid is None: + return 'Unassigned' + u = User.query.get(uid) + return u.full_name if u else f'User #{uid}' + ticket.assigned_to_id = new_assigned - log_ticket_history(ticket, 'assigned_to', str(old_assigned), str(new_assigned), current_user.id) + log_ticket_history(ticket, 'assigned_to', + _user_label(old_assigned), + _user_label(new_assigned), + current_user.id) changes.append(f'assigned_to: {old_assigned} → {new_assigned}') notify_assignment(ticket, current_user) @@ -339,9 +350,35 @@ def mark_notifications_read(): @tickets_bp.route('/kb') @login_required def knowledge_base(): - articles = KnowledgeBase.query.filter_by(is_published=True).order_by( - KnowledgeBase.view_count.desc()).all() - return render_template('tickets/knowledge_base.html', articles=articles) + q = request.args.get('q', '').strip() + category = request.args.get('category', '') + sort = request.args.get('sort', 'popular') # 'popular' | 'newest' + + query = KnowledgeBase.query.filter_by(is_published=True) + + if q: + query = query.filter( + KnowledgeBase.title.ilike(f'%{q}%') | + KnowledgeBase.tags.ilike(f'%{q}%') + ) + if category: + query = query.filter_by(category=category) + + if sort == 'newest': + query = query.order_by(KnowledgeBase.updated_at.desc()) + else: + query = query.order_by(KnowledgeBase.view_count.desc()) + + articles = query.all() + categories = sorted({a.category for a in KnowledgeBase.query.filter_by(is_published=True).with_entities(KnowledgeBase.category).distinct() if a.category}) + + return render_template('tickets/knowledge_base.html', + articles = articles, + categories = categories, + q = q, + sel_category = category, + sort = sort, + ) @tickets_bp.route('/kb/') @@ -367,4 +404,4 @@ def _categories(): return [TicketCategory.HARDWARE, TicketCategory.SOFTWARE, TicketCategory.NETWORK, TicketCategory.ACCESS, TicketCategory.EMAIL, TicketCategory.PRINTER, - TicketCategory.PHONE, TicketCategory.SECURITY, TicketCategory.OTHER] + TicketCategory.PHONE, TicketCategory.SECURITY, TicketCategory.OTHER] \ No newline at end of file diff --git a/app/templates/admin/activity_logs.html b/app/templates/admin/activity_logs.html index 4b04347..325f271 100644 --- a/app/templates/admin/activity_logs.html +++ b/app/templates/admin/activity_logs.html @@ -3,8 +3,95 @@ {% block page_title %}Activity Logs{% endblock %} {% block content %} + + +
+ + +
+
+
Log Summary
+
+
{{ '{:,}'.format(total) }}
+
total log entries
+ {% if oldest %} +
+ Oldest entry: + {{ oldest.strftime('%b %d, %Y') }} +
+ {% endif %} +
+
Entries eligible for cleanup:
+
+ {% for days, count in counts_by_retention.items() %} +
+ Older than {{ days }} days + + {{ '{:,}'.format(count) }} + +
+ {% endfor %} +
+
+
+
+ + +
+
+
+ Clean Up Old Logs + — permanently deletes entries older than the selected threshold +
+
+

+ Select a retention period. All activity log entries older than that threshold will be + permanently deleted and cannot be recovered. + This action is itself logged before the deletion runs. +

+
+ {% set options = [ + (7, 'danger', 'bi-lightning-charge', 'Last 7 days', 'Aggressive — keeps only the past week'), + (30, 'warning', 'bi-calendar-week', 'Last 30 days', 'Recommended for busy systems'), + (60, 'info', 'bi-calendar-month', 'Last 60 days', 'Balanced retention'), + (90, 'success', 'bi-calendar3', 'Last 90 days', 'Conservative — keeps 3 months'), + ] %} + {% for days, colour, icon, label, hint in options %} +
+
+ + + +
+
+ {% endfor %} +
+
+
+
+
+ +
-
System Activity Log
+
+ System Activity Log + + — page {{ logs.page }} of {{ logs.pages }} ({{ '{:,}'.format(logs.total) }} entries) + +
@@ -25,17 +112,28 @@ -
+ color:{% if 'create' in log.action %}var(--success) + {% elif 'delete' in log.action or 'deactivate' in log.action %}var(--danger) + {% elif 'update' in log.action or 'edit' in log.action or 'change' in log.action %}var(--warning) + {% elif 'login' in log.action %}var(--accent3) + {% elif 'cleanup' in log.action %}var(--danger) + {% else %}var(--muted){% endif %};"> {{ log.action }} - {% if log.user %}{{ log.user.full_name }}
{{ log.user.email }}{% else %}System{% endif %} + {% if log.user %} + {{ log.user.full_name }}
+ {{ log.user.email }} + {% else %} + System + {% endif %}
{{ log.entity_type or '—' }}{% if log.entity_id %} #{{ log.entity_id }}{% endif %} + {{ log.details or '—' }} @@ -53,8 +151,13 @@
  • ‹ Prev
  • {% endif %} {% for p in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %} - {% if p %}
  • {{ p }}
  • - {% else %}
  • {% endif %} + {% if p %} +
  • + {{ p }} +
  • + {% else %} +
  • + {% endif %} {% endfor %} {% if logs.has_next %}
  • Next ›
  • @@ -62,6 +165,17 @@ {% endif %} + -{% endblock %} + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/kb_edit.html b/app/templates/admin/kb_edit.html index 4ee0c24..3c09a45 100644 --- a/app/templates/admin/kb_edit.html +++ b/app/templates/admin/kb_edit.html @@ -246,7 +246,7 @@ tinymce.init({ images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => { const fd = new FormData(); fd.append('file', blobInfo.blob(), blobInfo.filename()); - fetch('/admin/kb/upload-image', { method: 'POST', credentials: 'same-origin', body: fd }) + fetch('/admin/kb/upload-image', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content }, body: fd }) .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(j => { if (j.location) resolve(j.location); else reject({ message: j.error || 'Upload failed', remove: true }); }) .catch(err => reject({ message: String(err), remove: true })); @@ -374,7 +374,7 @@ function deleteAtt(attId, deleteUrl, filename) { const row = document.getElementById('att-row-' + attId); if (row) { row.style.opacity = '0.4'; row.style.pointerEvents = 'none'; } - fetch(deleteUrl, { method: 'POST', credentials: 'same-origin' }) + fetch(deleteUrl, { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content } }) .then(r => r.json()) .then(data => { if (data.ok) { @@ -445,6 +445,7 @@ function doSave(asDraft) { const xhr = new XMLHttpRequest(); xhr.open('POST', window.location.pathname); xhr.withCredentials = true; + xhr.setRequestHeader('X-CSRFToken', document.querySelector('meta[name="csrf-token"]').content); xhr.upload.onprogress = ev => { if (ev.lengthComputable && pendingFiles.length > 0) { diff --git a/app/templates/admin/kb_list.html b/app/templates/admin/kb_list.html index 0aaf8f5..5a8f1f0 100644 --- a/app/templates/admin/kb_list.html +++ b/app/templates/admin/kb_list.html @@ -3,9 +3,60 @@ {% block page_title %}Knowledge Base Management{% endblock %} {% block content %} + + +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + + {% if q or sel_category or sel_author_id or sel_published %} + Clear + {% endif %} +
    +
    +
    +
    +
    - Articles ({{ articles|length }}) + Articles + ({{ articles|length }} result{{ 's' if articles|length != 1 }}) + New Article @@ -65,7 +116,12 @@ {% else %}
    - No articles yet. Create your first article → + {% if q or sel_category or sel_author_id or sel_published %} + No articles match your filters. + Clear filters + {% else %} + No articles yet. Create your first article → + {% endif %}
    {% endif %}
    diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index 29755df..8203e95 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -239,8 +239,25 @@
    {% for h in history %}
    - {{ h.field_name.replace('_',' ').title() }} - changed from {{ h.old_value or '—' }} to {{ h.new_value or '—' }} + {# ── Field label ── #} + {% if h.field_name == 'assigned_to' %} + Assigned To + {% elif h.field_name == 'status' %} + Status + {% elif h.field_name == 'priority' %} + Priority + {% else %} + {{ h.field_name.replace('_',' ').title() }} + {% endif %} + + {# ── Values — format status/priority nicely; keep others as-is ── #} + {% set old = h.old_value or 'Unassigned' %} + {% set new = h.new_value or 'Unassigned' %} + {% if h.field_name in ('status', 'priority') %} + {% set old = old.replace('_',' ').title() %} + {% set new = new.replace('_',' ').title() %} + {% endif %} + changed from {{ old }} to {{ new }}
    by {{ h.changer.full_name }} · {{ h.changed_at.strftime('%b %d %H:%M') }}
    diff --git a/app/templates/tickets/knowledge_base.html b/app/templates/tickets/knowledge_base.html index c964ecb..12d2d10 100644 --- a/app/templates/tickets/knowledge_base.html +++ b/app/templates/tickets/knowledge_base.html @@ -5,9 +5,51 @@ {% block content %}
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    + +
    +
    + + {% if q or sel_category or sort != 'popular' %} + Clear + {% endif %} +
    +
    +
    +
    + + {% if categories %} +
    +
    Browse by Category
    +
    + + All + + {% for cat in categories %} + + {{ cat.replace('_',' ').title() }} + + {% endfor %} +
    +
    + {% endif %}
    -{% endblock %} +{% endblock %} \ No newline at end of file