راه اندازی سیستم مدیریت محتوا با پایتون خام، یکی از آموزنده‌ترین پروژه‌ها برای توسعه‌دهندگانی است که می‌خواهند بفهمند فریمورک‌هایی مانند جنگو و فلسک در زیر لایه، دقیقاً چه کاری انجام می‌دهند. یک سیستم مدیریت محتوا (Content Management System) که به اختصار CMS نامیده می‌شود، مجموعه‌ای از ابزارها برای ایجاد، ویرایش، دسته‌بندی و انتشار محتواست.
در این راهنما، گام‌به‌گام یک CMS کامل با پایتون خام می‌سازیم که امکان انتشار پست، دسته‌بندی، صفحات داخلی، صفحه اصلی و جستجو را فراهم می‌کند.
از طراحی schema دیتابیس و لایه مدل شروع می‌کنیم، سپس موتور قالب ساده، سرور HTTP، سیستم مسیریابی و پنل مدیریت را پیاده‌سازی می‌کنیم.
تمام کدها با کتابخانه استاندارد پایتون نوشته می‌شوند تا وابستگی خارجی به حداقل برسد.
در پایان، یک CMS قابل استفاده خواهید داشت که به‌عنوان پایه پروژه‌های واقعی قابل توسعه است.

در تجربه ساخت پروژه‌های متعدد وب، یک نکته بارها خود را نشان داده است: توسعه‌دهندگانی که یک بار CMS را از صفر ساخته‌اند، بعداً هنگام کار با جنگو یا فلسک، درک عمیق‌تری از رفتار فریمورک دارند. این راهنما دقیقاً همان مسیر را طی می‌کند. برای مبانی پایتون، آموزش پایتون از صفر و برای ایده‌های بیشتر، چرا اجرای پروژه با پایتون خام بهتر از استفاده از فریمورک است؟ را ببینید.

چرا ساخت CMS با پایتون خام؟

یک CMS با پایتون خام به شما این امکان را می‌دهد که تمام لایه‌های یک اپلیکیشن وب را از نزدیک بشناسید. مفاهیمی مانند مسیریابی (Routing)، نگاشت درخواست به تابع (Request Dispatching)، مدیریت نشست (Session Management)، موتور قالب (Template Engine) و لایه دسترسی به داده (Data Access Layer)، همه در فریمورک‌ها انتزاع شده‌اند. اما وقتی خودتان آن‌ها را می‌سازید، درک عمیقی به دست می‌آورید که در عیب‌یابی و طراحی معماری، ارزش بالایی دارد.

در ویکی‌پدیا، Content Management System به‌عنوان «نرم‌افزاری برای مدیریت ایجاد و اصلاح محتوای دیجیتال» تعریف شده است. یک CMS معمولاً شامل این اجزاست: مدیریت پست، دسته‌بندی، صفحات ثابت، جستجو، مدیریت کاربران و تنظیمات. در این راهنما، همه این اجزا را با پایتون خام پیاده می‌کنیم.

مزیت‌های این رویکرد:

  • درک عمیق پروتکل HTTP و چرخه درخواست-پاسخ
  • کنترل کامل بر رفتار برنامه و معماری
  • کاهش وابستگی به پکیج‌های خارجی
  • آمادگی برای مصاحبه‌های فنی سطح بالا
  • قابلیت اجرا در محیط‌های محدود بدون اینترنت

محدودیت‌ها نیز باید در نظر گرفته شوند: زمان توسعه بیشتر، امنیت پایین‌تر به‌طور پیش‌فرض، و دشواری نگهداری در تیم‌های بزرگ. بنابراین، این رویکرد برای یادگیری، پروژه‌های کوچک و متوسط، و ابزارهای داخلی مناسب‌تر است.

«ساختن CMS از صفر، به شما یاد می‌دهد که چرا فریمورک‌ها وجود دارند، نه اینکه چرا از آن‌ها استفاده کنید.»

معماری کلی پروژه CMS

معماری CMS ما بر پایه چند لایه ساده بنا می‌شود:

  1. لایه دیتابیس (Database Layer): مدیریت اتصال به SQLite و اجرای کوئری‌ها
  2. لایه مدل (Model Layer): کلاس‌هایی برای نمایش پست، دسته‌بندی، صفحه و کاربر
  3. لایه قالب (Template Layer): موتور قالب ساده برای تولید HTML
  4. لایه مسیریابی (Routing Layer): نگاشت مسیرها به توابع
  5. لایه کنترلر (Controller Layer): توابعی که منطق هر صفحه را اجرا می‌کنند
  6. لایه سرور (Server Layer): سرور HTTP که درخواست‌ها را دریافت و پاسخ می‌دهد

این ساختار به شما اجازه می‌دهد هر بخش را مستقل توسعه دهید و در صورت نیاز، بخشی را جایگزین کنید. مثلاً می‌توانید موتور قالب را با Jinja2 جایگزین کنید، بدون تغییر در سایر لایه‌ها.

ساختار پوشه‌ها و فایل‌ها

ساختار پوشه‌های پروژه به این شکل خواهد بود:

cms/
├── app.py              # نقطه ورود برنامه
├── db.py               # لایه دیتابیس
├── models.py           # لایه مدل
├── router.py           # سیستم مسیریابی
├── template.py         # موتور قالب
├── views.py            # توابع کنترلر
├── admin.py            # پنل مدیریت
├── auth.py             # احراز هویت
├── config.py           # تنظیمات
├── templates/          # قالب‌های HTML
│   ├── base.html
│   ├── home.html
│   ├── post.html
│   ├── category.html
│   ├── page.html
│   ├── search.html
│   └── admin/
│       ├── login.html
│       ├── dashboard.html
│       └── post_form.html
├── static/             # فایل‌های استاتیک
│   ├── style.css
│   └── script.js
└── data/
    └── cms.db          # فایل دیتابیس SQLite

