Fix article editor
This commit is contained in:
+78
-4
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify, current_app, send_from_directory
|
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
|
from flask_login import login_required, current_user
|
||||||
@@ -226,8 +227,47 @@ def all_tickets():
|
|||||||
@login_required
|
@login_required
|
||||||
@it_required
|
@it_required
|
||||||
def kb_list():
|
def kb_list():
|
||||||
articles = KnowledgeBase.query.order_by(KnowledgeBase.created_at.desc()).all()
|
q = request.args.get('q', '').strip()
|
||||||
return render_template('admin/kb_list.html', articles=articles)
|
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 ──────────────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -452,14 +492,48 @@ def kb_delete(article_id):
|
|||||||
|
|
||||||
# ─── Activity Log ─────────────────────────────────────────────────────────────
|
# ─── Activity Log ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@admin_bp.route('/logs')
|
@admin_bp.route('/logs', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def activity_logs():
|
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)
|
page = request.args.get('page', 1, type=int)
|
||||||
logs = ActivityLog.query.order_by(ActivityLog.created_at.desc()).paginate(
|
logs = ActivityLog.query.order_by(ActivityLog.created_at.desc()).paginate(
|
||||||
page=page, per_page=50)
|
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():
|
def _roles():
|
||||||
|
|||||||
+42
-5
@@ -261,8 +261,19 @@ def update_ticket(ticket_id):
|
|||||||
changes.append(f'priority: {old_priority} → {new_priority}')
|
changes.append(f'priority: {old_priority} → {new_priority}')
|
||||||
|
|
||||||
if new_assigned != old_assigned:
|
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
|
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}')
|
changes.append(f'assigned_to: {old_assigned} → {new_assigned}')
|
||||||
notify_assignment(ticket, current_user)
|
notify_assignment(ticket, current_user)
|
||||||
|
|
||||||
@@ -339,9 +350,35 @@ def mark_notifications_read():
|
|||||||
@tickets_bp.route('/kb')
|
@tickets_bp.route('/kb')
|
||||||
@login_required
|
@login_required
|
||||||
def knowledge_base():
|
def knowledge_base():
|
||||||
articles = KnowledgeBase.query.filter_by(is_published=True).order_by(
|
q = request.args.get('q', '').strip()
|
||||||
KnowledgeBase.view_count.desc()).all()
|
category = request.args.get('category', '')
|
||||||
return render_template('tickets/knowledge_base.html', articles=articles)
|
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/<int:article_id>')
|
@tickets_bp.route('/kb/<int:article_id>')
|
||||||
@@ -367,4 +404,4 @@ def _categories():
|
|||||||
return [TicketCategory.HARDWARE, TicketCategory.SOFTWARE,
|
return [TicketCategory.HARDWARE, TicketCategory.SOFTWARE,
|
||||||
TicketCategory.NETWORK, TicketCategory.ACCESS,
|
TicketCategory.NETWORK, TicketCategory.ACCESS,
|
||||||
TicketCategory.EMAIL, TicketCategory.PRINTER,
|
TicketCategory.EMAIL, TicketCategory.PRINTER,
|
||||||
TicketCategory.PHONE, TicketCategory.SECURITY, TicketCategory.OTHER]
|
TicketCategory.PHONE, TicketCategory.SECURITY, TicketCategory.OTHER]
|
||||||
@@ -3,8 +3,95 @@
|
|||||||
{% block page_title %}Activity Logs{% endblock %}
|
{% block page_title %}Activity Logs{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- ── Stats + Cleanup panel ───────────────────────────────────────────────── -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header"><i class="bi bi-bar-chart me-2"></i>Log Summary</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="font-size:28px;font-weight:700;color:var(--text);">{{ '{:,}'.format(total) }}</div>
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:12px;">total log entries</div>
|
||||||
|
{% if oldest %}
|
||||||
|
<div style="font-size:12px;color:var(--muted);">
|
||||||
|
<i class="bi bi-calendar3 me-1"></i>Oldest entry:
|
||||||
|
<span style="color:var(--text);">{{ oldest.strftime('%b %d, %Y') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<hr style="border-color:var(--border);margin:12px 0;">
|
||||||
|
<div style="font-size:12px;color:var(--muted);">Entries eligible for cleanup:</div>
|
||||||
|
<div class="mt-2" style="font-size:13px;">
|
||||||
|
{% for days, count in counts_by_retention.items() %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center py-1"
|
||||||
|
style="border-bottom:1px solid var(--border);">
|
||||||
|
<span style="color:var(--muted);">Older than {{ days }} days</span>
|
||||||
|
<span style="font-weight:600;color:{% if count > 0 %}var(--warning){% else %}var(--success){% endif %};">
|
||||||
|
{{ '{:,}'.format(count) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cleanup -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-trash3 me-1"></i>Clean Up Old Logs
|
||||||
|
<span style="font-size:11px;color:var(--muted);font-weight:400;">— permanently deletes entries older than the selected threshold</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p style="font-size:13px;color:var(--muted);margin-bottom:16px;">
|
||||||
|
Select a retention period. All activity log entries older than that threshold will be
|
||||||
|
<strong style="color:var(--danger);">permanently deleted</strong> and cannot be recovered.
|
||||||
|
This action is itself logged before the deletion runs.
|
||||||
|
</p>
|
||||||
|
<div class="row g-3">
|
||||||
|
{% 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 %}
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<form method="POST" action="{{ url_for('admin.activity_logs') }}"
|
||||||
|
onsubmit="return confirmCleanup({{ days }}, {{ counts_by_retention[days] }})">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<input type="hidden" name="days" value="{{ days }}"/>
|
||||||
|
<button type="submit" class="btn btn-sm w-100 d-flex flex-column align-items-center py-3 gap-1"
|
||||||
|
style="background:rgba(var(--{{ colour }}-rgb, 100,100,100),.08);
|
||||||
|
border:1px solid rgba(var(--{{ colour }}-rgb, 100,100,100),.25);
|
||||||
|
color:var(--{{ colour }});"
|
||||||
|
{% if counts_by_retention[days] == 0 %}disabled title="No entries to delete"{% endif %}>
|
||||||
|
<i class="bi {{ icon }}" style="font-size:20px;"></i>
|
||||||
|
<span style="font-size:13px;font-weight:600;">{{ label }}</span>
|
||||||
|
<span style="font-size:11px;opacity:.75;">
|
||||||
|
{{ '{:,}'.format(counts_by_retention[days]) }} to delete
|
||||||
|
</span>
|
||||||
|
<span style="font-size:10px;opacity:.6;text-align:center;line-height:1.3;">{{ hint }}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Log table ────────────────────────────────────────────────────────────── -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><i class="bi bi-list-ul me-2"></i>System Activity Log</div>
|
<div class="card-header">
|
||||||
|
<i class="bi bi-list-ul me-2"></i>System Activity Log
|
||||||
|
<span style="font-size:12px;color:var(--muted);">
|
||||||
|
— page {{ logs.page }} of {{ logs.pages }} ({{ '{:,}'.format(logs.total) }} entries)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<table class="table mb-0">
|
<table class="table mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -25,17 +112,28 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="mono" style="font-size:11px;
|
<span class="mono" style="font-size:11px;
|
||||||
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){% else %}var(--muted){% endif %};">
|
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 }}
|
{{ log.action }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:13px;">
|
<td style="font-size:13px;">
|
||||||
{% if log.user %}{{ log.user.full_name }}<br><span style="font-size:11px;color:var(--muted);">{{ log.user.email }}</span>{% else %}<span style="color:var(--muted);">System</span>{% endif %}
|
{% if log.user %}
|
||||||
|
{{ log.user.full_name }}<br>
|
||||||
|
<span style="font-size:11px;color:var(--muted);">{{ log.user.email }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--muted);">System</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:12px;color:var(--muted);">
|
<td style="font-size:12px;color:var(--muted);">
|
||||||
{{ log.entity_type or '—' }}{% if log.entity_id %} <span class="mono">#{{ log.entity_id }}</span>{% endif %}
|
{{ log.entity_type or '—' }}{% if log.entity_id %} <span class="mono">#{{ log.entity_id }}</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:12px;color:var(--muted);max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
<td style="font-size:12px;color:var(--muted);max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
||||||
|
title="{{ log.details or '' }}">
|
||||||
{{ log.details or '—' }}
|
{{ log.details or '—' }}
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;">
|
<td style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;">
|
||||||
@@ -53,8 +151,13 @@
|
|||||||
<li class="page-item"><a class="page-link" href="{{ url_for('admin.activity_logs', page=logs.prev_num) }}">‹ Prev</a></li>
|
<li class="page-item"><a class="page-link" href="{{ url_for('admin.activity_logs', page=logs.prev_num) }}">‹ Prev</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for p in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
{% for p in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
{% if p %}<li class="page-item {% if p==logs.page %}active{% endif %}"><a class="page-link" href="{{ url_for('admin.activity_logs', page=p) }}">{{ p }}</a></li>
|
{% if p %}
|
||||||
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
<li class="page-item {% if p==logs.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('admin.activity_logs', page=p) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if logs.has_next %}
|
{% if logs.has_next %}
|
||||||
<li class="page-item"><a class="page-link" href="{{ url_for('admin.activity_logs', page=logs.next_num) }}">Next ›</a></li>
|
<li class="page-item"><a class="page-link" href="{{ url_for('admin.activity_logs', page=logs.next_num) }}">Next ›</a></li>
|
||||||
@@ -62,6 +165,17 @@
|
|||||||
</ul></nav>
|
</ul></nav>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
<script>
|
||||||
|
function confirmCleanup(days, count) {
|
||||||
|
if (count === 0) return false;
|
||||||
|
return confirm(
|
||||||
|
'Delete ' + count.toLocaleString() + ' log entr' + (count === 1 ? 'y' : 'ies') +
|
||||||
|
' older than ' + days + ' days?\n\nThis cannot be undone.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -246,7 +246,7 @@ tinymce.init({
|
|||||||
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
|
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', blobInfo.blob(), blobInfo.filename());
|
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(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 }); })
|
.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 }));
|
.catch(err => reject({ message: String(err), remove: true }));
|
||||||
@@ -374,7 +374,7 @@ function deleteAtt(attId, deleteUrl, filename) {
|
|||||||
const row = document.getElementById('att-row-' + attId);
|
const row = document.getElementById('att-row-' + attId);
|
||||||
if (row) { row.style.opacity = '0.4'; row.style.pointerEvents = 'none'; }
|
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(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
@@ -445,6 +445,7 @@ function doSave(asDraft) {
|
|||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', window.location.pathname);
|
xhr.open('POST', window.location.pathname);
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
|
xhr.setRequestHeader('X-CSRFToken', document.querySelector('meta[name="csrf-token"]').content);
|
||||||
|
|
||||||
xhr.upload.onprogress = ev => {
|
xhr.upload.onprogress = ev => {
|
||||||
if (ev.lengthComputable && pendingFiles.length > 0) {
|
if (ev.lengthComputable && pendingFiles.length > 0) {
|
||||||
|
|||||||
@@ -3,9 +3,60 @@
|
|||||||
{% block page_title %}Knowledge Base Management{% endblock %}
|
{% block page_title %}Knowledge Base Management{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<form method="GET" action="{{ url_for('admin.kb_list') }}" class="card mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col-12 col-sm">
|
||||||
|
<div style="position:relative;">
|
||||||
|
<i class="bi bi-search" style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--muted);font-size:13px;"></i>
|
||||||
|
<input type="text" name="q" value="{{ q }}" placeholder="Search title or tags…"
|
||||||
|
class="form-control form-control-sm" style="padding-left:30px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-auto">
|
||||||
|
<select name="category" class="form-select form-select-sm" style="min-width:140px;">
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat }}" {% if cat == sel_category %}selected{% endif %}>
|
||||||
|
{{ cat.replace('_',' ').title() }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-auto">
|
||||||
|
<select name="author_id" class="form-select form-select-sm" style="min-width:140px;">
|
||||||
|
<option value="">All authors</option>
|
||||||
|
{% for author in authors %}
|
||||||
|
<option value="{{ author.id }}" {% if sel_author_id == author.id|string %}selected{% endif %}>
|
||||||
|
{{ author.full_name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-auto">
|
||||||
|
<select name="published" class="form-select form-select-sm" style="min-width:130px;">
|
||||||
|
<option value="" {% if sel_published == '' %}selected{% endif %}>All statuses</option>
|
||||||
|
<option value="yes" {% if sel_published == 'yes' %}selected{% endif %}>Published</option>
|
||||||
|
<option value="no" {% if sel_published == 'no' %}selected{% endif %}>Draft</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||||
|
{% if q or sel_category or sel_author_id or sel_published %}
|
||||||
|
<a href="{{ url_for('admin.kb_list') }}" class="btn btn-secondary btn-sm ms-1">Clear</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex align-items-center justify-content-between">
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
<span><i class="bi bi-journal-text me-2"></i>Articles <span style="font-size:12px;color:var(--muted);">({{ articles|length }})</span></span>
|
<span><i class="bi bi-journal-text me-2"></i>Articles
|
||||||
|
<span style="font-size:12px;color:var(--muted);">({{ articles|length }} result{{ 's' if articles|length != 1 }})</span>
|
||||||
|
</span>
|
||||||
<a href="{{ url_for('admin.kb_new') }}" class="btn btn-primary btn-sm">
|
<a href="{{ url_for('admin.kb_new') }}" class="btn btn-primary btn-sm">
|
||||||
<i class="bi bi-plus-lg me-1"></i>New Article
|
<i class="bi bi-plus-lg me-1"></i>New Article
|
||||||
</a>
|
</a>
|
||||||
@@ -65,7 +116,12 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="p-5 text-center" style="color:var(--muted);">
|
<div class="p-5 text-center" style="color:var(--muted);">
|
||||||
<i class="bi bi-journal-x" style="font-size:40px;display:block;margin-bottom:12px;"></i>
|
<i class="bi bi-journal-x" style="font-size:40px;display:block;margin-bottom:12px;"></i>
|
||||||
No articles yet. <a href="{{ url_for('admin.kb_new') }}" style="color:var(--accent3);">Create your first article →</a>
|
{% if q or sel_category or sel_author_id or sel_published %}
|
||||||
|
No articles match your filters.
|
||||||
|
<a href="{{ url_for('admin.kb_list') }}" style="color:var(--accent3);display:block;margin-top:8px;">Clear filters</a>
|
||||||
|
{% else %}
|
||||||
|
No articles yet. <a href="{{ url_for('admin.kb_new') }}" style="color:var(--accent3);">Create your first article →</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -239,8 +239,25 @@
|
|||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
{% for h in history %}
|
{% for h in history %}
|
||||||
<div class="history-item">
|
<div class="history-item">
|
||||||
<strong>{{ h.field_name.replace('_',' ').title() }}</strong>
|
{# ── Field label ── #}
|
||||||
changed from <em>{{ h.old_value or '—' }}</em> to <em>{{ h.new_value or '—' }}</em>
|
{% if h.field_name == 'assigned_to' %}
|
||||||
|
<strong>Assigned To</strong>
|
||||||
|
{% elif h.field_name == 'status' %}
|
||||||
|
<strong>Status</strong>
|
||||||
|
{% elif h.field_name == 'priority' %}
|
||||||
|
<strong>Priority</strong>
|
||||||
|
{% else %}
|
||||||
|
<strong>{{ h.field_name.replace('_',' ').title() }}</strong>
|
||||||
|
{% 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 <em>{{ old }}</em> to <em>{{ new }}</em>
|
||||||
<br>
|
<br>
|
||||||
<span style="font-size:10px;">by {{ h.changer.full_name }} · {{ h.changed_at.strftime('%b %d %H:%M') }}</span>
|
<span style="font-size:10px;">by {{ h.changer.full_name }} · {{ h.changed_at.strftime('%b %d %H:%M') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,9 +5,51 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<form method="GET" action="{{ url_for('tickets.knowledge_base') }}" class="card mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col-12 col-sm">
|
||||||
|
<div style="position:relative;">
|
||||||
|
<i class="bi bi-search" style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--muted);font-size:13px;"></i>
|
||||||
|
<input type="text" name="q" value="{{ q }}" placeholder="Search articles…"
|
||||||
|
class="form-control form-control-sm" style="padding-left:30px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-auto">
|
||||||
|
<select name="category" class="form-select form-select-sm" style="min-width:140px;">
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat }}" {% if cat == sel_category %}selected{% endif %}>
|
||||||
|
{{ cat.replace('_',' ').title() }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-auto">
|
||||||
|
<select name="sort" class="form-select form-select-sm" style="min-width:120px;">
|
||||||
|
<option value="popular" {% if sort == 'popular' %}selected{% endif %}>Most viewed</option>
|
||||||
|
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Newest first</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||||
|
{% if q or sel_category or sort != 'popular' %}
|
||||||
|
<a href="{{ url_for('tickets.knowledge_base') }}" class="btn btn-secondary btn-sm ms-1">Clear</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex align-items-center justify-content-between">
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
<span><i class="bi bi-book me-2"></i>Self-Service Articles</span>
|
<span><i class="bi bi-book me-2"></i>Self-Service Articles
|
||||||
|
<span style="font-size:12px;color:var(--muted);">
|
||||||
|
({{ articles|length }} result{{ 's' if articles|length != 1 }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
{% if current_user.is_it_staff %}
|
{% if current_user.is_it_staff %}
|
||||||
<a href="{{ url_for('admin.kb_new') }}" class="btn btn-primary btn-sm">
|
<a href="{{ url_for('admin.kb_new') }}" class="btn btn-primary btn-sm">
|
||||||
<i class="bi bi-plus-lg me-1"></i>New Article
|
<i class="bi bi-plus-lg me-1"></i>New Article
|
||||||
@@ -25,8 +67,11 @@
|
|||||||
<div style="flex:1;">
|
<div style="flex:1;">
|
||||||
<div style="font-size:14px;font-weight:600;">{{ art.title }}</div>
|
<div style="font-size:14px;font-weight:600;">{{ art.title }}</div>
|
||||||
<div style="font-size:12px;color:var(--muted);margin-top:2px;">
|
<div style="font-size:12px;color:var(--muted);margin-top:2px;">
|
||||||
{% if art.category %}{{ art.category.replace('_',' ').title() }} · {% endif %}
|
{% if art.category %}<span style="background:rgba(0,180,216,.1);color:var(--accent3);padding:1px 6px;border-radius:4px;margin-right:6px;">{{ art.category.replace('_',' ').title() }}</span>{% endif %}
|
||||||
{{ art.updated_at.strftime('%b %d, %Y') }}
|
{{ art.updated_at.strftime('%b %d, %Y') }}
|
||||||
|
{% if art.tags %}
|
||||||
|
· {% for tag in art.tags.split(',')[:3] %}<span style="color:var(--muted);">#{{ tag.strip() }}</span>{% if not loop.last %} {% endif %}{% endfor %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:12px;color:var(--muted);flex-shrink:0;">
|
<div style="font-size:12px;color:var(--muted);flex-shrink:0;">
|
||||||
@@ -37,7 +82,8 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="p-5 text-center" style="color:var(--muted);">
|
<div class="p-5 text-center" style="color:var(--muted);">
|
||||||
<i class="bi bi-journal-x" style="font-size:40px;display:block;margin-bottom:12px;"></i>
|
<i class="bi bi-journal-x" style="font-size:40px;display:block;margin-bottom:12px;"></i>
|
||||||
No articles published yet.
|
No articles match your filters.
|
||||||
|
<a href="{{ url_for('tickets.knowledge_base') }}" style="color:var(--accent3);display:block;margin-top:8px;">Clear filters</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +101,25 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if categories %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header"><i class="bi bi-tags me-2"></i>Browse by Category</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<a href="{{ url_for('tickets.knowledge_base', q=q, sort=sort) }}"
|
||||||
|
class="badge me-1 mb-1 text-decoration-none"
|
||||||
|
style="background:{% if not sel_category %}var(--accent3){% else %}rgba(0,180,216,.15){% endif %};color:{% if not sel_category %}#fff{% else %}var(--accent3){% endif %};padding:5px 10px;font-size:12px;">
|
||||||
|
All
|
||||||
|
</a>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<a href="{{ url_for('tickets.knowledge_base', category=cat, q=q, sort=sort) }}"
|
||||||
|
class="badge me-1 mb-1 text-decoration-none"
|
||||||
|
style="background:{% if cat == sel_category %}var(--accent3){% else %}rgba(0,180,216,.15){% endif %};color:{% if cat == sel_category %}#fff{% else %}var(--accent3){% endif %};padding:5px 10px;font-size:12px;">
|
||||||
|
{{ cat.replace('_',' ').title() }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user