コード例 #1
0
class StoryLocalThread(db.Entity):
    """ Временный костыль из-за ограниченной гибкости комментариев """
    story = orm.Required(Story, unique=True)
    comments_count = orm.Required(int, size=16, unsigned=True, default=0)
    comments = orm.Set('StoryLocalComment')

    bl = Resource('bl.story_local_thread')
コード例 #2
0
class StaticPage(db.Entity):
    name = orm.Required(str, 64)
    lang = orm.Required(str, 6, default='none')
    title = orm.Optional(str, 192)
    content = orm.Optional(orm.LongStr, autostrip=False)
    is_template = orm.Required(bool, default=False)
    is_full_page = orm.Required(bool, default=False)
    date = orm.Required(datetime, 6, default=datetime.utcnow)
    updated = orm.Required(datetime, 6, default=datetime.utcnow)

    orm.PrimaryKey(name, lang)

    bl = Resource('bl.staticpage')

    def __str__(self):
        if self.name == 'robots.txt':
            label = '/robots.txt'
        else:
            label = '/page/{}/'.format(self.name)
        if self.title:
            label += ' - {}'.format(self.title)
        return label

    def before_update(self):
        self.updated = datetime.utcnow()
コード例 #3
0
class StoryComment(db.Entity):
    """ Модель комментария к рассказу """

    id = orm.PrimaryKey(int, auto=True)
    local_id = orm.Required(int)
    parent = orm.Optional('StoryComment', reverse='answers', nullable=True, default=None)
    author = orm.Optional(Author, nullable=True, default=None)
    author_username = orm.Optional(str, 64)  # На случай, если учётную запись автора удалят
    date = orm.Required(datetime, 6, default=datetime.utcnow)
    updated = orm.Required(datetime, 6, default=datetime.utcnow)
    deleted = orm.Required(bool, default=False)
    last_deleted_at = orm.Optional(datetime, 6, index=True)
    last_deleted_by = orm.Optional(Author)
    story = orm.Required(Story)
    text = orm.Optional(orm.LongStr, lazy=False)
    ip = orm.Required(str, 50, default=ipaddress.ip_address('::1').exploded)
    vote_count = orm.Required(int, size=16, unsigned=True, default=0)
    vote_total = orm.Required(int, default=0)

    # Optimizations
    tree_depth = orm.Required(int, size=16, unsigned=True, default=0)
    answers_count = orm.Required(int, size=16, unsigned=True, default=0)
    edits_count = orm.Required(int, size=16, unsigned=True, default=0)
    root_id = orm.Required(int)  # for pagination
    story_published = orm.Required(bool)
    last_edited_at = orm.Optional(datetime, 6)  # only for text updates
    last_edited_by = orm.Optional(Author)  # only for text updates

    extra = orm.Required(orm.LongStr, lazy=False, default='{}')

    votes = orm.Set('StoryCommentVote')
    edits = orm.Set('StoryCommentEdit')
    answers = orm.Set('StoryComment', reverse='parent')

    bl = Resource('bl.story_comment')

    orm.composite_key(story, local_id)
    orm.composite_index(deleted, story_published)
    orm.composite_index(author, deleted, story_published)
    orm.composite_index(story, root_id, tree_depth)

    @property
    def brief_text(self):
        return htmlcrop(self.text, current_app.config['BRIEF_COMMENT_LENGTH'])

    @property
    def text_as_html(self):
        return self.bl.text2html(self.text)

    @property
    def brief_text_as_html(self):
        return self.bl.text2html(self.brief_text)

    def before_update(self):
        self.updated = datetime.utcnow()
コード例 #4
0
class Classifier(db.Entity):
    """ Модель события """

    description = orm.Optional(orm.LongStr)
    name = orm.Required(str, 256)

    stories = orm.Set('Story')

    bl = Resource('bl.classifier')

    def __str__(self):
        return self.name
コード例 #5
0
class CharacterGroup(db.Entity):
    """ Модель группы персонажа """

    name = orm.Required(str, 256)
    description = orm.Optional(orm.LongStr)

    characters = orm.Set('Character')

    bl = Resource('bl.charactergroup')

    def __str__(self):
        return self.name