این ساختار، ساده اما اصولی است. در پروژه‌های بزرگ‌تر می‌توانید از Blueprint یا بسته‌بندی (Package) استفاده کنید، اما برای شروع، این ساختار کافی است.

طراحی دیتابیس با SQLite

SQLite انتخاب طبیعی برای شروع است چون بدون نصب سرور دیتابیس کار می‌کند و فایل آن قابل جابجایی است. برای مبانی اتصال به دیتابیس، اتصال پایتون به mysql مفاهیم مشابهی را توضیح می‌دهد. schema دیتابیس CMS شامل جدول‌های زیر است:

-- جدول دسته‌بندی‌ها
CREATE TABLE IF NOT EXISTS categories (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE,
    slug TEXT NOT NULL UNIQUE,
    description TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- جدول پست‌ها
CREATE TABLE IF NOT EXISTS posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    slug TEXT NOT NULL UNIQUE,
    content TEXT NOT NULL,
    excerpt TEXT,
    category_id INTEGER,
    status TEXT DEFAULT 'draft',
    views INTEGER DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    published_at TIMESTAMP,
    FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);

-- جدول صفحات داخلی
CREATE TABLE IF NOT EXISTS pages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    slug TEXT NOT NULL UNIQUE,
    content TEXT NOT NULL,
    status TEXT DEFAULT 'published',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- جدول کاربران (مدیران)
CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    email TEXT,
    role TEXT DEFAULT 'editor',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- جدول تنظیمات
CREATE TABLE IF NOT EXISTS settings (
    key TEXT PRIMARY KEY,
    value TEXT
);

