From 3ba7eae39026f4c026e2ddce176cef2e88cd1b56 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 25 Mar 2026 12:45:07 -0400 Subject: [PATCH] Enhance Article editor --- app/models.py | 24 ++ app/routes/admin.py | 140 +++++++- app/templates/admin/kb_edit.html | 495 +++++++++++++++++++++++--- app/templates/admin/kb_list.html | 22 +- app/templates/tickets/kb_article.html | 49 ++- 5 files changed, 677 insertions(+), 53 deletions(-) diff --git a/app/models.py b/app/models.py index c2a36e0..96e3388 100644 --- a/app/models.py +++ b/app/models.py @@ -262,3 +262,27 @@ class KnowledgeBase(db.Model): def __repr__(self): return f'' + + +class KBAttachment(db.Model): + """Files and images attached to a Knowledge Base article.""" + __tablename__ = 'kb_attachments' + + id = db.Column(db.Integer, primary_key=True) + article_id = db.Column(db.Integer, db.ForeignKey('knowledge_base.id'), nullable=False) + filename = db.Column(db.String(256), nullable=False) # original name + stored_name = db.Column(db.String(256), nullable=False) # UUID-based on disk + mime_type = db.Column(db.String(100)) + file_size = db.Column(db.Integer) + uploaded_by = db.Column(db.Integer, db.ForeignKey('users.id')) + uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) + + article = db.relationship('KnowledgeBase', backref=db.backref('attachments', lazy='dynamic', cascade='all, delete-orphan')) + uploader = db.relationship('User', foreign_keys=[uploaded_by]) + + @property + def url(self): + return f'/admin/kb/files/{self.stored_name}' + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/app/routes/admin.py b/app/routes/admin.py index 072355e..7a0fd0a 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,10 +1,13 @@ import logging +import os +import uuid from functools import wraps -from flask import Blueprint, render_template, redirect, url_for, flash, request, abort +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 werkzeug.utils import secure_filename from app import db from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase, - UserRole, TicketStatus) + KBAttachment, UserRole, TicketStatus) from app.services.log_service import log_action admin_bp = Blueprint('admin', __name__, url_prefix='/admin') @@ -193,6 +196,94 @@ def kb_list(): return render_template('admin/kb_list.html', articles=articles) +# ── Helpers ────────────────────────────────────────────────────────────────── + +_KB_ALLOWED_EXT = { + 'png', 'jpg', 'jpeg', 'gif', 'webp', # images + 'pdf', 'doc', 'docx', 'xls', 'xlsx', # documents + 'ppt', 'pptx', 'txt', 'csv', 'zip', 'log', # misc +} + +def _kb_allowed(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in _KB_ALLOWED_EXT + +def _save_kb_file(file, article_id): + """Save an uploaded file and return a KBAttachment (not yet committed).""" + filename = secure_filename(file.filename) + ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else 'bin' + stored_name = f"{uuid.uuid4().hex}.{ext}" + upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads') + os.makedirs(upload_dir, exist_ok=True) + filepath = os.path.join(upload_dir, stored_name) + file.save(filepath) + return KBAttachment( + article_id = article_id, + filename = filename, + stored_name = stored_name, + mime_type = file.content_type, + file_size = os.path.getsize(filepath), + uploaded_by = current_user.id, + ) + + +# ── TinyMCE image upload endpoint ───────────────────────────────────────────── + +@admin_bp.route('/kb/upload-image', methods=['POST']) +@login_required +@it_required +def kb_upload_image(): + """ + TinyMCE image upload handler. + Returns JSON: {"location": ""} on success. + """ + f = request.files.get('file') + if not f or not f.filename: + return jsonify({'error': 'No file provided'}), 400 + + ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else '' + if ext not in {'png', 'jpg', 'jpeg', 'gif', 'webp'}: + return jsonify({'error': 'Only image files are accepted (PNG, JPG, GIF, WEBP)'}), 400 + + stored_name = f"{uuid.uuid4().hex}.{ext}" + upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads') + os.makedirs(upload_dir, exist_ok=True) + f.save(os.path.join(upload_dir, stored_name)) + logger.info(f'[KB IMAGE UPLOAD] stored_name={stored_name} by user_id={current_user.id}') + return jsonify({'location': f'/admin/kb/files/{stored_name}'}) + + +# ── File-serve route (images embedded in articles + attachment downloads) ───── + +@admin_bp.route('/kb/files/') +@login_required +def kb_serve_file(stored_name): + """Serve a KB attachment file. Login required — no public access.""" + upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads') + return send_from_directory(upload_dir, stored_name) + + +# ── Delete a single KB attachment ───────────────────────────────────────────── + +@admin_bp.route('/kb//attachments//delete', methods=['POST']) +@login_required +@it_required +def kb_delete_attachment(article_id, att_id): + att = KBAttachment.query.filter_by(id=att_id, article_id=article_id).first_or_404() + upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads') + filepath = os.path.join(upload_dir, att.stored_name) + if os.path.exists(filepath): + os.remove(filepath) + log_action(current_user.id, 'kb_attachment_delete', 'kb_attachment', att.id, + f'article_id={article_id} filename={att.filename}') + logger.info(f'[KB ATTACHMENT DELETE] att_id={att.id} article_id={article_id} by user_id={current_user.id}') + db.session.delete(att) + db.session.commit() + flash(f'Attachment "{att.filename}" deleted.', 'success') + return redirect(url_for('admin.kb_edit', article_id=article_id)) + + +# ── Create article ───────────────────────────────────────────────────────────── + @admin_bp.route('/kb/new', methods=['GET', 'POST']) @login_required @it_required @@ -204,18 +295,30 @@ def kb_new(): category = request.form.get('category', ''), tags = request.form.get('tags', ''), author_id = current_user.id, - is_published= bool(request.form.get('is_published')), + is_published= bool(request.form.get('is_published')) and not bool(request.form.get('_save_as_draft')), ) db.session.add(article) + db.session.flush() # get article.id before saving attachments + + for f in request.files.getlist('attachments'): + if f and f.filename and _kb_allowed(f.filename): + att = _save_kb_file(f, article.id) + db.session.add(att) + db.session.commit() log_action(current_user.id, 'kb_create', 'knowledge_base', article.id, f'title={article.title}') logger.info(f'[KB CREATE] article_id={article.id} by user_id={current_user.id}') - flash('Article created.', 'success') + flash('Article created successfully.', 'success') + # XHR fetch submit: return JSON so JS can redirect to the final URL + if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or 'multipart/form-data' in request.content_type: + return jsonify({'redirect': url_for('admin.kb_list')}) return redirect(url_for('admin.kb_list')) return render_template('admin/kb_edit.html', article=None) +# ── Edit article ─────────────────────────────────────────────────────────────── + @admin_bp.route('/kb//edit', methods=['GET', 'POST']) @login_required @it_required @@ -226,15 +329,40 @@ def kb_edit(article_id): article.body = request.form.get('body', article.body).strip() article.category = request.form.get('category', article.category) article.tags = request.form.get('tags', article.tags) - article.is_published= bool(request.form.get('is_published')) + article.is_published= bool(request.form.get('is_published')) and not bool(request.form.get('_save_as_draft')) + + for f in request.files.getlist('attachments'): + if f and f.filename and _kb_allowed(f.filename): + att = _save_kb_file(f, article.id) + db.session.add(att) + db.session.commit() log_action(current_user.id, 'kb_edit', 'knowledge_base', article.id) logger.info(f'[KB EDIT] article_id={article.id} by user_id={current_user.id}') - flash('Article updated.', 'success') + flash('Article updated successfully.', 'success') + # XHR fetch submit: return JSON so JS can redirect to the final URL + if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or 'multipart/form-data' in request.content_type: + return jsonify({'redirect': url_for('admin.kb_list')}) return redirect(url_for('admin.kb_list')) return render_template('admin/kb_edit.html', article=article) +@admin_bp.route('/kb//publish', methods=['POST']) +@login_required +@it_required +def kb_toggle_publish(article_id): + """Quick publish/unpublish toggle — callable from the article list.""" + article = KnowledgeBase.query.get_or_404(article_id) + article.is_published = not article.is_published + db.session.commit() + state = 'published' if article.is_published else 'unpublished' + log_action(current_user.id, f'kb_{state}', 'knowledge_base', article.id, + f'title={article.title}') + logger.info(f'[KB TOGGLE PUBLISH] article_id={article.id} is_published={article.is_published} by user_id={current_user.id}') + flash(f'Article "{article.title}" has been {state}.', 'success') + return redirect(url_for('admin.kb_list')) + + @admin_bp.route('/kb//delete', methods=['POST']) @login_required @it_required diff --git a/app/templates/admin/kb_edit.html b/app/templates/admin/kb_edit.html index 043a459..efe670c 100644 --- a/app/templates/admin/kb_edit.html +++ b/app/templates/admin/kb_edit.html @@ -2,63 +2,474 @@ {% block title %}{% if article %}Edit Article{% else %}New Article{% endif %}{% endblock %} {% block page_title %}{% if article %}Edit Article{% else %}New KB Article{% endif %}{% endblock %} +{% block head %} + + +{% endblock %} + {% block content %} -
+
- +
- - {% if article %}Edit: {{ article.title[:40] }}{% else %}Create New Article{% endif %} + + {% if article %}Edit: {{ article.title[:50] }}{% else %}Create New Article{% endif %}
-
-
-
- - + +
+ + +
+
+ + +
+
+ +
+ +