コード例 #6
0
class Category(db.Entity):
    """ Модель жанра """

    description = orm.Optional(orm.LongStr)
    name = orm.Required(str, 256)
    color = orm.Required(str, 7, default='#808080')

    stories = orm.Set('Story')

    bl = Resource('bl.category')

    def __str__(self):
        return self.name
コード例 #7
0
class TagCategory(db.Entity):
    """ Модель категории тега """

    name = orm.Required(str, 255)
    color = orm.Optional(str, 7)
    description = orm.Optional(orm.LongStr)
    created_at = orm.Required(datetime, 6, default=datetime.utcnow)
    updated_at = orm.Required(datetime, 6, default=datetime.utcnow)

    tags = orm.Set('Tag')

    bl = Resource('bl.tag_category')

    def __str__(self):
        return self.name
コード例 #8
0
class Tag(db.Entity):
    """ Модель тега рассказа """

    name = orm.Required(str, 255)  # fancy human-readable name
    iname = orm.Required(str, 32, unique=True)  # normalized lowercase name
    category = orm.Optional(TagCategory)
    color = orm.Optional(str, 7)
    description = orm.Optional(orm.LongStr)
    is_main_tag = orm.Required(bool, default=False)
    created_at = orm.Required(datetime, 6, default=datetime.utcnow)
    updated_at = orm.Required(datetime, 6, default=datetime.utcnow)
    created_by = orm.Optional(Author)
    stories_count = orm.Required(int, unsigned=True, default=0)
    published_stories_count = orm.Required(int, unsigned=True, default=0)
    is_alias_for = orm.Optional('Tag')
    is_hidden_alias = orm.Required(bool, default=False)
    reason_to_blacklist = orm.Optional(str, 255)

    aliases = orm.Set('Tag', reverse='is_alias_for')
    stories = orm.Set('StoryTag')
    log = orm.Set('StoryTagLog')

    bl = Resource('bl.tag')

    def __str__(self):
        return self.name

    def __repr__(self):
        return '<Tag{}{}: {}>'.format(
            ' [alias]' if self.is_alias else '',
            ' [blacklist]' if self.is_blacklisted else '',
            str(self)
        )

    def get_color(self):
        if self.color:
            return self.color
        if self.category and self.category.color:
            return self.category.color
        return ''

    @property
    def is_alias(self):
        return self.is_alias_for is not None

    @property
    def is_blacklisted(self):
        return self.reason_to_blacklist != ''
コード例 #9
0
class NewsComment(db.Entity):
    """ Модель комментария к новости """

    id = orm.PrimaryKey(int, auto=True)
    local_id = orm.Required(int)
    parent = orm.Optional('NewsComment', reverse='answers', nullable=True, default=None)
    author = orm.Optional(Author, nullable=True, default=None)
    author_username = orm.Optional(str, 64)
    date = orm.Required(datetime, 6, default=datetime.utcnow)
    updated = orm.Required(datetime, 6, default=datetime.utcnow)
    deleted = orm.Required(bool, default=False)
    last_deleted_at = orm.Optional(datetime, 6, index=True)
    last_deleted_by = orm.Optional(Author)
    newsitem = orm.Required(NewsItem)
    text = orm.Optional(orm.LongStr, lazy=False)
    ip = orm.Required(str, 50, default=ipaddress.ip_address('::1').exploded)
    vote_count = orm.Required(int, size=16, unsigned=True, default=0)
    vote_total = orm.Required(int, default=0)

    # Optimizations
    tree_depth = orm.Required(int, size=16, unsigned=True, default=0)
    answers_count = orm.Required(int, size=16, unsigned=True, default=0)
    edits_count = orm.Required(int, size=16, unsigned=True, default=0)
    root_id = orm.Required(int)  # for pagination
    last_edited_at = orm.Optional(datetime, 6)  # only for text updates
    last_edited_by = orm.Optional(Author)  # only for text updates

    extra = orm.Required(orm.LongStr, lazy=False, default='{}')

    votes = orm.Set('NewsCommentVote')
    edits = orm.Set('NewsCommentEdit')
    answers = orm.Set('NewsComment', reverse='parent')

    bl = Resource('bl.news_comment')

    orm.composite_key(newsitem, local_id)
    orm.composite_index(newsitem, root_id, tree_depth)

    @property
    def brief_text(self):
        return htmlcrop(self.text, current_app.config['BRIEF_COMMENT_LENGTH'])

    text_as_html = filtered_html_property('text', filter_html)
    brief_text_as_html = filtered_html_property('brief_text', filter_html)

    def before_update(self):
        self.updated = datetime.utcnow()