-- جدول لاگ بازدید
CREATE TABLE IF NOT EXISTS visits (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    path TEXT NOT NULL,
    ip TEXT,
    user_agent TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- ایندکس‌ها برای بهبود سرعت
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
CREATE INDEX IF NOT EXISTS idx_posts_category ON posts(category_id);
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
CREATE INDEX IF NOT EXISTS idx_pages_slug ON pages(slug);

توضیح جدول‌ها:

  • categories: دسته‌بندی‌ها با نام، slug و توضیح
  • posts: پست‌ها با وضعیت (draft/published)، تعداد بازدید و ارتباط با دسته‌بندی
  • pages: صفحات ثابت مانند درباره ما و تماس با ما
  • users: کاربران با رمز هش‌شده و نقش
  • settings: تنظیمات سایت به‌صورت کلید-مقدار
  • visits: لاگ بازدید برای آمار

فایل db.py شامل توابع مدیریت اتصال است:

import sqlite3
from pathlib import Path

DB_PATH = Path(__file__).parent / 'data' / 'cms.db'

def get_connection():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute('PRAGMA foreign_keys = ON')
    return conn

def init_db():
    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    conn = get_connection()
    with open(Path(__file__).parent / 'schema.sql', 'r', encoding='utf-8') as f:
        conn.executescript(f.read())
    conn.commit()
    conn.close()

def query(sql, params=()):
    conn = get_connection()
    try:
        cursor = conn.execute(sql, params)
        rows = cursor.fetchall()
        return [dict(row) for row in rows]
    finally:
        conn.close()

def execute(sql, params=()):
    conn = get_connection()
    try:
        cursor = conn.execute(sql, params)
        conn.commit()
        return cursor.lastrowid
    finally:
        conn.close()

نکته امنیتی مهم: همیشه از پارامترهای positional (?) استفاده کنید، نه string formatting. این کار از SQL Injection جلوگیری می‌کند. برای مبانی این مفهوم، حملات SQL Injection و راه‌های مقابله را ببینید.

لایه مدل و دسترسی به داده

لایه مدل، یک لایه انتزاعی روی دیتابیس است که کار با داده را ساده‌تر می‌کند. کلاس‌های مدل، نماینده رکوردهای دیتابیس هستند و متدهای کلاس، عملیات CRUD را انجام می‌دهند.

from datetime import datetime
from db import query, execute
import re

def slugify(text):
    text = text.lower().strip()
    text = re.sub(r'[^\w\s-]', '', text)
    text = re.sub(r'[-\s]+', '-', text)
    return text

class Post:
    @staticmethod
    def all(status='published', limit=None, offset=0):
        sql = 'SELECT * FROM posts WHERE status = ? ORDER BY published_at DESC'
        params = [status]
        if limit:
            sql += ' LIMIT ? OFFSET ?'
            params.extend([limit, offset])
        return query(sql, params)

    @staticmethod
    def by_slug(slug):
        rows = query('SELECT * FROM posts WHERE slug = ? AND status = ?', (slug, 'published'))
        return rows[0] if rows else None

    @staticmethod
    def by_category(category_id, limit=20):
        return query(
            'SELECT * FROM posts WHERE category_id = ? AND status = ? ORDER BY published_at DESC LIMIT ?',
            (category_id, 'published', limit)
        )

    @staticmethod
    def create(title, content, category_id, excerpt='', status='draft'):
        slug = slugify(title)
        existing = query('SELECT id FROM posts WHERE slug = ?', (slug,))
        if existing:
            slug = f'{slug}-{int(datetime.now().timestamp())}'
        published_at = datetime.now().isoformat() if status == 'published' else None
        return execute(
            'INSERT INTO posts (title, slug, content, excerpt, category_id, status, published_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
            (title, slug, content, excerpt, category_id, status, published_at)
        )

    @staticmethod
    def update(post_id, **fields):
        allowed = ['title', 'content', 'excerpt', 'category_id', 'status']
        sets = []
        params = []
        for key, value in fields.items():
            if key in allowed:
                sets.append(f'{key} = ?')
                params.append(value)
        if not sets:
            return
        sets.append('updated_at = ?')
        params.append(datetime.now().isoformat())
        params.append(post_id)
        execute(f'UPDATE posts SET {", ".join(sets)} WHERE id = ?', params)

    @staticmethod
    def delete(post_id):
        execute('DELETE FROM posts WHERE id = ?', (post_id,))

    @staticmethod
    def search(keyword, limit=20):
        pattern = f'%{keyword}%'
        return query(
            '''SELECT * FROM posts WHERE status = ? 
               AND (title LIKE ? OR content LIKE ?) 
               ORDER BY published_at DESC LIMIT ?''',
            ('published', pattern, pattern, limit)
        )

    @staticmethod
    def increment_views(post_id):
        execute('UPDATE posts SET views = views + 1 WHERE id = ?', (post_id,))


class Category:
    @staticmethod
    def all():
        return query('SELECT * FROM categories ORDER BY name')

    @staticmethod
    def by_slug(slug):
        rows = query('SELECT * FROM categories WHERE slug = ?', (slug,))
        return rows[0] if rows else None

    @staticmethod
    def create(name, description=''):
        slug = slugify(name)
        return execute(
            'INSERT INTO categories (name, slug, description) VALUES (?, ?, ?)',
            (name, slug, description)
        )


class Page:
    @staticmethod
    def by_slug(slug):
        rows = query('SELECT * FROM pages WHERE slug = ? AND status = ?', (slug, 'published'))
        return rows[0] if rows else None

    @staticmethod
    def all():
        return query('SELECT * FROM pages WHERE status = ? ORDER BY title', ('published',))

    @staticmethod
    def create(title, content):
        slug = slugify(title)
        return execute('INSERT INTO pages (title, slug, content) VALUES (?, ?, ?)', (title, slug, content))

این لایه مدل، تمام عملیات CRUD و جستجو را پیاده‌سازی می‌کند. مزیت این رویکرد، جداسازی منطق داده از منطق نمایش است، که همان اصل Separation of Concerns است. برای مبانی شی‌گرایی در پایتون، شی گرایی در پایتون را ببینید.

موتور قالب ساده

یک موتور قالب ساده، با پشتیبانی از جایگزینی متغیر، حلقه و شرط، برای CMS کافی است. در اینجا یک پیاده‌سازی مینیمال ارائه می‌شود:

import re
from pathlib import Path
from html import escape

TEMPLATES_DIR = Path(__file__).parent / 'templates'

def render(template_name, context=None):
    context = context or {}
    path = TEMPLATES_DIR / template_name
    if not path.exists():
        raise FileNotFoundError(f'Template not found: {template_name}')
    source = path.read_text(encoding='utf-8')
    return Template(source).render(context)

class Template:
    def __init__(self, source):
        self.source = source

    def render(self, context):
        text = self.source
        # پردازش extends
        extends_match = re.search(r'{% extends "([^"]+)" %}', text)
        if extends_match:
            parent_name = extends_match.group(1)
            parent = (TEMPLATES_DIR / parent_name).read_text(encoding='utf-8')
            blocks = dict(re.findall(r'{% block (\w+) %}(.*?){% endblock %}', text, re.DOTALL))
            text = re.sub(
                r'{% block (\w+) %}(.*?){% endblock %}',
                lambda m: blocks.get(m.group(1), m.group(2)),
                parent,
                flags=re.DOTALL
            )
        # شرط‌ها
        text = self._process_conditionals(text, context)
        # حلقه‌ها
        text = self._process_loops(text, context)
        # متغیرها
        text = self._process_variables(text, context)
        return text

    def _process_variables(self, text, context):
        def replace(match):
            expr = match.group(1).strip()
            value = self._resolve(expr, context)
            if value is None:
                return ''
            if isinstance(value, str) and ('< p>' in value or '< h' in value or '< br' in value):
                return value
            return escape(str(value))
        return re.sub(r'{{\s*([^}]+)\s*}}', replace, text)

    def _process_conditionals(self, text, context):
        pattern = r'{% if (\w+) %}(.*?){% endif %}'
        def replace(match):
            var, body = match.groups()
            if context.get(var):
                return body
            return ''
        return re.sub(pattern, replace, text, flags=re.DOTALL)

    def _process_loops(self, text, context):
        pattern = r'{% for (\w+) in (\w+) %}(.*?){% endfor %}'
        def replace(match):
            item_name, list_name, body = match.groups()
            items = context.get(list_name, [])
            output = []
            for item in items:
                new_body = body
                if isinstance(item, dict):
                    for key, value in item.items():
                        new_body = re.sub(
                            r'{{\s*' + item_name + r'\.' + key + r'\s*}}',
                            escape(str(value)) if value is not None else '',
                            new_body
                        )
                else:
                    new_body = re.sub(
                        r'{{\s*' + item_name + r'\s*}}',
                        escape(str(item)),
                        new_body
                    )
                output.append(new_body)
            return ''.join(output)
        return re.sub(pattern, replace, text, flags=re.DOTALL)

    def _resolve(self, expr, context):
        parts = expr.split('.')
        value = context.get(parts[0])
        for part in parts[1:]:
            if isinstance(value, dict):
                value = value.get(part)
            else:
                value = getattr(value, part, None)
        return value

این موتور قالب از سه سینتکس پشتیبانی می‌کند: {{ variable }} برای متغیر، {% for x in list %} برای حلقه و {% if cond %} برای شرط. همچنین از {% extends %} و {% block %} برای ارث‌بری قالب پشتیبانی می‌کند.

نکته امنیتی: متغیرها به‌طور پیش‌فرض escape می‌شوند تا از XSS جلوگیری شود. اما برای محتوای HTML پست‌ها، نیاز به escape نشدن دارید. در کد بالا، اگر مقدار شامل تگ‌های HTML باشد، escape نمی‌شود. این یک تصمیم آگاهانه است که باید با اعتبارسنجی محتوا همراه باشد.

قالب پایه (templates/base.html):

<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}CMS{% endblock %}</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <header>
        <nav>
            <a href="/">صفحه اصلی</a>
            {% for cat in categories %}
            <a href="/category/{{ cat.slug }}">{{ cat.name }}</a>
            {% endfor %}
            <form action="/search" method="get" class="search-form">
                <input type="text" name="q" placeholder="جستجو...">
                <button type="submit">جستجو</button>
            </form>
        </nav>
    </header>
    <main>
        {% block content %}{% endblock %}
    </main>
    <footer>
        <p>&copy; 2026 CMS</p>
    </footer>
