25.1. Hoàn thành logic xử lý đăng bài phía backend
25.1.1. Định nghĩa mô hình bài viết (`apps/models.py`)
Tạo mô hình PostModel để lưu trữ thông tin các bài viết trên diễn đàn, bao gồm tiêu đề, nội dung, thời gian tạo, và liên kết đến bảng/chuyên mục.
from datetime import datetime
from exts import db
class PostModel(db.Model):
__tablename__ = 'post'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
create_time = db.Column(db.DateTime, default=datetime.now)
board_id = db.Column(db.Integer, db.ForeignKey('board.id'))
board = db.relationship('BoardModel', backref='posts')
25.1.2. Tạo decorator kiểm tra đăng nhập (`front/decorator.py`)
Xây dựng một decorator login_required để đảm bảo chỉ những người dùng đã đăng nhập mới có thể truy cập chức năng đăng bài. Nếu chưa đăng nhập, người dùng sẽ được chuyển hướng đến trang đăng nhập.
from flask import session, redirect, url_for
from functools import wraps
import config
def login_required(func):
@wraps(func)
def wrapper(*args, **kwargs):
if config.FRONT_USER_ID in session:
return func(*args, **kwargs)
else:
return redirect(url_for('front.signin'))
return wrapper
25.1.3. Thiết kế biểu mẫu đăng bài (`front/forms.py`)
Định nghĩa biểu mẫu AddPostForm sử dụng WTForms để xác thực dữ liệu đầu vào cho tiêu đề, nội dung và ID của bảng.
from wtforms import StringField, IntegerField
from wtforms.validators import InputRequired
from .forms import BaseForm # Giả định BaseForm đã được định nghĩa
class AddPostForm(BaseForm):
title = StringField(validators=[InputRequired(message='Vui lòng nhập tiêu đề')])
content = StringField(validators=[InputRequired(message='Vui lòng nhập nội dung')])
board_id = IntegerField(validators=[InputRequired(message='Vui lòng chọn bảng')])
25.1.4. Xử lý logic đăng bài (`front/views.py`)
Trong view function apost, xử lý cả yêu cầu GET để hiển thị form và yêu cầu POST để nhận dữ liệu, xác thực, và lưu bài viết vào cơ sở dữ liệu. Đồng thời, kiểm tra sự tồn tại của bảng được chọn.
from flask import Blueprint, request, render_template, session, redirect, url_for
from exts import db
from apps.models import BoardModel, PostModel
from utils import restful # Giả định có module restful để trả về JSON
from .decorator import login_required
bp = Blueprint('front', __name__, url_prefix='/front')
@bp.route('/add_post/', methods=['GET', 'POST'])
@login_required
def add_post():
if request.method == 'GET':
boards = BoardModel.query.all()
return render_template('front/front_add_post.html', boards=boards)
else:
form = AddPostForm(request.form)
if form.validate():
title = form.title.data
content = form.content.data
board_id = form.board_id.data
board = BoardModel.query.get(board_id)
if not board:
return restful.params_error(message='Bảng không tồn tại!')
new_post = PostModel(title=title, content=content, board_id=board_id)
new_post.board = board # Liên kết bài viết với bảng
db.session.add(new_post)
db.session.commit()
return restful.success()
else:
return restful.params_error(message=form.get_error())
25.2. Hoàn thiện giao diện đăng bài
25.2.1. Chuẩn bị thư mục UEditor
Sao chép các tệp tĩnh của UEditor vào thư mục static và tạo một thư mục ueditor trong apps.
25.2.2. Tích hợp UEditor vào ứng dụng Flask
Tạo một blueprint ueditor_bp cho UEditor, xử lý các yêu cầu tải lên tệp và cấu hình. Cấu hình cho phép tải lên lưu trữ cục bộ hoặc lên Qiniu Cloud.
apps/__init__.py
from .ueditor import bp as ueditor_bp
# ... các phần khác của ứng dụng ...
app.register_blueprint(ueditor_bp)
apps/ueditor/ueditor.py
#encoding: utf-8
from flask import (
Blueprint, request, jsonify, url_for, send_from_directory, current_app as app
)
import json
import re
import string
import time
import hashlib
import random
import base64
import sys
import os
from urllib import parse
from io import BytesIO
# Thay đổi thư mục làm việc để tránh lỗi quyền ghi khi lưu file với Qiniu SDK
try:
os.chdir(os.path.abspath(sys.path[0]))
import qiniu
except ImportError:
qiniu = None
bp = Blueprint('ueditor', __name__, url_prefix='/ueditor')
# Các biến cấu hình cho UEditor
UPLOAD_PATH = ""
UPLOAD_TO_QINIU = False
QINIU_ACCESS_KEY = ""
QINIU_SECRET_KEY = ""
QINIU_BUCKET_NAME = ""
QINIU_DOMAIN = ""
@bp.before_app_first_request
def initialize_ueditor_config():
global UPLOAD_PATH, UPLOAD_TO_QINIU, QINIU_ACCESS_KEY, QINIU_SECRET_KEY, QINIU_BUCKET_NAME, QINIU_DOMAIN
UPLOAD_PATH = app.config.get('UEDITOR_UPLOAD_PATH', os.path.join(app.root_path, 'static', 'media'))
if not os.path.exists(UPLOAD_PATH):
os.makedirs(UPLOAD_PATH)
UPLOAD_TO_QINIU = app.config.get("UEDITOR_UPLOAD_TO_QINIU", False)
if UPLOAD_TO_QINIU:
if qiniu is None:
raise RuntimeError('Chưa cài đặt thư viện Qiniu: pip install qiniu')
try:
QINIU_ACCESS_KEY = app.config["UEDITOR_QINIU_ACCESS_KEY"]
QINIU_SECRET_KEY = app.config["UEDITOR_QINIU_SECRET_KEY"]
QINIU_BUCKET_NAME = app.config["UEDITOR_QINIU_BUCKET_NAME"]
QINIU_DOMAIN = app.config["UEDITOR_QINIU_DOMAIN"]
except KeyError as e:
raise RuntimeError(f'Thiếu cấu hình Qiniu trong app.config: {e}')
# Bỏ qua CSRF cho endpoint upload nếu CSRF được bật
csrf = app.extensions.get('csrf')
if csrf:
csrf.exempt(upload_handler)
def generate_unique_filename(original_filename):
letters = string.ascii_letters + string.digits
random_part = "".join(random.sample(letters, 8))
timestamp = str(time.time()).replace('.', '')
unique_name = f"{timestamp}_{random_part}"
file_extension = os.path.splitext(original_filename)[-1]
return unique_name + file_extension
@bp.route('/upload/', methods=['GET', 'POST'])
def upload_handler():
action = request.args.get('action')
response_data = {}
if action == 'config':
# Đọc file cấu hình của UEditor
config_file_path = os.path.join(bp.static_folder or app.static_folder, 'ueditor', 'config.json')
try:
with open(config_file_path, 'r', encoding='utf-8') as f:
content = re.sub(r'/\*.*?\*/', '', f.read()) # Loại bỏ comment
response_data = json.loads(content)
except FileNotFoundError:
response_data = {"state": "Lỗi: Không tìm thấy file config.json"}
elif action in ['uploadimage', 'uploadvideo', 'uploadfile']:
file_obj = request.files.get("upfile")
if not file_obj:
return jsonify({"state": "Lỗi: Không nhận được file tải lên."})
original_filename = file_obj.filename
save_filename = generate_unique_filename(original_filename)
if UPLOAD_TO_QINIU:
try:
buffer = BytesIO()
file_obj.save(buffer)
buffer.seek(0)
# Khởi tạo client Qiniu
q = qiniu.Auth(QINIU_ACCESS_KEY, QINIU_SECRET_KEY)
token = q.upload_token(QINIU_BUCKET_NAME)
ret, info = qiniu.put_data(token, save_filename, buffer.read())
if info.status_code == 200:
response_data = {
"state": "SUCCESS",
"url": parse.urljoin(QINIU_DOMAIN, ret.get('key')),
"title": ret.get('key'),
"original": original_filename
}
else:
response_data = {"state": f"Lỗi tải lên Qiniu: {info.error_message}"}
except Exception as e:
response_data = {"state": f"Lỗi xử lý Qiniu: {str(e)}"}
else:
# Lưu file vào hệ thống cục bộ
file_path = os.path.join(UPLOAD_PATH, save_filename)
file_obj.save(file_path)
response_data = {
"state": "SUCCESS",
"url": url_for('ueditor.get_file', filename=save_filename),
"title": save_filename,
"original": original_filename
}
elif action == 'uploadscrawl':
# Xử lý ảnh vẽ tay (screenshot)
base64_data = request.form.get("upfile")
try:
image_bytes = base64.b64decode(base64_data)
filename = generate_unique_filename('scrawl.png')
filepath = os.path.join(UPLOAD_PATH, filename)
with open(filepath, 'wb') as f:
f.write(image_bytes)
response_data = {
"state": "SUCCESS",
"url": url_for('ueditor.get_file', filename=filename),
"title": filename,
"original": filename
}
except Exception as e:
response_data = {"state": f"Lỗi xử lý ảnh vẽ tay: {str(e)}"}
return jsonify(response_data)
@bp.route('/media//')
def get_file(filename):
# Trả về file đã upload từ thư mục UPLOAD_PATH
return send_from_directory(UPLOAD_PATH, filename)
25.2.3. Hoàn thiện giao diện người dùng
front/index.html
Thêm nút để điều hướng đến trang đăng bài.
<a class="btn btn-warning btn-block" href="{{ url_for('front.add_post') }}" id="add-post-btn">Đăng bài mới</a>
front/front_add_post.html
Tạo giao diện form đăng bài, sử dụng UEditor cho nội dung và hiển thị danh sách các bảng.
{% extends 'front/front_base.html' %}
{% from 'common/_macros.html' import static %}
{% block title %}
Đăng bài mới
{% endblock %}
{% block head %}
{# Tải các tệp JavaScript cần thiết cho UEditor #}
<script src="{{ static('ueditor/ueditor.config.js') }}"></script>
<script src="{{ static('ueditor/ueditor.all.min.js') }}"></script>
{# Tệp JavaScript tùy chỉnh cho trang này #}
<script src="{{ static('front/js/front_add_post.js') }}"></script>
{% endblock %}
{% block body %}
<form method="post" enctype="multipart/form-data">
<div class="form-group">
<div class="input-group">
<span class="input-group-addon">Tiêu đề</span>
<input type="text" name="title" class="form-control" placeholder="Nhập tiêu đề bài viết" required>
</div>
</div>
<div class="form-group">
<div class="input-group">
<span class="input-group-addon">Chuyên mục</span>
<select name="board_id" class="form-control" required>
<option value="">-- Chọn chuyên mục --</option>
{% for board in boards %}
<option value="{{ board.id }}">{{ board.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label for="content">Nội dung</label>
{# Đây là nơi UEditor sẽ được khởi tạo #}
<script id="ueditor_editor" name="content" type="text/plain" style="height:400px;"></script>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary" id="submit-btn">Đăng bài</button>
</div>
</form>
{% endblock %}
front/js/front_add_post.js
Khởi tạo trình soạn thảo UEditor và cấu hình nó để giao tiếp với backend qua URL upload đã định nghĩa.
$(function () {
// Khởi tạo UEditor và cấu hình serverUrl để trỏ đến endpoint upload của chúng ta
var ue = UE.getEditor('ueditor_editor', {
'serverUrl': '/ueditor/upload/',
'autoHeightEnabled': false, // Tắt tự động điều chỉnh chiều cao nếu cần
initialFrameHeight: 400 // Đặt chiều cao ban đầu
});
// Có thể thêm các xử lý khác tại đây, ví dụ:
// $('#submit-btn').click(function(e){
// e.preventDefault();
// // Lấy nội dung từ UEditor trước khi submit form
// // var content = ue.getContent();
// // $('textarea[name="content"]').val(content); // Gán vào textarea ẩn nếu form của bạn có
// $('form').submit();
// });
});