diff --git a/codingpy/__init__.py b/codingpy/__init__.py index 4e1d890..c6f8219 100644 --- a/codingpy/__init__.py +++ b/codingpy/__init__.py @@ -1,39 +1,100 @@ #!usr/bin/env python # -*- coding: utf-8 -*- -import os -import sys - -from flask import Flask, render_template, g, request -from flask.ext.bootstrap import Bootstrap -from flask.ext.mail import Mail, Message -from flask.ext.moment import Moment -from flask.ext.login import LoginManager, current_user, logout_user + +# import os +# import sys + +from flask import Flask, send_from_directory, flash, render_template +from config import config from flask_wtf.csrf import CsrfProtect +from flask.login import logout_user, current_user +from ext import (babel, bootstrap, db, moment, cache, mail, + login_manager, bcrypt) + +from models import User, AnonymousUser # 将project目录加入sys.path -project_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) -if project_path not in sys.path: - sys.path.insert(0, project_path) +# project_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +# if project_path not in sys.path: +# sys.path.insert(0, project_path) csrf = CsrfProtect() -bootstrap = Bootstrap() -moment = Moment() -mail = Mail() + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) def create_app(config_name): app = Flask(__name__) app.config.from_object(config[config_name]) - config[config_name].init_app(app) + # config[config_name].init_app(app) + db.init_app(app) bootstrap.init_app(app) mail.init_app(app) moment.init_app(app) csrf.init_app(app) + babel.init_app(app) + cache.init_app(app) + bcrypt.init_app(app) - register_db(app) - register_login_manager(app) + register_managers(app) register_routes(app) - register_uploadsets(app) - register_error_handle(app) + # register_uploadsets(app) + # register_error_handle(app) + + # before every request + @app.before_request + def before_request(): + if not current_user.is_anonymous: + if not current_user.confirmed: + flash('请登录邮箱激活账户。') + logout_user() + if current_user.is_banned: + flash('账户已被禁用,请联系管理员。') + logout_user() + + @app.route('/favicon.ico') + def favicon(): + return send_from_directory(app.static_folder, 'favicon.ico', + mimetype='image/vnd.microsoft.icon') + + @app.route('/robots.txt') + def robotstxt(): + return send_from_directory(app.static_folder, 'robots.txt') + + return app + + +def register_routes(app): + from .controllers import admin, site, account + + app.register_blueprint(site.bp, url_prefix='') + app.register_blueprint(account.bp, url_prefix='/account') + app.register_blueprint(admin.bp, url_prefix='/admin') + + +def register_managers(app): + login_manager.session_protection = 'strong' + # flask-login will keep track of ip and broswer agent, + # will log user out if it detects a change + login_manager.login_view = 'account.login' + login_manager.login_message = '请先登陆' + login_manager.anonymous_user = AnonymousUser + login_manager.init_app(app) + + +def register_error_handle(app): + @app.errorhandler(403) + def page_403(error): + return render_template('errors/403.html'), 403 + + @app.errorhandler(404) + def page_404(error): + return render_template('errors/404.html'), 404 + + @app.errorhandler(500) + def page_500(error): + return render_template('errors/500.html'), 500 diff --git a/codingpy/controllers/account.py b/codingpy/controllers/account.py new file mode 100644 index 0000000..e69de29 diff --git a/codingpy/decorators.py b/codingpy/decorators.py new file mode 100644 index 0000000..799bee1 --- /dev/null +++ b/codingpy/decorators.py @@ -0,0 +1,31 @@ +#!usr/bin/env python +# -*- coding: utf-8 -*- + +from threading import Thread +from functools import wraps +from flask import abort +from flask.ext.login import current_user + +from models import Permission + + +def async(f): + def wrapper(*args, **kwargs): + thr = Thread(target=f, args=args, kwargs=kwargs) + thr.start() + return wrapper + + +def permission_required(permission): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.can(permission): + abort(403) + return f(*args, **kwargs) + return decorated_function + return decorator + + +def admin_required(f): + return permission_required(Permission.ADMINISTER)(f) diff --git a/codingpy/ext.py b/codingpy/ext.py new file mode 100644 index 0000000..31834fd --- /dev/null +++ b/codingpy/ext.py @@ -0,0 +1,269 @@ +#!usr/bin/env python +# -*- coding: utf-8 -*- + +import logging +import datetime +from functools import wraps +from urllib2 import quote + +from flask import current_app, request, redirect, url_for +from flask.ext.bootstrap import Bootstrap +from flask.ext.mail import Mail, Message +from flask.ext.moment import Moment +from flask.ext.login import LoginManager +from flask.ext.sqlalchemy import SQLAlchemy +from flask.ext.babelex import Babel +from flask.ext.cache import Cache +from flask.ext.bcrypt import Bcrypt +from werkzeug._compat import text_type, to_bytes + + +def keywords_split(keywords): + return keywords.replace(u',', ' ') \ + .replace(u';', ' ') \ + .replace(u'+', ' ') \ + .replace(u';', ' ') \ + .replace(u',', ' ') \ + .replace(u' ', ' ') \ + .split(' ') + + +class SMTPHandler(logging.Handler): + + """ + A handler class which sends an SMTP email for each logging event. + + 默认不支持SSL协议的,增加参数use_ssl,如果使用SSL,则使用SMTP_SSL + """ + + def __init__(self, mailhost, fromaddr, toaddrs, subject, + credentials=None, secure=None, use_ssl=False): + """ + Initialize the handler. + + Initialize the instance with the from and to addresses and subject + line of the email. To specify a non-standard SMTP port, use the + (host, port) tuple format for the mailhost argument. To specify + authentication credentials, supply a (username, password) tuple + for the credentials argument. To specify the use of a secure + protocol (TLS), pass in a tuple for the secure argument. This will + only be used when authentication credentials are supplied. The tuple + will be either an empty tuple, or a single-value tuple with the name + of a keyfile, or a 2-value tuple with the names of the keyfile and + certificate file. (This tuple is passed to the `starttls` method). + """ + logging.Handler.__init__(self) + if isinstance(mailhost, tuple): + self.mailhost, self.mailport = mailhost + else: + self.mailhost, self.mailport = mailhost, None + if isinstance(credentials, tuple): + self.username, self.password = credentials + else: + self.username = None + self.fromaddr = fromaddr + if isinstance(toaddrs, basestring): + toaddrs = [toaddrs] + self.toaddrs = toaddrs + self.subject = subject + self.secure = secure + self.use_ssl = use_ssl + self._timeout = 5.0 + + def get_subject(self, record): + """ + Determine the subject for the email. + + If you want to specify a subject line which is record-dependent, + override this method. + """ + return self.subject + + def emit(self, record): + """ + Emit a record. + + Format the record and send it to the specified addressees. + """ + try: + import smtplib + port = self.mailport + if not port: + port = smtplib.SMTP_PORT + if self.use_ssl: + smtp = smtplib.SMTP_SSL( + self.mailhost, port, timeout=self._timeout) + else: + smtp = smtplib.SMTP(self.mailhost, port, timeout=self._timeout) + + _msg = self.format(record) + msg = Message(self.get_subject(record), + sender=self.fromaddr, recipients=self.toaddrs) + msg.body = _msg + + if self.username: + if self.secure is not None: + smtp.ehlo() + smtp.starttls(*self.secure) + smtp.ehlo() + smtp.login(self.username, self.password) + smtp.sendmail(self.fromaddr, self.toaddrs, msg.as_string()) + smtp.quit() + except (KeyboardInterrupt, SystemExit): + raise + except: + self.handleError(record) + + +class CodingpyCache(Cache): + + def init_app(self, app, config=None): + super(Cache, self).init_app(app) + self.app = app + + def cached(self, timeout=None, key_prefix=None, unless=None): + """ + Decorator. Use this to cache a function. By default the cache key + is `view/request.path`. You are able to use this decorator with any + function by changing the `key_prefix`. If the token `%s` is located + within the `key_prefix` then it will replace that with `request.path` + + Example:: + + # An example view function + @cache.cached(timeout=50) + def big_foo(): + return big_bar_calc() + + # An example misc function to cache. + @cache.cached(key_prefix='MyCachedList') + def get_list(): + return [random.randrange(0, 1) for i in range(50000)] + + my_list = get_list() + + .. note:: + + You MUST have a request context to actually called any functions + that are cached. + + .. versionadded:: 0.4 + The returned decorated function now has three function attributes + assigned to it. These attributes are readable/writable. + + **uncached** + The original undecorated function + + **cache_timeout** + The cache timeout value for this function. + For a custom value to take affect, this must be + set before the function is called. + + **make_cache_key** + A function used in generating the cache_key used. + + :param timeout: Default None. If set to an integer, will cache for that + amount of time. Unit of time is in seconds. + :param key_prefix: Default 'view/%(request.path)s'. Beginning key to . + use for the cache key. + + .. versionadded:: 0.3.4 + Can optionally be a callable + which takes no arguments + but returns a string that will be + used as the cache_key. + + :param unless: Default None. Cache will *always* execute the caching + facilities unless this callable is true. + This will bypass the caching entirely. + """ + if key_prefix is None: + key_prefix = self.app.config.get('CACHE_KEY', 'view/%s') + if timeout is None: + timeout = self.app.config.get('CACHE_DEFAULT_TIMEOUT', 300) + + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + # 如果是第一页,跳转到标准页面 + # 比如: `/page/1/` 跳转到 `/` + _kw = request.view_args + if 'page' in _kw and _kw['page'] == 1: + _kw.pop('page') + return redirect(url_for(request.endpoint, **_kw), code=301) + + # Bypass the cache entirely. + if callable(unless) and unless() is True: + return f(*args, **kwargs) + + try: + cache_key = decorated_function.make_cache_key( + *args, **kwargs) + if request.MOBILE: + cache_key = 'mobile_%s' % cache_key + rv = self.cache.get(cache_key) + except Exception: + if current_app.debug: + raise + current_app.logger.exception( + "Exception possibly due to cache backend.") + return f(*args, **kwargs) + + if rv is None: + rv = f(*args, **kwargs) + + # 添加缓存时间信息 + _suffix = u"\n" % str( + datetime.datetime.now()) + if hasattr(rv, "data"): + rv.data = '%s%s' % (rv.data, _suffix) + if isinstance(rv, text_type): + rv = '%s%s' % (rv, _suffix) + + try: + self.cache.set( + cache_key, rv, decorated_function.cache_timeout) + except Exception: + if current_app.debug: + raise + current_app.logger.exception( + "Exception possibly due to cache backend.") + return f(*args, **kwargs) + return rv + + def make_cache_key(*args, **kwargs): + if callable(key_prefix): + cache_key = key_prefix() + elif '%s' in key_prefix: + # 这里要转换成str(UTF-8)类型, 否则会报类型错误 + _path = to_bytes(request.path, 'utf-8') + # 对于非ASCII的URL,需要进行URL编码 + if quote(_path).count('%25') <= 0: + _path = quote(_path) + cache_key = key_prefix % _path + else: + cache_key = key_prefix + + return cache_key + + decorated_function.uncached = f + decorated_function.cache_timeout = timeout + decorated_function.make_cache_key = make_cache_key + + return decorated_function + return decorator + +mail = Mail() +db = SQLAlchemy() +babel = Babel() +moment = Moment() +bootstrap = Bootstrap() +cache = CodingpyCache() +bcrypt = Bcrypt() + +login_manager = LoginManager() + + +@babel.localeselector +def get_locale(): + return request.accept_languages.best_match(['en', 'zh_CN', 'zh_TW']) diff --git a/codingpy/models.py b/codingpy/models.py new file mode 100644 index 0000000..09a9365 --- /dev/null +++ b/codingpy/models.py @@ -0,0 +1,855 @@ +#!usr/bin/env python +# -*- coding: utf-8 -*- + +import re +import json +import hashlib +from datetime import datetime + +from flask import current_app, request, url_for +from flask.ext.login import UserMixin, AnonymousUserMixin +from flask.ext.sqlalchemy import BaseQuery +from itsdangerous import URLSafeTimedSerializer as Serializer +from werkzeug import cached_property +from jinja2.filters import do_striptags, do_truncate +from .ext import db, bcrypt, keywords_split, to_bytes +from .utils.filters import markdown_filter + +from config import Config + +BODY_FORMAT = Config.BODY_FORMAT + +pattern_hasmore = re.compile(r'', re.I) + + +def markitup(text): + """ + 把Markdown转换为HTML + + 默认不生成高亮代码。 + + 若需要生成高亮代码,需在Setting增加codehilite设置值,类型为int, + 值大于0. 另外需要安装Pygments。 + """ + try: + _flag = Setting.get('codehilite', False) and True + except: + _flag = False + return markdown_filter(text, codehilite=_flag) + + +class Permission: + + '''定义角色拥有的权限''' + + #: 写的文章是草稿,不公开 + WRITE_ARTICLES = 0x04 + + #: 可以公开文章 + PUBLISH_ARTICLES = 0x08 + + #: 上传文件 + UPLOAD_FILES = 0x10 + + #: 管理后台的权限 + ADMINISTER = 0x80 + + # TODO + # MODERATE_COMMENTS = + + # WRITE_COMMENTS = + + +class Role(db.Model): + + __tablename__ = 'roles' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(64), unique=True) + default = db.Column(db.Boolean, default=False, index=True) + permissions = db.Column(db.Integer) + users = db.relationship('User', backref='role', lazy='dynamic') + + __mapper_args__ = {'order_by': [id.desc()]} + + @staticmethod + def insert_roles(): + roles = { + 'User': (Permission.WRITE_ARTICLES, True), + 'Moderator': (Permission.WRITE_ARTICLES | + Permission.PUBLISH_ARTICLES, False), + 'Administrator': (0xff, False) + } + for r in roles: + role = Role.query.filter_by(name=r).first() + if role is None: + role = Role(name=r) + role.permissions = roles[r][0] + role.default = roles[r][1] + db.session.add(role) + db.session.commit() + + def __repr__(self): + return '' % self.name + + def __unicode__(self): + return self.name + + +class User(UserMixin, db.Model): + + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(64), unique=True) + username = db.Column(db.String(64), unique=True) + name = db.Column(db.String(64)) + + role_id = db.Column(db.Integer, db.ForeignKey(Role.id)) + password_hash = db.Column(db.String(128)) + confirmed = db.Column(db.Boolean, default=False) + about_me = db.Column(db.String(1000)) + member_since = db.Column(db.DateTime(), default=datetime.now) + last_seen = db.Column(db.DateTime(), default=datetime.now) + avatar_hash = db.Column(db.String(32)) + + __mapper_args__ = {'order_by': [confirmed.desc(), id.desc()]} + + def __init__(self, **kwargs): + super(User, self).__init__(**kwargs) + if self.role is None: + if self.email == Config.APP_ADMIN: + self.role = Role.query.filter_by(permissions=0xff).first() + if self.role is None: + self.role = Role.query.filter_by(default=True).first() + if self.email is not None and self.avatar_hash is None: + self.avatar_hash = hashlib.md5( + self.email.encode('utf-8')).hexdigest() + + @staticmethod + def authenticate(username, password): + """ + 验证用户 + + :param username: 用户名或者电子邮件地址 + :param password: 用户密码 + """ + user = User.query.filter(db.or_(User.username == username, + User.email == username)).first() + if isinstance(user, User): + if user.verify_password(password): + return None, user + else: + return '密码错误', None + return '用户名错误', None + + @staticmethod + def make_unique_username(username): + if User.query.filter_by(username=username).first() is None: + return username + version = 2 + while True: + new_username = '%s%s' % (username, str(version)) + if User.query.filter_by(username=new_username).first() is None: + break + version += 1 + return new_username + + @property + def password(self): + raise AttributeError('password is not a readable attribute') + + @password.setter + def password(self, password): + self.password_hash = bcrypt.generate_password_hash(password) + + def verify_password(self, password): + _hash = to_bytes(self.password_hash) + return bcrypt.check_password_hash(_hash, password) + + def generate_confirmation_token(self): + s = Serializer(current_app.config['SECRET_KEY']) + return s.dumps({'confirm': self.id}) + + def confirm(self, token, expiration=3600): + s = Serializer(current_app.config['SECRET_KEY']) + try: + data = s.loads(token, max_age=expiration) + except: + return False + if data.get('confirm') != self.id: + return False + self.confirmed = True + db.session.add(self) + db.session.commit() + return True + + def generate_reset_token(self): + s = Serializer(current_app.config['SECRET_KEY']) + return s.dumps({'reset': self.id}) + + def reset_password(self, token, new_password, expiration=3600): + s = Serializer(current_app.config['SECRET_KEY']) + try: + data = s.loads(token, max_age=expiration) + except: + return False + if data.get('reset') != self.id: + return False + self.password_hash = bcrypt.generate_password_hash(new_password) + db.session.add(self) + db.session.commit() + return True + + def generate_email_change_token(self, new_email): + s = Serializer(current_app.config['SECRET_KEY']) + return s.dumps({'change_email': self.id, 'new_email': new_email}) + + def change_email(self, token, expiration=3600): + s = Serializer(current_app.config['SECRET_KEY']) + try: + data = s.loads(token, max_age=expiration) + except: + return False + if data.get('change_email') != self.id: + return False + new_email = data.get('new_email') + if new_email is None: + return False + if self.query.filter_by(email=new_email).first() is not None: + return False + self.email = new_email + self.avatar_hash = hashlib.md5( + self.email.encode('utf-8')).hexdigest() + db.session.add(self) + db.session.commit() + return True + + def can(self, permissions): + return self.role is not None and \ + (self.role.permissions & permissions) == permissions + + def is_administrator(self): + return self.can(Permission.ADMINISTER) & self.confirmed + + def ping(self): + self.last_seen = datetime.now() + db.session.add(self) + db.session.commit() + + def gravatar(self, size=100, default='identicon', rating='g'): + if request.is_secure: + url = 'https://secure.gravatar.com/avatar' + else: + url = 'http://www.gravatar.com/avatar' + url = 'https://secure.gravatar.com/avatar' + hash = self.avatar_hash or hashlib.md5( + self.email.encode('utf-8')).hexdigest() + return '{url}/{hash}?s={size}&d={default}&r={rating}'.format( + url=url, hash=hash, size=size, default=default, rating=rating) + + def __repr__(self): + return '' % (self.name or self.username) + + def __unicode__(self): + return self.name or self.username + + +class AnonymousUser(AnonymousUserMixin): + + def can(self, permissions): + return False + + def is_administrator(self): + return False + + +# Create M2M table +article_tags_table = db.Table( + 'article_tags', + db.Model.metadata, + db.Column('article_id', db.Integer, db.ForeignKey( + "articles.id", ondelete='CASCADE')), + db.Column('tag_id', db.Integer, db.ForeignKey( + "tags.id", ondelete='CASCADE')), +) + + +class Category(db.Model): + + """目录""" + + __tablename__ = "categories" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(64), nullable=False) + longslug = db.Column( + db.String(255), unique=True, index=True, nullable=False) + name = db.Column(db.String(64), nullable=False) + + parent_id = db.Column(db.Integer(), db.ForeignKey('categories.id')) + parent = db.relationship('Category', + primaryjoin=('Category.parent_id == Category.id'), + remote_side=id, backref=db.backref("children")) + + # SEO page title + seotitle = db.Column(db.String(128)) + seokey = db.Column(db.String(128)) + seodesc = db.Column(db.String(300)) + + thumbnail = db.Column(db.String(255)) + template = db.Column(db.String(255)) + article_template = db.Column(db.String(255)) + + body = db.Column(db.Text) + body_html = db.Column(db.Text) + + __mapper_args__ = {'order_by': [longslug]} + + def __repr__(self): + return '' % (self.name) + + def __unicode__(self): + return self.longslug or self.name + + @cached_property + def link(self): + return url_for('main.category', longslug=self.longslug, _external=True) + + @cached_property + def shortlink(self): + return url_for('main.category', longslug=self.longslug) + + @cached_property + def count(self): + cates = db.session.query(Category.id).filter( + Category.longslug.startswith(self.longslug)).all() + cate_ids = [cate.id for cate in cates] + return Article.query.public().filter( + Article.category_id.in_(cate_ids)).count() + + @cached_property + def parents(self): + lst = [] + lst.append(self) + c = self.parent + while c is not None: + lst.append(c) + c = c.parent + lst.reverse() + return lst + + @staticmethod + def tree(): + """树形列表""" + cates = Category.query.all() + out = [] + for cate in cates: + indent = len(cate.longslug.split('/')) - 1 + out.append((indent, cate)) + return out + + @staticmethod + def on_changed_body(target, value, oldvalue, initiator): + if BODY_FORMAT == 'html': + target.body_html = value + else: + target.body_html = markitup(value) + + @staticmethod + def on_changed_longslug(target, value, oldvalue, initiator): + '''如果栏目有子栏目,则不允许更改longslug,因为会造成longslug不一致''' + if target.children and value != oldvalue: + raise Exception( + 'Category has children, longslug can not be change!') + + def gen_longslug(self): + '''生成longslug''' + if self.parent: + _longslug = '/'.join([self.parent.longslug, self.slug]).lower() + else: + _longslug = self.slug.lower() + self.longslug = _longslug + + @staticmethod + def before_insert(mapper, connection, target): + target.gen_longslug() + + _c = Category.query.filter_by(longslug=target.longslug).first() + # 新增时判断longslug是否重复 + if _c: + raise Exception( + 'Category longslug "%s" already exist' % _c.longslug) + + @staticmethod + def before_update(mapper, connection, target): + target.gen_longslug() + + _c = Category.query.filter_by(longslug=target.longslug).first() + # 更新时判断longslug是否重复 + if _c and _c.id != target.id: + raise Exception( + 'Category longslug "%s" already exist' % _c.longslug) + +db.event.listen(Category.body, 'set', Category.on_changed_body) +db.event.listen(Category.longslug, 'set', Category.on_changed_longslug) +db.event.listen(Category, 'before_insert', Category.before_insert) +db.event.listen(Category, 'before_update', Category.before_update) + + +class TagQuery(BaseQuery): + + def search(self, keyword): + keyword = u'%{0}%'.format(keyword.strip()) + return self.filter(Tag.name.ilike(keyword)) + + +class Tag(db.Model): + + """标签""" + + __tablename__ = "tags" + + query_class = TagQuery + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, index=True, nullable=False) + + # SEO info + seotitle = db.Column(db.String(128)) + seokey = db.Column(db.String(128)) + seodesc = db.Column(db.String(300)) + + thumbnail = db.Column(db.String(255)) + template = db.Column(db.String(255)) + + body = db.Column(db.Text) + body_html = db.Column(db.Text) + + __mapper_args__ = {'order_by': [id.desc()]} + + def __repr__(self): + return '' % (self.name) + + def __unicode__(self): + return self.name + + @cached_property + def link(self): + return url_for('main.tag', name=self.name.lower(), _external=True) + + @cached_property + def shortlink(self): + return url_for('main.tag', name=self.name.lower()) + + @cached_property + def count(self): + return Article.query.public().filter( + Article.tags.any(id=self.id)).count() + + @staticmethod + def on_changed_body(target, value, oldvalue, initiator): + if BODY_FORMAT == 'html': + target.body_html = value + else: + target.body_html = markitup(value) + +db.event.listen(Tag.body, 'set', Tag.on_changed_body) + + +class Topic(db.Model): + + """专题""" + + __tablename__ = "topics" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(64), unique=True, index=True, nullable=False) + name = db.Column(db.String(64), nullable=False) + + # SEO page title + seotitle = db.Column(db.String(128)) + seokey = db.Column(db.String(128)) + seodesc = db.Column(db.String(300)) + + thumbnail = db.Column(db.String(255)) + template = db.Column(db.String(255)) + + body = db.Column(db.Text) + body_html = db.Column(db.Text) + + __mapper_args__ = {'order_by': [id.desc()]} + + def __repr__(self): + return '' % (self.name) + + def __unicode__(self): + return self.name + + @cached_property + def link(self): + return url_for('main.topic', slug=self.slug, _external=True) + + @cached_property + def shortlink(self): + return url_for('main.topic', slug=self.slug) + + @staticmethod + def on_changed_body(target, value, oldvalue, initiator): + if BODY_FORMAT == 'html': + target.body_html = value + else: + target.body_html = markitup(value) + +db.event.listen(Topic.body, 'set', Topic.on_changed_body) + + +class ArticleQuery(BaseQuery): + + def public(self): + return self.filter_by(published=True) + + def search(self, keyword): + criteria = [] + + for keyword in keywords_split(keyword): + keyword = u'%{0}%'.format(keyword) + criteria.append(db.or_(Article.title.ilike(keyword),)) + + q = reduce(db.or_, criteria) + return self.public().filter(q) + + def archives(self, year, month): + if not year: + return self + + criteria = [] + criteria.append(db.extract('year', Article.created) == year) + if month: + criteria.append(db.extract('month', Article.created) == month) + + q = reduce(db.and_, criteria) + return self.public().filter(q) + + +class Article(db.Model): + + """贴文""" + + __tablename__ = "articles" + + query_class = ArticleQuery + + PER_PAGE = 10 + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(200)) + title = db.Column(db.String(200), nullable=False) + + seotitle = db.Column(db.String(200)) + seokey = db.Column(db.String(128)) + seodesc = db.Column(db.String(300)) + + category_id = db.Column( + db.Integer(), db.ForeignKey(Category.id), nullable=False,) + category = db.relationship(Category, backref=db.backref("articles")) + + topic_id = db.Column(db.Integer(), db.ForeignKey(Topic.id)) + topic = db.relationship(Topic, backref=db.backref("articles")) + + tags = db.relationship( + Tag, secondary=article_tags_table, backref=db.backref("articles")) + + thumbnail = db.Column(db.String(255)) + thumbnail_big = db.Column(db.String(255)) + template = db.Column(db.String(255)) + + summary = db.Column(db.String(2000)) + body = db.Column(db.Text, nullable=False) + body_html = db.Column(db.Text) + + published = db.Column(db.Boolean, default=True) + ontop = db.Column(db.Boolean, default=False) + recommended = db.Column(db.Boolean, default=False) + + hits = db.Column(db.Integer, default=0) + + author_id = db.Column(db.Integer, db.ForeignKey(User.id)) + author = db.relationship(User, backref=db.backref("articles")) + + created_at = db.Column(db.DateTime()) + last_modified = db.Column(db.DateTime()) + + __mapper_args__ = {'order_by': [ontop.desc(), id.desc()]} + + def __repr__(self): + return '' % (self.title) + + def __unicode__(self): + return self.title + + @cached_property + def has_more(self): + return pattern_hasmore.search(self.body) is not None or \ + self.summary.find('...') >= 0 + + @cached_property + def link(self): + return url_for('main.article', article_id=self.id, _external=True) + + @cached_property + def shortlink(self): + return url_for('main.article', article_id=self.id) + + @cached_property + def get_next(self): + _query = db.and_(Article.category_id.in_([self.category.id]), + Article.id > self.id) + return self.query.public().filter(_query) \ + .order_by(Article.id.asc()) \ + .first() + + @cached_property + def get_prev(self): + _query = db.and_(Article.category_id.in_([self.category.id]), + Article.id < self.id) + return self.query.public().filter(_query) \ + .order_by(Article.id.desc()) \ + .first() + + @staticmethod + def before_insert(mapper, connection, target): + def _format(_html): + return do_truncate(do_striptags(_html), length=200) + + value = target.body + if target.summary is None or target.summary.strip() == '': + # 新增文章时,如果 summary 为空,则自动生成 + if BODY_FORMAT == 'html': + target.summary = _format(value) + else: + _match = pattern_hasmore.search(value) + if _match is not None: + more_start = _match.start() + target.summary = _format(markitup(value[:more_start])) + else: + target.summary = _format(target.body_html) + + @staticmethod + def on_changed_body(target, value, oldvalue, initiator): + if BODY_FORMAT == 'html': + target.body_html = value + else: + target.body_html = markitup(value) + +db.event.listen(Article.body, 'set', Article.on_changed_body) +db.event.listen(Article, 'before_insert', Article.before_insert) + + +class Link(db.Model): + + """内部链接""" + + __tablename__ = 'links' + + id = db.Column(db.Integer, primary_key=True) + anchor = db.Column(db.String(64), nullable=False) + title = db.Column(db.String(128)) + url = db.Column(db.String(255), nullable=False) + note = db.Column(db.String(200)) + + __mapper_args__ = {'order_by': [id.desc()]} + + def __repr__(self): + return '' % (self.anchor) + + def __unicode__(self): + return self.anchor + + +class FriendLink(db.Model): + + """友情链接""" + + __tablename__ = 'friendlinks' + + id = db.Column(db.Integer, primary_key=True) + anchor = db.Column(db.String(64), nullable=False) + title = db.Column(db.String(128)) + url = db.Column(db.String(255), nullable=False) + actived = db.Column(db.Boolean, default=False) + order = db.Column(db.Integer, default=1) + note = db.Column(db.String(400)) + + __mapper_args__ = {'order_by': [actived.desc(), order.asc()]} + + def __repr__(self): + return '' % (self.anchor) + + def __unicode__(self): + return self.anchor + + +class Flatpage(db.Model): + + """单页面""" + + __tablename__ = 'flatpages' + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(32), nullable=False) + title = db.Column(db.Unicode(100), nullable=False, ) + seotitle = db.Column(db.Unicode(200)) + seokey = db.Column(db.Unicode(128)) + seodesc = db.Column(db.Unicode(400)) + template = db.Column(db.String(255)) + body = db.Column(db.Text, nullable=False) + body_html = db.Column(db.Text, nullable=False) + + __mapper_args__ = {'order_by': [id.desc()]} + + def __repr__(self): + return '' % (self.title) + + def __unicode__(self): + return self.title + + @cached_property + def link(self): + return url_for('main.flatpage', slug=self.slug, _external=True) + + @cached_property + def shortlink(self): + return url_for('main.flatpage', slug=self.slug) + + @staticmethod + def on_changed_body(target, value, oldvalue, initiator): + if BODY_FORMAT == 'html': + target.body_html = value + else: + target.body_html = markitup(value) + +db.event.listen(Flatpage.body, 'set', Flatpage.on_changed_body) + + +class Label(db.Model): + + """HTML代码片断""" + + __tablename__ = 'labels' + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(32), nullable=False) + title = db.Column(db.Unicode(100), nullable=False, ) + html = db.Column(db.Text, nullable=False) + + __mapper_args__ = {'order_by': [id.desc()]} + + def __repr__(self): + return '