</body>
</html>

سرور HTTP و سیستم مسیریابی

سرور HTTP بر پایه http.server ساخته می‌شود. سیستم مسیریابی از regex برای تطبیق مسیرها استفاده می‌کند. این طراحی به شما اجازه می‌دهد مسیرهای پویا مانند /post/slug را تعریف کنید.

import re
from urllib.parse import urlparse, parse_qs

class Router:
    def __init__(self):
        self.routes = []

    def add(self, method, pattern, handler):
        regex = re.compile('^' + pattern + '$')
        self.routes.append((method, regex, handler))

    def get(self, pattern):
        def decorator(handler):
            self.add('GET', pattern, handler)
            return handler
        return decorator

    def post(self, pattern):
        def decorator(handler):
            self.add('POST', pattern, handler)
            return handler
        return decorator

    def match(self, method, path):
        for route_method, regex, handler in self.routes:
            if route_method != method:
                continue
            match = regex.match(path)
            if match:
                return handler, match.groupdict()
        return None, None


router = Router()

@router.get(r'/')
def home(request):
    from views import home_view
    return home_view(request)

@router.get(r'/post/(?P<slug>[\w-]+)')
def post_detail(request, slug):
    from views import post_view
    return post_view(request, slug)

@router.get(r'/category/(?P<slug>[\w-]+)')
def category_detail(request, slug):
    from views import category_view
    return category_view(request, slug)

@router.get(r'/page/(?P<slug>[\w-]+)')
def page_detail(request, slug):
    from views import page_view
    return page_view(request, slug)

@router.get(r'/search')
def search(request):
    from views import search_view
    return search_view(request)

@router.get(r'/admin')
def admin_dashboard(request):
    from admin import dashboard_view
    return dashboard_view(request)

@router.get(r'/admin/login')
def admin_login_form(request):
    from auth import login_form_view
    return login_form_view(request)

@router.post(r'/admin/login')
def admin_login(request):
    from auth import login_view
    return login_view(request)

@router.get(r'/admin/logout')
def admin_logout(request):
    from auth import logout_view
    return logout_view(request)

حالا سرور اصلی در app.py:

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
from db import init_db
from router import router

HOST = '0.0.0.0'
PORT = 8000

class Request:
    def __init__(self, handler):
        self.handler = handler
        parsed = urlparse(handler.path)
        self.path = parsed.path
        self.query = parse_qs(parsed.query)
        self.method = handler.command
        self.headers = handler.headers
        self._body = None
        self.session = self._parse_session()

    def body(self):
        if self._body is None:
            length = int(self.headers.get('Content-Length', 0))
            self._body = self.handler.rfile.read(length).decode('utf-8') if length else ''
        return self._body

    def form(self):
        body = self.body()
        return {k: v[0] for k, v in parse_qs(body).items()}

    def _parse_session(self):
        cookie = self.headers.get('Cookie', '')
        session = {}
        for part in cookie.split(';'):
            if '=' in part:
                key, value = part.strip().split('=', 1)
                session[key] = value
        return session

class Response:
    def __init__(self, body=b'', status=200, content_type='text/html; charset=utf-8', headers=None):
        self.body = body if isinstance(body, bytes) else body.encode('utf-8')
        self.status = status
        self.content_type = content_type
        self.headers = headers or {}

class CMShandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self._handle('GET')

    def do_POST(self):
        self._handle('POST')

    def _handle(self, method):
        request = Request(self)
        # سرو فایل‌های استاتیک
        if request.path.startswith('/static/'):
            self._serve_static(request.path)
            return
        handler, params = router.match(method, request.path)
        if handler is None:
            self._send_response(Response('<h1>404 Not Found</h1>', 404))
            return
        try:
            response = handler(request, **(params or {}))
            if isinstance(response, str):
                response = Response(response)
            elif isinstance(response, dict):
                import json
                response = Response(json.dumps(response), content_type='application/json')
            self._send_response(response)
        except Exception as e:
            import traceback
            traceback.print_exc()
            self._send_response(Response(f'<h1>500 Error</h1><pre>{e}</pre>', 500))

    def _serve_static(self, path):
        from pathlib import Path
        file_path = Path(__file__).parent / path.lstrip('/')
        if not file_path.exists():
            self._send_response(Response('Not Found', 404))
            return
        content_type = 'text/css' if path.endswith('.css') else 'application/javascript'
        with open(file_path, 'rb') as f:
            self._send_response(Response(f.read(), content_type=content_type))

    def _send_response(self, response):
        self.send_response(response.status)
        self.send_header('Content-Type', response.content_type)
        for key, value in response.headers.items():
            self.send_header(key, value)
        self.end_headers()
        self.wfile.write(response.body)

    def log_message(self, format, *args):
        pass  # لاگ پیش‌فرض را خاموش می‌کنیم

