Hoàn thiện chức năng đăng bài trên diễn đàn BBS

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();
    // });
});
    

Thẻ: Flask WTForms UEditor Qiniu Backend

Đăng vào ngày 17 tháng 7 lúc 02:31