コード例 #10
0
class AdminLog(db.Entity):
    """Лог изменений в админке"""
    # ported from django

    ADDITION = 1
    CHANGE = 2
    DELETION = 3

    action_time = orm.Required(datetime, 6, default=datetime.utcnow)
    user = orm.Optional(Author)
    type = orm.Required(AdminLogType)
    object_id = orm.Required(str, 255)  # str for non-integer pk
    object_repr = orm.Optional(str, 255, autostrip=False)
    action_flag = orm.Required(int, size=8)
    change_message = orm.Optional(orm.LongStr)

    bl = Resource('bl.adminlog')
コード例 #11
0
class Logopic(db.Entity):
    """ Модель картинки в шапке сайта """

    picture = orm.Required(str, 255)
    sha256sum = orm.Required(str, 64)
    visible = orm.Required(bool, default=True)
    description = orm.Optional(orm.LongStr)
    original_link = orm.Optional(str, 255)
    original_link_label = orm.Optional(orm.LongStr, lazy=False)
    created_at = orm.Required(datetime, 6, default=datetime.utcnow)
    updated_at = orm.Required(datetime, 6, default=datetime.utcnow)

    bl = Resource('bl.logopic')

    @property
    def url(self):
        return url_for('media', filename=self.picture, v=self.sha256sum[:6])
コード例 #12
0
class HtmlBlock(db.Entity):
    name = orm.Required(str, 64)
    lang = orm.Required(str, 6, default='none')
    content = orm.Optional(orm.LongStr, autostrip=False)
    is_template = orm.Required(bool, default=False)
    date = orm.Required(datetime, 6, default=datetime.utcnow)
    updated = orm.Required(datetime, 6, default=datetime.utcnow)

    orm.PrimaryKey(name, lang)

    bl = Resource('bl.htmlblock')

    def __str__(self):
        return self.name

    def before_update(self):
        self.updated = datetime.utcnow()
コード例 #13
0
class Character(db.Entity):
    """ Модель персонажа """

    description = orm.Optional(orm.LongStr)
    name = orm.Required(str, 256)
    group = orm.Optional(CharacterGroup)
    picture = orm.Required(str, 128)
    sha256sum = orm.Required(str, 64)

    stories = orm.Set('Story')

    bl = Resource('bl.character')

    def __str__(self):
        return self.name

    @property
    def thumb(self):
        return '{}?{}'.format(
            url_for('media', filename=self.picture),
            self.sha256sum[:8],
        )
コード例 #14
0
class NewsItem(db.Entity):
    """ Модель новости """

    name = orm.Required(str, 64, index=True, unique=True)
    show = orm.Required(bool, default=False, index=True)
    author = orm.Required(Author)
    title = orm.Required(str, 192)
    content = orm.Optional(orm.LongStr)
    is_template = orm.Required(bool, default=False)
    date = orm.Required(datetime, 6, default=datetime.utcnow)
    updated = orm.Required(datetime, 6, default=datetime.utcnow)
    extra = orm.Required(orm.LongStr, lazy=False, default='{}')

    comments_count = orm.Required(int, size=16, unsigned=True, default=0)
    last_comment_id = orm.Required(int, default=0, index=True, optimistic=False)  # Для сортировки списка обсуждаемого
    comments = orm.Set('NewsComment')

    bl = Resource('bl.newsitem')

    def __str__(self):
        return self.name

    def before_update(self):
        self.updated = datetime.utcnow()