if __name__ == '__main__':
    init_db()
    server = ThreadingHTTPServer((HOST, PORT), CMShandler)
    print(f'Server running on http://{HOST}:{PORT}')
    server.serve_forever()

این سرور از ThreadingHTTPServer استفاده می‌کند تا هر درخواست در یک thread جداگانه پردازش شود. این کار برای بار متوسط کافی است. برای بار بالا، باید به فکر ابزارهایی مانند Gunicorn باشید، اما در پایتون خام، این سرور در ترکیب با چندین پروسه می‌تواند کار کند.

صفحه اصلی

صفحه اصلی، لیست آخرین پست‌های منتشرشده را نمایش می‌دهد. تابع view در views.py:

from template import render
from models import Post, Category, Page

def home_view(request):
    page_num = int(request.query.get('page', ['1'])[0])
    per_page = 10
    offset = (page_num - 1) * per_page
    posts = Post.all(limit=per_page, offset=offset)
    categories = Category.all()
    return render('home.html', {
        'posts': posts,
        'categories': categories,
        'page_num': page_num,
        'has_next': len(posts) == per_page,
    })

قالب templates/home.html:

{% extends "base.html" %}
{% block title %}صفحه اصلی{% endblock %}
{% block content %}
<section class="posts">
    <h1>آخرین پست‌ها</h1>
    {% for post in posts %}
    <article class="post-card">
        <h2><a href="/post/{{ post.slug }}">{{ post.title }}</a></h2>
        <p>{{ post.excerpt }}</p>
        <small>{{ post.published_at }}</small>
    </article>
    {% endfor %}
</section>
<nav class="pagination">
    {% if page_num > 1 %}
    <a href="/?page={{ page_num - 1 }}">قبلی</a>
    {% endif %}
    {% if has_next %}
    <a href="/?page={{ page_num + 1 }}">بعدی</a>
    {% endif %}
</nav>
{% endblock %}

نکته: در موتور قالب بالا، عملگرهای ریاضی مانند page_num - 1 پشتیبانی نمی‌شوند. برای سادگی، می‌توانید مقادیر محاسبه‌شده را در context قرار دهید. اما در نسخه‌های بعدی می‌توانید موتور قالب را با Jinja2 جایگزین کنید که از فیلترها و توابع پشتیبانی می‌کند.

نمایش پست

صفحه پست، محتوای کامل یک پست را نمایش می‌دهد و تعداد بازدید را افزایش می‌دهد:

def post_view(request, slug):
    post = Post.by_slug(slug)
    if not post:
        return render('404.html', {'categories': Category.all()}), 404
    Post.increment_views(post['id'])
    category = None
    if post['category_id']:
        category = next((c for c in Category.all() if c['id'] == post['category_id']), None)
    return render('post.html', {
        'post': post,
        'category': category,
        'categories': Category.all(),
    })

قالب templates/post.html:

