Enhance Article editor
This commit is contained in:
+134
-6
@@ -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": "<url>"} 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/<path:stored_name>')
|
||||
@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/<int:article_id>/attachments/<int:att_id>/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/<int:article_id>/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/<int:article_id>/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/<int:article_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@it_required
|
||||
|
||||
Reference in New Issue
Block a user