コード例 #15
0
class Chapter(db.Entity):
    """ Модель главы """

    date = orm.Required(datetime, 6, default=datetime.utcnow)
    story = orm.Required(Story)
    mark = orm.Required(int, size=16, unsigned=True, default=0)
    notes = orm.Optional(orm.LongStr, autostrip=False)
    order = orm.Required(int, size=16, unsigned=True, default=1)
    title = orm.Optional(str, 512, autostrip=False)
    text = orm.Optional(orm.LongStr, autostrip=False)
    text_md5 = orm.Required(str, 32, default='d41d8cd98f00b204e9800998ecf8427e')
    updated = orm.Required(datetime, 6, default=datetime.utcnow)
    words = orm.Required(int, default=0, optimistic=False)
    views = orm.Required(int, default=0, optimistic=False)
    # Глава опубликована только при draft=False и story_published=True
    draft = orm.Required(bool, default=True)
    story_published = orm.Required(bool)  # optimization of stream pages
    first_published_at = orm.Optional(datetime, 6)
    extra = orm.Required(orm.LongStr, lazy=False, default='{}')

    edit_log = orm.Set('StoryLog')

    orm.composite_key(story, order)
    orm.composite_index(first_published_at, order)

    bl = Resource('bl.chapter')

    chapter_views_set = orm.Set('StoryView')

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return url_for('chapter.view_single', story_id=self.story.id, order=self.order)

    def get_prev_chapter(self, allow_draft=False):
        q = orm.select(x for x in Chapter if x.story == self.story and x.order < self.order).order_by(Chapter.order.desc())
        if not allow_draft:
            q = q.filter(lambda x: not x.draft)
        return q.first()

    def get_next_chapter(self, allow_draft=False):
        q = orm.select(x for x in Chapter if x.story == self.story and x.order > self.order).order_by(Chapter.order)
        if not allow_draft:
            q = q.filter(lambda x: not x.draft)
        return q.first()

    @property
    def notes_as_html(self):
        # FIXME: унести кэширование куда-нибудь в bl

        cache_key = 'chapter_notes_html_{}'.format(self.id)
        cached_result = current_app.cache.get(cache_key)
        if cached_result and cached_result[0] == self.updated:
            return Markup(cached_result[1])

        is_new_chapter = (datetime.utcnow() - self.updated).total_seconds() < current_app.config['CHAPTER_NEW_AGE']
        if is_new_chapter:
            cache_timeout = current_app.config['CHAPTER_NEW_HTML_BACKEND_CACHE_TIME']
        else:
            cache_timeout = current_app.config['CHAPTER_OLD_HTML_BACKEND_CACHE_TIME']

        result = self.bl.notes2html(self.notes)
        current_app.cache.set(
            cache_key,
            (self.updated, str(result)),
            timeout=cache_timeout,
        )
        return result

    @property
    def text_as_html(self):
        # FIXME: унести кэширование куда-нибудь в bl

        cache_key = 'chapter_text_html_{}'.format(self.id)
        cached_result = current_app.cache.get(cache_key)
        if cached_result and cached_result[0] == self.updated and cached_result[1] == self.text_md5:
            return Markup(cached_result[2])

        is_new_chapter = (datetime.utcnow() - self.updated).total_seconds() < current_app.config['CHAPTER_NEW_AGE']
        if is_new_chapter:
            cache_timeout = current_app.config['CHAPTER_NEW_HTML_BACKEND_CACHE_TIME']
        else:
            cache_timeout = current_app.config['CHAPTER_OLD_HTML_BACKEND_CACHE_TIME']

        result = self.bl.text2html(self.text)
        current_app.cache.set(
            cache_key,
            (self.updated, self.text_md5, str(result)),
            timeout=cache_timeout,
        )
        return result

    @property
    def text_preview(self):
        text = self.text[:500]
        f = text.rfind(' ')
        if f >= 0 and len(text) == 500:
            text = text[:f]

        f = text.rfind('<')
        if f >= 0 and f > text.rfind('>'):
            text = text[:f]  # 'foo <stro' → 'foo '

        text = text.replace('</p>', '\n</p>').replace('<br', '\n<br')
        text = Markup(text).striptags().replace('\n', ' / ').replace('  ', ' ')
        if len(self.text) > 500:
            text += '...'
        return text

    @property
    def autotitle(self):
        # Если у главы нет заголовка, генерирует его
        if self.title:
            return self.title
        if self.order == 1:
            return self.story.title
        return gettext('Chapter {}').format(self.order)

    def get_filtered_chapter_text(self):
        return self.bl.filter_text(self.text)

    def get_fb2_chapter_text(self):
        # TODO: отрефакторить
        doc = self.bl.filter_text(self.text)
        if self.notes:
            body = doc.xpath('//body')[0]
            ann = self.bl.filter_text(self.notes).xpath('//body')[0]
            ann.tag = 'annotation'
            body.insert(0, ann)
        import lxml.etree
        return doc