Click to browse or drag & drop files here

+

Max 16 MB per file

-
- - -
-
- - -
-
- - -
-
- + + +
+ +
-
- + +
+ {% endfor %} +
+
+ {% endif %} + {% endif %} +
+ +
+
+ - Cancel + + Cancel
+ +
+
+
Article Settings
+
+
+ + +
+
+ + +
Tags improve search and discoverability.
+
+
+
+
+
Editor Tips
+
+
+
Ctrl+B Bold
+
Ctrl+I Italic
+
Ctrl+K Insert link
+
Ctrl+Z Undo
+
Use Insert → Table for tables
+
Drag & drop images directly into the editor
+
+
+
+ {% if article %} +
+
Article Info
+
+
+
+ Created{{ article.created_at.strftime('%b %d, %Y') }} +
+
+ Last updated{{ article.updated_at.strftime('%b %d, %Y') }} +
+
+ Views{{ article.view_count }} +
+
+ Author{{ article.author.full_name if article.author else '—' }} +
+
+ +
+
+ {% endif %} +
{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/kb_list.html b/app/templates/admin/kb_list.html index 20c7900..eeaa3ac 100644 --- a/app/templates/admin/kb_list.html +++ b/app/templates/admin/kb_list.html @@ -32,10 +32,26 @@ {{ art.view_count }} {{ art.updated_at.strftime('%b %d, %Y') }} -
+
-
+ + + {% if art.is_published %} + + {% else %} + + {% endif %} +
+
@@ -52,4 +68,4 @@ {% endif %}
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/tickets/kb_article.html b/app/templates/tickets/kb_article.html index b38a657..d7e151a 100644 --- a/app/templates/tickets/kb_article.html +++ b/app/templates/tickets/kb_article.html @@ -34,7 +34,52 @@ {% endfor %}
{% endif %} -
{{ article.body }}
+ + +
{{ article.body | safe }}
+ + {% set existing_atts = article.attachments.all() %} + {% if existing_atts %} +
+
+ Attachments +
+
+ {% for att in existing_atts %} + + + {{ att.filename }} + {{ (att.file_size / 1024)|int }} KB + + + {% endfor %} +
+
+ {% endif %}
@@ -52,4 +97,4 @@
-{% endblock %} +{% endblock %} \ No newline at end of file