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')
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()
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()
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
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
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
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
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 != ''
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()
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')
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])
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()
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], )
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()
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
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(',')
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'