コード例 #16
0
class Author(db.Entity, UserMixin):
    """Модель автора"""

    password = orm.Optional(str, 255)
    last_password_change = orm.Optional(datetime, 6, optimistic=False, default=datetime.utcnow)
    last_login = orm.Optional(datetime, 6, optimistic=False)
    last_visit = orm.Optional(datetime, 6, optimistic=False)
    is_superuser = orm.Required(bool, default=False, optimistic=False)
    username = orm.Required(str, 32, unique=True, autostrip=False)
    first_name = orm.Optional(str, 30, autostrip=False)
    last_name = orm.Optional(str, 30, autostrip=False)
    email = orm.Optional(str, 254, index=True)
    is_staff = orm.Required(bool, default=False, optimistic=False)
    is_active = orm.Required(bool, default=True, optimistic=False)
    ban_reason = orm.Optional(orm.LongStr)
    date_joined = orm.Required(datetime, 6, default=datetime.utcnow, optimistic=False)  # Дата отправки формы регистрации
    activated_at = orm.Optional(datetime, 6, optimistic=False)  # Дата перехода по ссылке активации из письма
    session_token = orm.Required(str, 32, optimistic=False)

    premoderation_mode = orm.Optional(str, 8, py_check=lambda x: x in {'', 'off', 'on'})

    bio = orm.Optional(orm.LongStr, autostrip=False)
    excluded_categories = orm.Optional(str, 200)  # TODO: use it on index page
    detail_view = orm.Required(bool, default=False)
    nsfw = orm.Required(bool, default=False)
    comments_per_page = orm.Optional(int, size=16, unsigned=True, nullable=True, default=None)
    comments_maxdepth = orm.Optional(int, size=16, unsigned=True, nullable=True, default=None)
    comment_spoiler_threshold = orm.Optional(int, size=16, nullable=True, default=None)
    header_mode = orm.Optional(str, 8, py_check=lambda x: x in {'', 'off', 'l', 'ls'})

    # Если хранить подписки наизнанку, проще регистрировать народ и добавлять
    # новые типы подписок
    silent_email = orm.Optional(orm.LongStr, lazy=False)
    silent_tracker = orm.Optional(orm.LongStr, lazy=False)
    last_viewed_notification_id = orm.Required(int, default=0, optimistic=False)

    avatar_small = orm.Optional(str, 255)
    avatar_medium = orm.Optional(str, 255)
    avatar_large = orm.Optional(str, 255)

    extra = orm.Required(orm.LongStr, lazy=False, default='{}')

    password_reset_profiles = orm.Set('PasswordResetProfile')
    change_email_profiles = orm.Set('ChangeEmailProfile')
    contacts = orm.Set('Contact')
    contributing = orm.Set('StoryContributor')
    coauthorseries = orm.Set('CoAuthorsSeries')
    approvals = orm.Set('Story', reverse='approved_by')
    published_stories = orm.Set('Story', reverse='published_by_author')
    favorites = orm.Set('Favorites')
    bookmarks = orm.Set('Bookmark')
    edit_log = orm.Set('StoryLog')
    views = orm.Set('StoryView')
    activity = orm.Set('Activity')
    votes = orm.Set('Vote')
    story_comments = orm.Set('StoryComment', reverse='author')
    story_comment_votes = orm.Set('StoryCommentVote')
    story_comment_edits = orm.Set('StoryCommentEdit')
    story_last_edited_comments = orm.Set('StoryComment', reverse='last_edited_by')
    story_deleted_comments = orm.Set('StoryComment', reverse='last_deleted_by')
    story_local_comments = orm.Set('StoryLocalComment', reverse='author')
    story_local_comment_edits = orm.Set('StoryLocalCommentEdit')
    story_local_last_edited_comments = orm.Set('StoryLocalComment', reverse='last_edited_by')
    story_local_deleted_comments = orm.Set('StoryLocalComment', reverse='last_deleted_by')
    news = orm.Set('NewsItem')
    news_comments = orm.Set('NewsComment', reverse='author')
    news_comment_votes = orm.Set('NewsCommentVote')
    news_comment_edits = orm.Set('NewsCommentEdit')
    news_last_edited_comments = orm.Set('NewsComment', reverse='last_edited_by')
    news_deleted_comments = orm.Set('NewsComment', reverse='last_deleted_by')
    notifications = orm.Set('Notification', reverse='user')
    created_notifications = orm.Set('Notification', reverse='caused_by_user')
    subscriptions = orm.Set('Subscription')
    abuse_reports = orm.Set('AbuseReport')
    admin_log = orm.Set('AdminLog')
    tags_created = orm.Set('Tag')
    tags_log = orm.Set('StoryTagLog')

    bl = Resource('bl.author')

    bio_as_html = filtered_html_property('bio', filter_html)

    def __str__(self):
        return self.username

    def get_id(self):
        # for flask-login
        return '{}#{}'.format(self.id, self.session_token)

    @property
    def contributing_stories(self):
        return orm.select(x.story for x in StoryContributor if x.user == self and not x.is_author).without_distinct()

    @property
    def stories(self):
        return orm.select(x.story for x in StoryContributor if x.user == self and x.is_author).without_distinct()

    @property
    def series(self):
        return orm.select(x.series for x in CoAuthorsSeries if x.author == self).without_distinct()

    # @property
    # def excluded_categories_list(self):
    #     return [int(x) for x in self.excluded_categories.split(',') if x]

    @property
    def silent_email_list(self):
        return self.silent_email.split(',')

    @property
    def silent_tracker_list(self):
        return self.silent_tracker.split(',')