{% extends "base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<article class="post-full">
    <h1>{{ post.title }}</h1>
    {% if category %}
    <p class="category">دسته: <a href="/category/{{ category.slug }}">{{ category.name }}</a></p>
    {% endif %}
    <div class="meta">
        <span>{{ post.published_at }}</span>
        <span>{{ post.views }} بازدید</span>
    </div>
    <div class="content">
        {{ post.content }}
    </div>
</article>
{% endblock %}

دسته‌بندی‌ها

صفحه دسته‌بندی، پست‌های یک دسته خاص را نمایش می‌دهد:

def category_view(request, slug):
    category = Category.by_slug(slug)
    if not category:
        return render('404.html', {'categories': Category.all()}), 404
    posts = Post.by_category(category['id'])
    return render('category.html', {
        'category': category,
        'posts': posts,
        'categories': Category.all(),
    })

قالب templates/category.html:

{% extends "base.html" %}
{% block title %}{{ category.name }}{% endblock %}
{% block content %}
<h1>دسته: {{ category.name }}</h1>
{% if category.description %}
<p>{{ category.description }}</p>
{% endif %}
<div class="post-list">
    {% for post in posts %}
    <article>
        <h2><a href="/post/{{ post.slug }}">{{ post.title }}</a></h2>
        <p>{{ post.excerpt }}</p>
    </article>
    {% endfor %}
</div>
{% endblock %}

صفحات داخلی

صفحات داخلی مانند درباره ما و تماس با ما، با slug یکتا شناسایی می‌شوند:

def page_view(request, slug):
    page = Page.by_slug(slug)
    if not page:
        return render('404.html', {'categories': Category.all()}), 404
    return render('page.html', {
        'page': page,
        'categories': Category.all(),
    })

قالب templates/page.html:

{% extends "base.html" %}
{% block title %}{{ page.title }}{% endblock %}
{% block content %}
<article class="static-page">
    <h1>{{ page.title }}</h1>
    <div class="content">
        {{ page.content }}
    </div>
</article>
{% endblock %}

جستجو با استفاده از عملگر LIKE در SQL پیاده‌سازی می‌شود. برای داده‌های کم، این روش کافی است. برای داده‌های زیاد، باید از FTS5 در SQLite یا Elasticsearch استفاده کنید.

def search_view(request):
    keyword = request.query.get('q', [''])[0].strip()
    results = []
    if keyword and len(keyword) >= 2:
        results = Post.search(keyword)
    return render('search.html', {
        'keyword': keyword,
        'results': results,
        'categories': Category.all(),
    })

قالب templates/search.html:

{% extends "base.html" %}
{% block title %}جستجو{% endblock %}
{% block content %}
<h1>نتایج جستجو برای: {{ keyword }}</h1>
{% if results %}
<div class="search-results">
    {% for post in results %}
    <article>
        <h2><a href="/post/{{ post.slug }}">{{ post.title }}</a></h2>
        <p>{{ post.excerpt }}</p>
    </article>
    {% endfor %}
</div>
{% else %}
<p>نتیجه‌ای یافت نشد.</p>
{% endif %}
{% endblock %}

برای جستجوی پیشرفته‌تر، می‌توانید از FTS5 در SQLite استفاده کنید:

CREATE VIRTUAL TABLE posts_fts USING fts5(title, content, content='posts', content_rowid='id');

-- ترگر برای همگام‌سازی
CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN
    INSERT INTO posts_fts(rowid, title, content) VALUES (new.id, new.title, new.content);
END;

CREATE TRIGGER posts_ad AFTER DELETE ON posts BEGIN
    INSERT INTO posts_fts(posts_fts, rowid, title, content) VALUES('delete', old.id, old.title, old.content);
END;

CREATE TRIGGER posts_au AFTER UPDATE ON posts BEGIN
    INSERT INTO posts_fts(posts_fts, rowid, title, content) VALUES('delete', old.id, old.title, old.content);
    INSERT INTO posts_fts(rowid, title, content) VALUES (new.id, new.title, new.content);
END;

سپس کوئری جستجو:

def search_fts(keyword, limit=20):
    return query(
        '''SELECT p.* FROM posts p
           JOIN posts_fts fts ON p.id = fts.rowid
           WHERE posts_fts MATCH ? AND p.status = 'published'
           ORDER BY rank LIMIT ?''',
        (keyword, limit)
    )

پنل مدیریت

پنل مدیریت، امکان ایجاد، ویرایش و حذف پست‌ها، دسته‌بندی‌ها و صفحات را فراهم می‌کند. ابتدا تابع احراز هویت را در auth.py می‌سازیم:

import hashlib
import secrets
import hmac
from models import query, execute

def hash_password(password, salt=None):
    if salt is None:
        salt = secrets.token_hex(16)
    hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
    return f'{salt}${hashed.hex()}'

def verify_password(password, stored):
    salt, hashed = stored.split('$')
    check = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
    return hmac.compare_digest(check.hex(), hashed)

def authenticate(username, password):
    rows = query('SELECT * FROM users WHERE username = ?', (username,))
    if not rows:
        return None
    user = rows[0]
    if verify_password(password, user['password_hash']):
        return user
    return None

def create_user(username, password, email='', role='editor'):
    return execute(
        'INSERT INTO users (username, password_hash, email, role) VALUES (?, ?, ?, ?)',
        (username, hash_password(password), email, role)
    )

توابع view برای احراز هویت:

from template import render
from auth import authenticate
from models import Category
from http.cookies import SimpleCookie

def login_form_view(request):
    return render('admin/login.html', {'categories': Category.all()})

def login_view(request):
    form = request.form()
    username = form.get('username', '').strip()
    password = form.get('password', '')
    user = authenticate(username, password)
    if not user:
        return render('admin/login.html', {'error': 'نام کاربری یا رمز اشتباه است', 'categories': Category.all()})
    token = secrets.token_urlsafe(32)
    execute('INSERT INTO sessions (token, user_id, created_at) VALUES (?, ?, datetime(\'now\'))', (token, user['id']))
    from http.server import BaseHTTPRequestHandler
    return Response(
        '<script>window.location=\'/admin\'</script>',
        headers={'Set-Cookie': f'session={token}; Path=/; HttpOnly'}
    )

برای اینکه کد کامل باشد، جدول sessions را نیز به schema اضافه کنید:

CREATE TABLE IF NOT EXISTS sessions (
    token TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

داشبورد مدیریت:

from auth import get_current_user

def dashboard_view(request):
    user = get_current_user(request)
    if not user:
        return Response('', 302, headers={'Location': '/admin/login'})
    posts = query('SELECT * FROM posts ORDER BY created_at DESC')
    return render('admin/dashboard.html', {
        'user': user,
        'posts': posts,
        'categories': Category.all(),
    })

قالب templates/admin/dashboard.html:

{% extends "base.html" %}
{% block title %}داشبورد{% endblock %}
{% block content %}
<h1>داشبورد مدیریت</h1>
<p>خوش آمدید، {{ user.username }}</p>
<a href="/admin/posts/new" class="btn">پست جدید</a>
<table>
    <thead>
        <tr><th>عنوان</th><th>وضعیت</th><th>تاریخ</th><th>عملیات</th></tr>
    </thead>
    <tbody>
    {% for post in posts %}
    <tr>
        <td>{{ post.title }}</td>
        <td>{{ post.status }}</td>
        <td>{{ post.created_at }}</td>
        <td>
            <a href="/admin/posts/{{ post.id }}/edit">ویرایش</a>
            <a href="/admin/posts/{{ post.id }}/delete">حذف</a>
        </td>
    </tr>
    {% endfor %}
    </tbody>
</table>
{% endblock %}

احراز هویت و امنیت

احراز هویت با نشست (Session) انجام می‌شود. توکن نشست در کوکی HttpOnly ذخیره می‌شود تا از دسترسی JavaScript جلوگیری شود. تابع get_current_user:

def get_current_user(request):
    token = request.session.get('session')
    if not token:
        return None
    rows = query(
        'SELECT u.* FROM users u JOIN sessions s ON u.id = s.user_id WHERE s.token = ?',
        (token,)
    )
    return rows[0] if rows else None

def require_login(view_func):
    def wrapper(request, *args, **kwargs):
        user = get_current_user(request)
        if not user:
            return Response('', 302, headers={'Location': '/admin/login'})
        request.user = user
        return view_func(request, *args, **kwargs)
    return wrapper

نکات امنیتی مهم:

  • رمز عبور با PBKDF2 و ۱۰۰۰۰۰ تکرار هش می‌شود
  • مقایسه رمز با hmac.compare_digest برای جلوگیری از timing attack
  • کوکی نشست با HttpOnly و در تولید با Secure
  • تمام ورودی‌ها قبل از ذخیره اعتبارسنجی می‌شوند
  • کوئری‌ها پارامتری هستند تا SQL Injection رخ ندهد
  • محتوای HTML پست‌ها قبل از ذخیره sanitize می‌شود

برای مبانی امنیت وب، حملات XSS چیست و چگونه جلوگیری کنیم؟ و CSRF چیست و چگونه دفع می‌شود؟ را ببینید.

«امنیت در پایتون خام، یک ویژگی نیست که بعداً اضافه شود؛ یک تصمیم طراحی است که باید در هر لایه رعایت شود.»

استقرار در تولید

هرگز از http.server در تولید استفاده نکنید. این سرور برای توسعه طراحی شده و در برابر بار بالا و حملات، آسیب‌پذیر است. برای استقرار:

  1. WSGI Server: از Gunicorn یا uWSGI استفاده کنید. اما چون کد ما WSGI نیست، باید آن را به WSGI تبدیل کنیم یا از یک سرور multi-process استفاده کنیم.
  2. Reverse Proxy: Nginx یا Caddy برای مدیریت TLS، فشرده‌سازی و کش
  3. Systemd: برای اجرای خودکار سرویس
  4. HTTPS: با Let's Encrypt و Certbot

نمونه فایل systemd:

[Unit]
Description=CMS Service
After=network.target

[Service]
User=www-data
WorkingDirectory=/var/www/cms
ExecStart=/usr/bin/python3 /var/www/cms/app.py
Restart=always
StandardOutput=append:/var/log/cms/stdout.log
StandardError=append:/var/log/cms/stderr.log

[Install]
WantedBy=multi-user.target

برای درک بهتر لاگ‌های سرور، فایل stderr.log چیست و چه کاربردی دارد؟ را ببینید. همچنین دستورات ضروری CLI برای مدیریت سرور مرجع مفیدی است.

پرسش‌های پرتکرار درباره CMS با پایتون خام

آیا CMS با پایتون خام برای پروژه واقعی مناسب است؟

بله، برای پروژه‌های کوچک و متوسط که نیاز به سرعت توسعه بالا ندارند. اما برای پروژه‌های بزرگ با تیم چندنفره، استفاده از فریمورکی مانند Django یا Flask توصیه می‌شود. برای مقایسه، مقایسه کامل جنگو و فلسک را ببینید.

چرا از SQLite استفاده می‌کنیم و نه PostgreSQL یا MySQL؟

SQLite برای شروع و پروژه‌های کوچک ایده‌آل است چون بدون سرور دیتابیس کار می‌کند. برای پروژه‌های بزرگ، باید به PostgreSQL یا MySQL مهاجرت کنید. برای مبانی اتصال، اتصال پایتون به mysql را ببینید.

چگونه امنیت CMS را افزایش دهم؟

چند کار کلیدی: هش کردن رمز با PBKDF2 یا Argon2، استفاده از پارامترهای positional در کوئری‌ها، sanitize کردن HTML ورودی، استفاده از CSRF token در فرم‌های مدیریتی، فعال کردن HTTPS، محدودیت نرخ درخواست و لاگ‌گیری دقیق.

چگونه سرعت CMS را افزایش دهم؟

چند راهکار: اضافه کردن ایندکس به ستون‌های پرکاربرد، استفاده از FTS5 برای جستجو، کش کردن نتایج کوئری‌ها در حافظه، استفاده از CDN برای فایل‌های استاتیک و بهینه‌سازی کوئری‌ها.

چگونه CMS را به چند زبان تبدیل کنم؟

به جدول‌ها یک ستون language اضافه کنید و slug را با پیشوند زبان ذخیره کنید. یا از جدول جداگانه translations استفاده کنید که هر پست را به نسخه‌های زبانی مختلف متصل می‌کند.

آیا می‌توانم این CMS را با یک فریمورک ترکیب کنم؟

بله، می‌توانید موتور قالب ساده را با Jinja2 جایگزین کنید، یا کل CMS را به‌عنوان یک ماژول در Django یا Flask استفاده کنید. الگوهای طراحی به‌گونه‌ای انتخاب شده‌اند که این مهاجرت ممکن باشد.

چگونه از CMS بکاپ بگیرم؟

کافی است فایل data/cms.db و پوشه templates را کپی کنید. برای بکاپ خودکار، از cron و دستور sqlite3 cms.db ".backup backup.db" استفاده کنید.

چگونه یک تم جدید برای CMS بسازم؟

پوشه قالب‌ها را کپی کنید، فایل‌های HTML و CSS را ویرایش کنید. موتور قالب از هر ساختار HTML معتبری پشتیبانی می‌کند.

چرا کوکی نشست در مرورگر ذخیره نمی‌شود؟

مطمئن شوید هدر Set-Cookie به‌درستی ارسال می‌شود و مسیر Path=/ تنظیم شده است. در مرورگر، بخش Developer Tools و تب Application را بررسی کنید. برای مبانی این مفهوم، چرا کوکی وردپرس کار نمی کند را ببینید که مفاهیم مشابهی دارد.

ملاحظات سطح ارشد

در سطح مهندسی ارشد، ساخت یک CMS با پایتون خام یک تمرین معماری است، نه فقط یک پروژه کدنویسی. چند نکته کلیدی که در مقیاس بزرگ اهمیت پیدا می‌کنند:

۱. جداسازی لایه‌ها با Dependency Injection: به‌جای import مستقیم دیتابیس در view، از یک رابط (Interface) استفاده کنید که بتوان آن را با mock در تست جایگزین کرد. این کار تست‌پذیری را چند برابر می‌کند.

۲. کش چندلایه: برای CMS با ترافیک بالا، باید از کش در سه لایه استفاده کنید: کش HTML کامل (full-page cache)، کش کوئری (query cache) و کش شیء (object cache). ابزارهایی مانند Redis برای این کار مناسب هستند.

۳. صف پیام برای کارهای ناهمزمان: ارسال ایمیل، تولید تصویر بندانگشتی و ایندکس جستجو باید در صف قرار گیرند. برای این کار از Celery یا RQ استفاده کنید. برای مبانی، نوشتن کامند مدیریتی برای پاک‌سازی داده‌های قدیمی را ببینید که الگوهای مشابهی دارد.

۴. Observability: در تولید، باید لاگ‌های ساختاریافته JSON تولید کنید و با Trace ID مرتبط کنید. OpenTelemetry استاندارد فعلی است. برای درک بهتر، فایل stderr.log چیست و چه کاربردی دارد؟ را ببینید.

۵. امنیت در سطح پروتکل: علاوه بر اعتبارسنجی ورودی، باید هدرهای امنیتی HTTP مانند CSP، HSTS، X-Content-Type-Options و X-Frame-Options را تنظیم کنید. این هدرها در برابر حملات رایج وب محافظت می‌کنند.

۶. Migration دیتابیس: در پروژه‌های واقعی، schema دیتابیس تغییر می‌کند. باید یک سیستم migration ساده پیاده کنید که نسخه schema را در جدول schema_version نگه دارد و تغییرات را به‌ترتیب اعمال کند.

۷. تست: برای هر لایه، تست واحد بنویسید. از unittest یا pytest استفاده کنید. برای دیتابیس، از یک فایل SQLite موقت در setUp استفاده کنید و در tearDown آن را حذف کنید.

۸. بهینه‌سازی کوئری: برای لیست پست‌ها، از JOIN به‌جای N+1 query استفاده کنید. مثلاً دسته‌بندی هر پست را در همان کوئری لیست پست‌ها بگیرید، نه در یک کوئری جداگانه برای هر پست.

۹. مدیریت فایل‌های آپلودی: برای آپلود تصویر، باید محدودیت نوع فایل، محدودیت حجم، تغییر نام امن و ذخیره در مسیر خارج از ریشه وب را پیاده کنید. هرگز به نام فایل کاربر اعتماد نکنید.

۱۰. مقیاس‌پذیری افقی: در معماری production، اپلیکیشن باید Stateless باشد. نشست‌ها باید در Redis یا دیتابیس نگهداری شوند، نه در حافظه پروسه. این کار امکان اجرای چندین نمونه پشت Load Balancer را فراهم می‌کند.

«CMS خوب، CMS‌ای است که در روز اول ساده به نظر برسد و در روز هزارم، همچنان قابل نگهداری باشد.»

نگاه نهایی

ساخت سیستم مدیریت محتوا با پایتون خام، یک سفر عمیق به لایه‌های بنیادین وب است. در این راهنما، گام‌به‌گام یک CMS کامل ساختیم: از طراحی schema دیتابیس و لایه مدل، تا موتور قالب ساده، سرور HTTP، مسیریابی، صفحه اصلی، نمایش پست، دسته‌بندی، صفحات داخلی، جستجو و پنل مدیریت. هر بخش با کد کامل و توضیح فنی ارائه شد تا بتوانید مستقیماً آن را اجرا و توسعه دهید.

این پروژه، پایه‌ای برای یادگیری عمیق‌تر است. می‌توانید آن را با قابلیت‌های بیشتری مانند تگ‌گذاری، کامنت، RSS، نقش‌های کاربری چندگانه و API JSON گسترش دهید. همچنین می‌توانید موتور قالب را با Jinja2 یا لایه دیتابیس را با SQLAlchemy جایگزین کنید و تفاوت‌ها را از نزدیک ببینید.

اگر تجربه‌ای از ساخت CMS با پایتون خام دارید یا با چالش خاصی در این مسیر مواجه شده‌اید، خوشحال می‌شوم در دیدگاه‌ها بشنوم. به‌خصوص اگر راه‌حل خلاقانه‌ای برای معماری، امنیت یا عملکرد پیدا کرده‌اید که می‌تواند برای سایر توسعه‌دهندگان مفید باشد، تجربه خود را به اشتراک بگذارید.