コード例 #17
0
class Story(db.Entity):
    """ Модель рассказа """

    title = orm.Required(str, 512, autostrip=False)
    contributors = orm.Set('StoryContributor')
    characters = orm.Set(Character)
    categories = orm.Set(Category)
    classifications = orm.Set(Classifier)
    tags = orm.Set(StoryTag)
    tags_log = orm.Set(StoryTagLog)
    cover = orm.Required(bool, default=False)
    date = orm.Required(datetime, 6, default=datetime.utcnow)
    first_published_at = orm.Optional(datetime, 6, index=True)
    draft = orm.Required(bool, default=True)
    approved = orm.Required(bool, default=False)
    # TODO: finished и freezed объединены в интерфейсе сайта,
    # стоит объединить и в базе тоже
    finished = orm.Required(bool, default=False)
    freezed = orm.Required(bool, default=False)
    favorites = orm.Set('Favorites')
    bookmarks = orm.Set('Bookmark')
    notes = orm.Optional(orm.LongStr, autostrip=False)
    original = orm.Required(bool, default=True)
    rating = orm.Required(Rating)
    summary = orm.Optional(orm.LongStr, lazy=False, autostrip=False)
    updated = orm.Required(datetime, 6, default=datetime.utcnow, optimistic=False)
    words = orm.Required(int, default=0, optimistic=False)
    views = orm.Required(int, default=0, optimistic=False)

    vote_total = orm.Required(int, unsigned=True, default=0, optimistic=False)  # TODO: rename to vote_count
    vote_value = orm.Required(int, default=0, optimistic=False)
    vote_extra = orm.Required(orm.LongStr, lazy=False, default='{}', optimistic=False)

    all_chapters_count = orm.Required(int, size=16, unsigned=True, default=0, optimistic=False)
    published_chapters_count = orm.Required(int, size=16, unsigned=True, default=0, optimistic=False)
    comments_count = orm.Required(int, size=16, unsigned=True, default=0, optimistic=False)
    original_url = orm.Optional(str, 255)
    original_title = orm.Optional(str, 255)
    original_author = orm.Optional(str, 255)
    pinned = orm.Required(bool, default=False)
    robots_noindex = orm.Required(bool, default=False)
    comments_mode = orm.Optional(str, 8, py_check=lambda x: x in {'', 'on', 'off', 'pub', 'nodraft'})
    direct_access = orm.Optional(str, 8, py_check=lambda x: x in {'', 'all', 'none', 'nodraft', 'anodraft'})
    approved_by = orm.Optional(Author)
    published_by_author = orm.Optional(Author)  # Ему будет отправлено уведомление о публикации
    last_author_notification_at = orm.Optional(datetime, 6)  # Во избежание слишком частых уведомлений
    last_staff_notification_at = orm.Optional(datetime, 6)
    publishing_blocked_until = orm.Optional(datetime, 6)
    last_comment_id = orm.Required(int, default=0, index=True, optimistic=False)  # Для сортировки списка обсуждаемого
    extra = orm.Required(orm.LongStr, lazy=False, default='{}')

    in_series_permissions = orm.Set(InSeriesPermissions)
    chapters = orm.Set('Chapter')
    edit_log = orm.Set('StoryLog')
    story_views_set = orm.Set('StoryView')
    activity = orm.Set('Activity')
    votes = orm.Set('Vote')
    comments = orm.Set('StoryComment')
    local = orm.Optional('StoryLocalThread', cascade_delete=True)

    orm.composite_index(approved, draft)
    orm.composite_index(approved, draft, first_published_at)
    orm.composite_index(approved, draft, pinned, first_published_at)
    orm.composite_index(approved, draft, vote_value)

    bl = Resource('bl.story')

    def __str__(self):
        return self.title

    @property
    def url(self):
        return url_for('story.view', pk=self.id)

    @property
    def authors(self):
        return self.bl.get_authors()

    @property
    def published(self):
        return bool(self.approved and not self.draft)

    @classmethod
    def select_published(cls):
        return cls.select(lambda x: x.approved and not x.draft)

    @classmethod
    def select_submitted(cls):
        return cls.select(lambda x: not x.approved and not x.draft)

    def favorited(self, user_id):
        return self.favorites.select(lambda x: x.author.id == user_id).exists()

    def bookmarked(self, user_id):
        return self.bookmarks.select(lambda x: x.author.id == user_id).exists()

    # Дельта количества последних добавленных комментариев с момента посещения юзером рассказа
    def last_comments_by_author(self, author):
        act = self.activity.select(lambda x: x.author.id == author.id).first()
        return act.last_comments if act else 0

    # Проверка возможности публикации
    @property
    def publishable(self):
        return self.bl.is_publishable()

    @property
    def nsfw(self):
        return self.rating.nsfw

    summary_as_html = filtered_html_property('summary', filter_html)
    notes_as_html = filtered_html_property('notes', filter_html)

    def list_downloads(self):
        from mini_fiction.downloads import list_formats
        downloads = []
        for f in list_formats():
            downloads.append({
                'format': f,
                'cls': f.name.lower().replace('+', '-').replace('/', '-'),
                'url': f.url(self),
            })
        return downloads

    @property
    def status_string(self):
        if self.finished:
            return 'finished'
        if self.freezed:
            return 'freezed'
        return 'unfinished'