| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | """ |
|---|
| 3 | zine.models |
|---|
| 4 | ~~~~~~~~~~~ |
|---|
| 5 | |
|---|
| 6 | The core models and query helper functions. |
|---|
| 7 | |
|---|
| 8 | :copyright: (c) 2010 by the Zine Team, see AUTHORS for more details. |
|---|
| 9 | :license: BSD, see LICENSE for more details. |
|---|
| 10 | """ |
|---|
| 11 | from math import log |
|---|
| 12 | from datetime import date, datetime, timedelta |
|---|
| 13 | from urlparse import urljoin |
|---|
| 14 | |
|---|
| 15 | from werkzeug.exceptions import NotFound |
|---|
| 16 | |
|---|
| 17 | from zine.database import users, categories, posts, post_links, \ |
|---|
| 18 | post_categories, post_tags, tags, comments, groups, group_users, \ |
|---|
| 19 | privileges, user_privileges, group_privileges, texts, \ |
|---|
| 20 | notification_subscriptions, schema_versions, db |
|---|
| 21 | from zine.utils import zeml |
|---|
| 22 | from zine.utils.text import gen_slug, gen_timestamped_slug, build_tag_uri, \ |
|---|
| 23 | increment_string |
|---|
| 24 | from zine.utils.pagination import Pagination |
|---|
| 25 | from zine.utils.crypto import gen_pwhash, check_pwhash |
|---|
| 26 | from zine.utils.http import make_external_url |
|---|
| 27 | from zine.privileges import _Privilege, privilege_attribute, \ |
|---|
| 28 | add_admin_privilege, MODERATE_COMMENTS, ENTER_ADMIN_PANEL, BLOG_ADMIN, \ |
|---|
| 29 | VIEW_DRAFTS, VIEW_PROTECTED, MODERATE_OWN_ENTRIES, MODERATE_OWN_PAGES |
|---|
| 30 | from zine.application import get_application, get_request, url_for |
|---|
| 31 | |
|---|
| 32 | from zine.i18n import to_blog_timezone |
|---|
| 33 | |
|---|
| 34 | #: all kind of states for a post |
|---|
| 35 | STATUS_DRAFT = 1 |
|---|
| 36 | STATUS_PUBLISHED = 2 |
|---|
| 37 | STATUS_PROTECTED = 3 |
|---|
| 38 | STATUS_PRIVATE = 4 |
|---|
| 39 | |
|---|
| 40 | #: Comment Status |
|---|
| 41 | COMMENT_MODERATED = 0 |
|---|
| 42 | COMMENT_UNMODERATED = 1 |
|---|
| 43 | COMMENT_BLOCKED_USER = 2 |
|---|
| 44 | COMMENT_BLOCKED_SPAM = 3 |
|---|
| 45 | COMMENT_BLOCKED_SYSTEM = 4 |
|---|
| 46 | COMMENT_DELETED = 5 |
|---|
| 47 | |
|---|
| 48 | #: moderation modes |
|---|
| 49 | MODERATE_NONE = 0 |
|---|
| 50 | MODERATE_ALL = 1 |
|---|
| 51 | MODERATE_UNKNOWN = 2 |
|---|
| 52 | |
|---|
| 53 | |
|---|
| 54 | class _ZEMLContainer(object): |
|---|
| 55 | """A mixin for objects that have ZEML markup stored.""" |
|---|
| 56 | |
|---|
| 57 | parser_reason = None |
|---|
| 58 | |
|---|
| 59 | @property |
|---|
| 60 | def parser_missing(self): |
|---|
| 61 | """If the parser for this post is not available this property will |
|---|
| 62 | be `True`. If such as post is edited the text area is grayed out |
|---|
| 63 | and tells the user to reinstall the plugin that provides that |
|---|
| 64 | parser. Because it doesn't know the name of the plugin, the |
|---|
| 65 | preferred was is telling it the parser which is available using |
|---|
| 66 | the `parser` property. |
|---|
| 67 | """ |
|---|
| 68 | app = get_application() |
|---|
| 69 | return self.parser not in app.parsers |
|---|
| 70 | |
|---|
| 71 | def touch_parser_data(self): |
|---|
| 72 | """Mark the parser data as modified.""" |
|---|
| 73 | # this is enough for sqlalchemy to pick it up as as change. |
|---|
| 74 | # it will only compare the object's identity. |
|---|
| 75 | self.parser_data = dict(self.parser_data) |
|---|
| 76 | |
|---|
| 77 | def _get_parser(self): |
|---|
| 78 | if self.parser_data is not None: |
|---|
| 79 | return self.parser_data.get('parser') |
|---|
| 80 | |
|---|
| 81 | def _set_parser(self, value): |
|---|
| 82 | if self.parser_data is None: |
|---|
| 83 | self.parser_data = {} |
|---|
| 84 | self.parser_data['parser'] = value |
|---|
| 85 | self.touch_parser_data() |
|---|
| 86 | |
|---|
| 87 | parser = property(_get_parser, _set_parser, doc="The name of the parser.") |
|---|
| 88 | del _get_parser, _set_parser |
|---|
| 89 | |
|---|
| 90 | @property |
|---|
| 91 | def body(self): |
|---|
| 92 | """The body as ZEML element.""" |
|---|
| 93 | if self.parser_data is not None: |
|---|
| 94 | return self.parser_data.get('body') |
|---|
| 95 | |
|---|
| 96 | def _parse_text(self, text): |
|---|
| 97 | from zine.parsers import parse |
|---|
| 98 | self.parser_data['body'] = parse(text, self.parser, self.parser_reason) |
|---|
| 99 | |
|---|
| 100 | def _get_text(self): |
|---|
| 101 | return self._text |
|---|
| 102 | |
|---|
| 103 | def _set_text(self, value): |
|---|
| 104 | if self.parser_data is None: |
|---|
| 105 | self.parser_data = {} |
|---|
| 106 | self._text = value |
|---|
| 107 | self._parse_text(value) |
|---|
| 108 | self.touch_parser_data() |
|---|
| 109 | |
|---|
| 110 | text = property(_get_text, _set_text, doc="The raw text.") |
|---|
| 111 | del _get_text, _set_text |
|---|
| 112 | |
|---|
| 113 | def find_urls(self): |
|---|
| 114 | """Iterate over all urls in the text. This will only work if the |
|---|
| 115 | parser for this post is available, otherwise an exception is raised. |
|---|
| 116 | The URLs returned are absolute URLs. |
|---|
| 117 | """ |
|---|
| 118 | from zine.parsers import parse |
|---|
| 119 | if self.parser_missing: |
|---|
| 120 | raise TypeError('parser is missing, urls cannot be looked up.') |
|---|
| 121 | found = set() |
|---|
| 122 | this_url = url_for(self, _external=True) |
|---|
| 123 | tree = parse(self.text, self.parser, 'linksearch') |
|---|
| 124 | for node in tree.query('a[href]'): |
|---|
| 125 | href = urljoin(this_url, node.attributes['href']) |
|---|
| 126 | if href not in found: |
|---|
| 127 | found.add(href) |
|---|
| 128 | yield href |
|---|
| 129 | |
|---|
| 130 | |
|---|
| 131 | class _ZEMLDualContainer(_ZEMLContainer): |
|---|
| 132 | """Like the ZEML mixin but with intro and body sections.""" |
|---|
| 133 | |
|---|
| 134 | def _parse_text(self, text): |
|---|
| 135 | from zine.parsers import parse |
|---|
| 136 | self.parser_data['intro'], self.parser_data['body'] = \ |
|---|
| 137 | zeml.split_intro(parse(text, self.parser, self.parser_reason)) |
|---|
| 138 | |
|---|
| 139 | @property |
|---|
| 140 | def intro(self): |
|---|
| 141 | """The intro as zeml element.""" |
|---|
| 142 | if self.parser_data is not None: |
|---|
| 143 | return self.parser_data.get('intro') |
|---|
| 144 | |
|---|
| 145 | |
|---|
| 146 | class CommentCounterExtension(db.AttributeExtension): |
|---|
| 147 | """A simple attribute extension that helps the post to reflect |
|---|
| 148 | the number of public comments on the post table. |
|---|
| 149 | |
|---|
| 150 | This will not count or "uncount" blocked or unmoderated comments. |
|---|
| 151 | To fight this problem there is a :meth:`Post.sync_comment_count` |
|---|
| 152 | method that should be called after comment moderation on affected |
|---|
| 153 | comments. |
|---|
| 154 | """ |
|---|
| 155 | |
|---|
| 156 | def append(self, state, value, initiator): |
|---|
| 157 | instance = state.obj() |
|---|
| 158 | if not value.blocked: |
|---|
| 159 | instance._comment_count += 1 |
|---|
| 160 | return value |
|---|
| 161 | |
|---|
| 162 | def remove(self, state, value, initiator): |
|---|
| 163 | instance = state.obj() |
|---|
| 164 | if not value.blocked: |
|---|
| 165 | instance._comment_count -= 1 |
|---|
| 166 | |
|---|
| 167 | def set(self, state, value, oldvalue, initiator): |
|---|
| 168 | return value |
|---|
| 169 | |
|---|
| 170 | |
|---|
| 171 | class UserQuery(db.Query): |
|---|
| 172 | """Add some extra query methods to the user object.""" |
|---|
| 173 | |
|---|
| 174 | def get_nobody(self): |
|---|
| 175 | return AnonymousUser() |
|---|
| 176 | |
|---|
| 177 | def authors(self): |
|---|
| 178 | return self.filter_by(is_author=True) |
|---|
| 179 | |
|---|
| 180 | |
|---|
| 181 | class SchemaVersion(object): |
|---|
| 182 | """Represents a database schema version.""" |
|---|
| 183 | |
|---|
| 184 | query = db.query_property(db.Query) |
|---|
| 185 | |
|---|
| 186 | def __init__(self, repos, version=0): |
|---|
| 187 | self.repository_id = repos.config.get('repository_id') |
|---|
| 188 | self.repository_path = repos.path |
|---|
| 189 | self.version = version |
|---|
| 190 | |
|---|
| 191 | |
|---|
| 192 | class User(object): |
|---|
| 193 | """Represents an user. |
|---|
| 194 | |
|---|
| 195 | If you change something on this model, even default values, keep in mind |
|---|
| 196 | that the websetup does not use this model to create the admin account |
|---|
| 197 | because at that time the Zine system is not yet ready. Also update |
|---|
| 198 | the code in `zine.websetup.WebSetup.start_setup`. |
|---|
| 199 | """ |
|---|
| 200 | |
|---|
| 201 | query = db.query_property(UserQuery) |
|---|
| 202 | is_somebody = True |
|---|
| 203 | |
|---|
| 204 | def __init__(self, username, password, email, real_name=u'', |
|---|
| 205 | description=u'', www=u'', is_author=False): |
|---|
| 206 | self.username = username |
|---|
| 207 | if password is not None: |
|---|
| 208 | self.set_password(password) |
|---|
| 209 | else: |
|---|
| 210 | self.disable() |
|---|
| 211 | self.email = email |
|---|
| 212 | self.www = www |
|---|
| 213 | self.real_name = real_name |
|---|
| 214 | self.description = description |
|---|
| 215 | self.extra = {} |
|---|
| 216 | self.display_name = u'$username' |
|---|
| 217 | self.is_author = is_author |
|---|
| 218 | |
|---|
| 219 | @property |
|---|
| 220 | def is_manager(self): |
|---|
| 221 | return self.has_privilege(ENTER_ADMIN_PANEL) |
|---|
| 222 | |
|---|
| 223 | @property |
|---|
| 224 | def is_admin(self): |
|---|
| 225 | return self.has_privilege(BLOG_ADMIN) |
|---|
| 226 | |
|---|
| 227 | def _set_display_name(self, value): |
|---|
| 228 | self._display_name = value |
|---|
| 229 | |
|---|
| 230 | def _get_display_name(self): |
|---|
| 231 | from string import Template |
|---|
| 232 | return Template(self._display_name).safe_substitute( |
|---|
| 233 | username=self.username, |
|---|
| 234 | real_name=self.real_name |
|---|
| 235 | ) |
|---|
| 236 | |
|---|
| 237 | display_name = property(_get_display_name, _set_display_name) |
|---|
| 238 | own_privileges = privilege_attribute('_own_privileges') |
|---|
| 239 | |
|---|
| 240 | @property |
|---|
| 241 | def privileges(self): |
|---|
| 242 | """A read-only set with all privileges.""" |
|---|
| 243 | result = set(self.own_privileges) |
|---|
| 244 | for group in self.groups: |
|---|
| 245 | result.update(group.privileges) |
|---|
| 246 | return frozenset(result) |
|---|
| 247 | |
|---|
| 248 | def has_privilege(self, privilege): |
|---|
| 249 | """Check if the user has a given privilege. If the user has the |
|---|
| 250 | BLOG_ADMIN privilege he automatically has all the other privileges |
|---|
| 251 | as well. |
|---|
| 252 | """ |
|---|
| 253 | return add_admin_privilege(privilege)(self.privileges) |
|---|
| 254 | |
|---|
| 255 | def set_password(self, password): |
|---|
| 256 | self.pw_hash = gen_pwhash(password) |
|---|
| 257 | |
|---|
| 258 | def check_password(self, password): |
|---|
| 259 | if self.pw_hash == '!': |
|---|
| 260 | return False |
|---|
| 261 | return check_pwhash(self.pw_hash, password) |
|---|
| 262 | |
|---|
| 263 | def disable(self): |
|---|
| 264 | self.pw_hash = '!' |
|---|
| 265 | |
|---|
| 266 | @property |
|---|
| 267 | def disabled(self): |
|---|
| 268 | return self.pw_hash == '!' |
|---|
| 269 | |
|---|
| 270 | def get_url_values(self): |
|---|
| 271 | if self.is_author: |
|---|
| 272 | return 'blog/show_author', { |
|---|
| 273 | 'username': self.username |
|---|
| 274 | } |
|---|
| 275 | return self.www or '#' |
|---|
| 276 | |
|---|
| 277 | def __repr__(self): |
|---|
| 278 | return '<%s %r>' % ( |
|---|
| 279 | self.__class__.__name__, |
|---|
| 280 | self.username |
|---|
| 281 | ) |
|---|
| 282 | |
|---|
| 283 | |
|---|
| 284 | class Group(object): |
|---|
| 285 | """Wraps the group table.""" |
|---|
| 286 | |
|---|
| 287 | def __init__(self, name): |
|---|
| 288 | self.name = name |
|---|
| 289 | |
|---|
| 290 | privileges = privilege_attribute('_privileges') |
|---|
| 291 | |
|---|
| 292 | def has_privilege(self, privilege): |
|---|
| 293 | return add_admin_privilege(privilege)(self.privileges) |
|---|
| 294 | |
|---|
| 295 | def get_url_values(self): |
|---|
| 296 | # TODO: a public view is missing! |
|---|
| 297 | return 'admin/edit_group', {'group_id': self.id} |
|---|
| 298 | |
|---|
| 299 | def __repr__(self): |
|---|
| 300 | return '<%s %r>' % ( |
|---|
| 301 | self.__class__.__name__, |
|---|
| 302 | self.name |
|---|
| 303 | ) |
|---|
| 304 | |
|---|
| 305 | |
|---|
| 306 | class AnonymousUser(User): |
|---|
| 307 | """Fake model for anonymous users.""" |
|---|
| 308 | id = -1 |
|---|
| 309 | is_somebody = is_author = False |
|---|
| 310 | display_name = 'Nobody' |
|---|
| 311 | real_name = description = username = '' |
|---|
| 312 | own_privileges = privileges = property(lambda x: frozenset()) |
|---|
| 313 | |
|---|
| 314 | def __init__(self): |
|---|
| 315 | pass |
|---|
| 316 | |
|---|
| 317 | def __nonzero__(self): |
|---|
| 318 | return False |
|---|
| 319 | |
|---|
| 320 | def check_password(self, password): |
|---|
| 321 | return False |
|---|
| 322 | |
|---|
| 323 | |
|---|
| 324 | class _PostQueryBase(db.Query): |
|---|
| 325 | """Add some extra methods to the post model.""" |
|---|
| 326 | |
|---|
| 327 | def type(self, content_type): |
|---|
| 328 | """Filter all posts by a given type.""" |
|---|
| 329 | return self.filter_by(content_type=content_type) |
|---|
| 330 | |
|---|
| 331 | def for_index(self): |
|---|
| 332 | """Return all the types for the index.""" |
|---|
| 333 | types = get_application().cfg['index_content_types'] |
|---|
| 334 | if len(types) == 1: |
|---|
| 335 | return self.filter_by(content_type=types[0]) |
|---|
| 336 | return self.filter(Post.content_type.in_(types)) |
|---|
| 337 | |
|---|
| 338 | def published(self, ignore_privileges=False, user=None): |
|---|
| 339 | """Return a queryset for only published posts.""" |
|---|
| 340 | if not user: |
|---|
| 341 | req = get_request() |
|---|
| 342 | user = req and req.user |
|---|
| 343 | |
|---|
| 344 | if ignore_privileges or not user: |
|---|
| 345 | # Anonymous. Return only public entries. |
|---|
| 346 | return self.filter( |
|---|
| 347 | (Post.status == STATUS_PUBLISHED) & |
|---|
| 348 | (Post.pub_date <= datetime.utcnow()) |
|---|
| 349 | ) |
|---|
| 350 | elif not user.has_privilege(VIEW_PROTECTED): |
|---|
| 351 | # Authenticated user without protected viewing privilege |
|---|
| 352 | # Return public and their own private entries |
|---|
| 353 | return self.filter( |
|---|
| 354 | ((Post.status == STATUS_PUBLISHED) | |
|---|
| 355 | ((Post.status == STATUS_PRIVATE) & |
|---|
| 356 | (Post.author_id == user.id))) & |
|---|
| 357 | (Post.pub_date <= datetime.utcnow()) |
|---|
| 358 | ) |
|---|
| 359 | else: |
|---|
| 360 | # Authenticated and can view protected. |
|---|
| 361 | # Return public, protected and their own private |
|---|
| 362 | return self.filter( |
|---|
| 363 | ((Post.status == STATUS_PUBLISHED) | |
|---|
| 364 | (Post.status == STATUS_PROTECTED) | |
|---|
| 365 | ((Post.status == STATUS_PRIVATE) & |
|---|
| 366 | (Post.author_id == user.id))) & |
|---|
| 367 | (Post.pub_date <= datetime.utcnow()) |
|---|
| 368 | ) |
|---|
| 369 | |
|---|
| 370 | def drafts(self, ignore_user=False, user=None): |
|---|
| 371 | """Return a query that returns all drafts for the current user. |
|---|
| 372 | or the user provided or no user at all if `ignore_user` is set. |
|---|
| 373 | """ |
|---|
| 374 | if user is None and not ignore_user: |
|---|
| 375 | req = get_request() |
|---|
| 376 | if req and req.user: |
|---|
| 377 | user = req.user |
|---|
| 378 | query = self.filter(Post.status == STATUS_DRAFT) |
|---|
| 379 | if user is not None: |
|---|
| 380 | query = query.filter(Post.author_id == user.id) |
|---|
| 381 | return query |
|---|
| 382 | |
|---|
| 383 | def get_list(self, endpoint=None, page=1, per_page=None, |
|---|
| 384 | url_args=None, raise_if_empty=True): |
|---|
| 385 | """Return a dict with pagination, the current posts, number of pages, |
|---|
| 386 | total posts and all that stuff for further processing. |
|---|
| 387 | """ |
|---|
| 388 | if per_page is None: |
|---|
| 389 | app = get_application() |
|---|
| 390 | per_page = app.cfg['posts_per_page'] |
|---|
| 391 | |
|---|
| 392 | # send the query |
|---|
| 393 | offset = per_page * (page - 1) |
|---|
| 394 | postlist = self.order_by(Post.pub_date.desc()) \ |
|---|
| 395 | .offset(offset).limit(per_page).all() |
|---|
| 396 | |
|---|
| 397 | # if raising exceptions is wanted, raise it |
|---|
| 398 | if raise_if_empty and (page != 1 and not postlist): |
|---|
| 399 | raise NotFound() |
|---|
| 400 | |
|---|
| 401 | pagination = Pagination(endpoint, page, per_page, |
|---|
| 402 | self.count(), url_args) |
|---|
| 403 | |
|---|
| 404 | return { |
|---|
| 405 | 'pagination': pagination, |
|---|
| 406 | 'posts': postlist |
|---|
| 407 | } |
|---|
| 408 | |
|---|
| 409 | def get_archive_summary(self, detail='months', limit=None, |
|---|
| 410 | ignore_privileges=False): |
|---|
| 411 | """Query function to get the archive of the blog. Usually used |
|---|
| 412 | directly from the templates to add some links to the sidebar. |
|---|
| 413 | """ |
|---|
| 414 | # XXX: currently we also return months without articles in it. |
|---|
| 415 | # other blog systems do not, but because we use sqlalchemy we have |
|---|
| 416 | # to go with the functionality provided. Currently there is no way |
|---|
| 417 | # to do date truncating in a database agnostic way. When this is done |
|---|
| 418 | # ignore_privileges should no longer be a noop |
|---|
| 419 | last = self.filter(Post.pub_date != None) \ |
|---|
| 420 | .order_by(Post.pub_date.asc()).first() |
|---|
| 421 | now = datetime.utcnow() |
|---|
| 422 | |
|---|
| 423 | there_are_more = False |
|---|
| 424 | result = [] |
|---|
| 425 | |
|---|
| 426 | if last is not None: |
|---|
| 427 | now = date(now.year, now.month, now.day) |
|---|
| 428 | oldest = date(last.pub_date.year, last.pub_date.month, |
|---|
| 429 | last.pub_date.day) |
|---|
| 430 | result = [now] |
|---|
| 431 | |
|---|
| 432 | there_are_more = False |
|---|
| 433 | if detail == 'years': |
|---|
| 434 | now, oldest = [x.replace(month=1, day=1) for x in now, oldest] |
|---|
| 435 | while True: |
|---|
| 436 | now = now.replace(year=now.year - 1) |
|---|
| 437 | if now < oldest: |
|---|
| 438 | break |
|---|
| 439 | result.append(now) |
|---|
| 440 | else: |
|---|
| 441 | there_are_more = True |
|---|
| 442 | elif detail == 'months': |
|---|
| 443 | now, oldest = [x.replace(day=1) for x in now, oldest] |
|---|
| 444 | while limit is None or len(result) < limit: |
|---|
| 445 | if not now.month - 1: |
|---|
| 446 | now = now.replace(year=now.year - 1, month=12) |
|---|
| 447 | else: |
|---|
| 448 | now = now.replace(month=now.month - 1) |
|---|
| 449 | if now < oldest: |
|---|
| 450 | break |
|---|
| 451 | result.append(now) |
|---|
| 452 | else: |
|---|
| 453 | there_are_more = True |
|---|
| 454 | elif detail == 'days': |
|---|
| 455 | while limit is None or len(result) < limit: |
|---|
| 456 | now = now - timedelta(days=1) |
|---|
| 457 | if now < oldest: |
|---|
| 458 | break |
|---|
| 459 | result.append(now) |
|---|
| 460 | else: |
|---|
| 461 | there_are_more = True |
|---|
| 462 | else: |
|---|
| 463 | raise ValueError('detail must be years, months, or days') |
|---|
| 464 | |
|---|
| 465 | return { |
|---|
| 466 | detail: result, |
|---|
| 467 | 'more': there_are_more, |
|---|
| 468 | 'empty': not result |
|---|
| 469 | } |
|---|
| 470 | |
|---|
| 471 | def latest(self, ignore_privileges=False): |
|---|
| 472 | """Filter for the latest n posts.""" |
|---|
| 473 | return self.published(ignore_privileges=ignore_privileges) |
|---|
| 474 | |
|---|
| 475 | def date_filter(self, year, month=None, day=None): |
|---|
| 476 | """Filter all the items that match the given date.""" |
|---|
| 477 | if month is None: |
|---|
| 478 | return self.filter( |
|---|
| 479 | (Post.pub_date >= datetime(year, 1, 1)) & |
|---|
| 480 | (Post.pub_date < datetime(year + 1, 1, 1)) |
|---|
| 481 | ) |
|---|
| 482 | elif day is None: |
|---|
| 483 | return self.filter( |
|---|
| 484 | (Post.pub_date >= datetime(year, month, 1)) & |
|---|
| 485 | (Post.pub_date < (month == 12 and |
|---|
| 486 | datetime(year + 1, 1, 1) or |
|---|
| 487 | datetime(year, month + 1, 1))) |
|---|
| 488 | ) |
|---|
| 489 | return self.filter( |
|---|
| 490 | (Post.pub_date >= datetime(year, month, day)) & |
|---|
| 491 | (Post.pub_date < datetime(year, month, day) + |
|---|
| 492 | timedelta(days=1)) |
|---|
| 493 | ) |
|---|
| 494 | |
|---|
| 495 | def search(self, query): |
|---|
| 496 | """Search for posts by a query.""" |
|---|
| 497 | # XXX: use a sophisticated search |
|---|
| 498 | q = self |
|---|
| 499 | for word in query.split(): |
|---|
| 500 | q = q.filter( |
|---|
| 501 | posts.c.body.like('%%%s%%' % word) | |
|---|
| 502 | posts.c.intro.like('%%%s%%' % word) | |
|---|
| 503 | posts.c.title.like('%%%s%%' % word) |
|---|
| 504 | ) |
|---|
| 505 | return q.all() |
|---|
| 506 | |
|---|
| 507 | |
|---|
| 508 | class _PostBase(object): |
|---|
| 509 | """Represents one blog post.""" |
|---|
| 510 | |
|---|
| 511 | @property |
|---|
| 512 | def _privileges(self): |
|---|
| 513 | return get_application().content_type_privileges[self.content_type] |
|---|
| 514 | |
|---|
| 515 | @property |
|---|
| 516 | def EDIT_OWN_PRIVILEGE(self): |
|---|
| 517 | """The edit-own privilege for this content type.""" |
|---|
| 518 | return self._privileges[1] |
|---|
| 519 | |
|---|
| 520 | @property |
|---|
| 521 | def EDIT_OTHER_PRIVILEGE(self): |
|---|
| 522 | """The edit-other privilege for this content type.""" |
|---|
| 523 | return self._privileges[2] |
|---|
| 524 | |
|---|
| 525 | @property |
|---|
| 526 | def root_comments(self): |
|---|
| 527 | """Return only the comments for this post that don't have a parent.""" |
|---|
| 528 | return [x for x in self.comments if x.parent is None] |
|---|
| 529 | |
|---|
| 530 | @property |
|---|
| 531 | def visible_comments(self): |
|---|
| 532 | """Return only the comments for this post that are visible to |
|---|
| 533 | the user. |
|---|
| 534 | """ |
|---|
| 535 | return [x for x in self.comments if x.visible] |
|---|
| 536 | |
|---|
| 537 | @property |
|---|
| 538 | def visible_root_comments(self): |
|---|
| 539 | """Return only the comments for this post that are visible to |
|---|
| 540 | the user and that don't have a parent. |
|---|
| 541 | """ |
|---|
| 542 | return [x for x in self.comments if x.visible and x.parent is None] |
|---|
| 543 | |
|---|
| 544 | @property |
|---|
| 545 | def comment_count(self): |
|---|
| 546 | """The number of visible comments.""" |
|---|
| 547 | req = get_request() |
|---|
| 548 | |
|---|
| 549 | # if the model was loaded with .lightweight() there are no comments |
|---|
| 550 | # but a _comment_count we can use. |
|---|
| 551 | if not db.attribute_loaded(self, 'comments'): |
|---|
| 552 | return self._comment_count |
|---|
| 553 | |
|---|
| 554 | # otherwise the comments are already available and we can savely |
|---|
| 555 | # filter it. |
|---|
| 556 | if req and req.user.is_manager: |
|---|
| 557 | return len(self.comments) |
|---|
| 558 | return len([x for x in self.comments if not x.blocked]) |
|---|
| 559 | |
|---|
| 560 | @property |
|---|
| 561 | def comment_feed_url(self): |
|---|
| 562 | """The link to the comment feed.""" |
|---|
| 563 | return make_external_url(self.slug.rstrip('/') + '/feed.atom') |
|---|
| 564 | |
|---|
| 565 | @property |
|---|
| 566 | def is_draft(self): |
|---|
| 567 | """True if this post is unpublished.""" |
|---|
| 568 | return self.status == STATUS_DRAFT |
|---|
| 569 | |
|---|
| 570 | def sync_comment_count(self): |
|---|
| 571 | """Sync the reflected comment count.""" |
|---|
| 572 | self._comment_count = Comment.query.comments_for_post(self) \ |
|---|
| 573 | .filter(Comment.status==0).count() |
|---|
| 574 | |
|---|
| 575 | def set_auto_slug(self): |
|---|
| 576 | """Generate a slug for this post.""" |
|---|
| 577 | #cfg = get_application().cfg |
|---|
| 578 | slug = gen_slug(self.title) |
|---|
| 579 | if not slug: |
|---|
| 580 | slug = to_blog_timezone(self.pub_date).strftime('%H%M') |
|---|
| 581 | |
|---|
| 582 | full_slug = gen_timestamped_slug(slug, self.content_type, self.pub_date) |
|---|
| 583 | |
|---|
| 584 | if full_slug != self.slug: |
|---|
| 585 | while Post.query.autoflush(False).filter_by(slug=full_slug) \ |
|---|
| 586 | .limit(1).count(): |
|---|
| 587 | full_slug = increment_string(full_slug) |
|---|
| 588 | self.slug = full_slug |
|---|
| 589 | |
|---|
| 590 | def touch_times(self, pub_date=None): |
|---|
| 591 | """Touches the times for this post. If the pub_date is given the |
|---|
| 592 | `pub_date` is changed to the given date. If it's not given the |
|---|
| 593 | current time is assumed if the post status is set to published, |
|---|
| 594 | otherwise it's set to `None`. |
|---|
| 595 | |
|---|
| 596 | Additionally the `last_update` is always set to now. |
|---|
| 597 | """ |
|---|
| 598 | now = datetime.utcnow() |
|---|
| 599 | if pub_date is None and self.status == STATUS_PUBLISHED: |
|---|
| 600 | pub_date = now |
|---|
| 601 | self.pub_date = pub_date |
|---|
| 602 | self.last_update = now |
|---|
| 603 | |
|---|
| 604 | def bind_slug(self, slug=None): |
|---|
| 605 | """Binds a new slug to the post. If the slug is `None`/empty a new |
|---|
| 606 | automatically generated slug is created. Otherwise that slug is |
|---|
| 607 | used and assigned. |
|---|
| 608 | """ |
|---|
| 609 | if not slug: |
|---|
| 610 | self.set_auto_slug() |
|---|
| 611 | else: |
|---|
| 612 | self.slug = slug |
|---|
| 613 | |
|---|
| 614 | def bind_tags(self, tags): |
|---|
| 615 | """Rebinds the tags to a list of tags (strings, not tag objects).""" |
|---|
| 616 | current_map = dict((x.name, x) for x in self.tags) |
|---|
| 617 | currently_attached = set(x.name for x in self.tags) |
|---|
| 618 | new_tags = set(tags) |
|---|
| 619 | |
|---|
| 620 | # delete outdated tags |
|---|
| 621 | for name in currently_attached.difference(new_tags): |
|---|
| 622 | self.tags.remove(current_map[name]) |
|---|
| 623 | |
|---|
| 624 | # add new tags |
|---|
| 625 | for name in new_tags.difference(currently_attached): |
|---|
| 626 | self.tags.append(Tag.get_or_create(name)) |
|---|
| 627 | |
|---|
| 628 | def bind_categories(self, categories): |
|---|
| 629 | """Rebinds the categories to the list passed. The list of objects |
|---|
| 630 | must be a list of category objects. |
|---|
| 631 | """ |
|---|
| 632 | currently_attached = set(self.categories) |
|---|
| 633 | new_categories = set(categories) |
|---|
| 634 | |
|---|
| 635 | # delete outdated categories |
|---|
| 636 | for category in currently_attached.difference(new_categories): |
|---|
| 637 | self.categories.remove(category) |
|---|
| 638 | |
|---|
| 639 | # attach new categories |
|---|
| 640 | for category in new_categories.difference(currently_attached): |
|---|
| 641 | self.categories.append(category) |
|---|
| 642 | |
|---|
| 643 | def can_edit(self, user=None): |
|---|
| 644 | """Checks if the given user (or current user) can edit this post.""" |
|---|
| 645 | if user is None: |
|---|
| 646 | user = get_request().user |
|---|
| 647 | |
|---|
| 648 | return ( |
|---|
| 649 | user.has_privilege(self.EDIT_OTHER_PRIVILEGE) or |
|---|
| 650 | (self.author == user and |
|---|
| 651 | user.has_privilege(self.EDIT_OWN_PRIVILEGE)) |
|---|
| 652 | ) |
|---|
| 653 | |
|---|
| 654 | def can_read(self, user=None): |
|---|
| 655 | """Check if the current user or the user provided can read-access |
|---|
| 656 | this post. If there is no user there must be a request object |
|---|
| 657 | for this thread defined. |
|---|
| 658 | """ |
|---|
| 659 | # published posts are always accessible |
|---|
| 660 | if self.status == STATUS_PUBLISHED and self.pub_date is not None and \ |
|---|
| 661 | self.pub_date <= datetime.utcnow(): |
|---|
| 662 | return True |
|---|
| 663 | |
|---|
| 664 | if user is None: |
|---|
| 665 | user = get_request().user |
|---|
| 666 | |
|---|
| 667 | # users that are allowed to look at drafts may pass |
|---|
| 668 | if user.has_privilege(VIEW_DRAFTS): |
|---|
| 669 | return True |
|---|
| 670 | |
|---|
| 671 | # if this is protected and user can view protected, allow them |
|---|
| 672 | if self.status == STATUS_PROTECTED and self.pub_date is not None and \ |
|---|
| 673 | self.pub_date <= datetime.utcnow() and \ |
|---|
| 674 | user.has_privilege(VIEW_PROTECTED): |
|---|
| 675 | return True |
|---|
| 676 | |
|---|
| 677 | # if we have the privilege to edit other entries or if we are |
|---|
| 678 | # a blog administrator we can always look at posts. |
|---|
| 679 | if user.has_privilege(self.EDIT_OTHER_PRIVILEGE): |
|---|
| 680 | return True |
|---|
| 681 | |
|---|
| 682 | # otherwise if the user has the EDIT_OWN_PRIVILEGE and the |
|---|
| 683 | # author of the post, he may look at it as well |
|---|
| 684 | if user.id == self.author_id and \ |
|---|
| 685 | user.has_privilege(self.EDIT_OWN_PRIVILEGE): |
|---|
| 686 | return True |
|---|
| 687 | |
|---|
| 688 | return False |
|---|
| 689 | |
|---|
| 690 | @property |
|---|
| 691 | def is_published(self): |
|---|
| 692 | """`True` if the post is visible for everyone.""" |
|---|
| 693 | return self.can_read(AnonymousUser()) |
|---|
| 694 | |
|---|
| 695 | @property |
|---|
| 696 | def is_private(self): |
|---|
| 697 | """`True` if the post is marked private.""" |
|---|
| 698 | return self.status == STATUS_PRIVATE |
|---|
| 699 | |
|---|
| 700 | @property |
|---|
| 701 | def is_protected(self): |
|---|
| 702 | """`True` if the post is marked protected.""" |
|---|
| 703 | return self.status == STATUS_PROTECTED |
|---|
| 704 | |
|---|
| 705 | @property |
|---|
| 706 | def is_scheduled(self): |
|---|
| 707 | """True if the item is scheduled for appearing.""" |
|---|
| 708 | return self.status == STATUS_PUBLISHED and \ |
|---|
| 709 | self.pub_date > datetime.utcnow() |
|---|
| 710 | |
|---|
| 711 | def get_url_values(self): |
|---|
| 712 | return self.slug |
|---|
| 713 | |
|---|
| 714 | def __repr__(self): |
|---|
| 715 | return '<%s %r>' % ( |
|---|
| 716 | self.__class__.__name__, |
|---|
| 717 | self.title |
|---|
| 718 | ) |
|---|
| 719 | |
|---|
| 720 | |
|---|
| 721 | class PostQuery(_PostQueryBase): |
|---|
| 722 | """Add some extra methods to the post model.""" |
|---|
| 723 | |
|---|
| 724 | def theme_lightweight(self, key): |
|---|
| 725 | """A query for lightweight settings based on the theme. For example |
|---|
| 726 | to use the lightweight settings for the author overview page you can |
|---|
| 727 | use this query:: |
|---|
| 728 | |
|---|
| 729 | Post.query.theme_lightweight('author_overview') |
|---|
| 730 | """ |
|---|
| 731 | theme_settings = get_application().theme.settings |
|---|
| 732 | deferred = theme_settings.get('sql.%s.deferred' % key) |
|---|
| 733 | lazy = theme_settings.get('sql.%s.lazy' % key) |
|---|
| 734 | return self.lightweight(deferred, lazy) |
|---|
| 735 | |
|---|
| 736 | |
|---|
| 737 | class SummarizedPostQuery(_PostQueryBase): |
|---|
| 738 | """Add some extra methods to the summarized post model.""" |
|---|
| 739 | |
|---|
| 740 | |
|---|
| 741 | class Post(_PostBase, _ZEMLDualContainer): |
|---|
| 742 | """A full blown post.""" |
|---|
| 743 | |
|---|
| 744 | query = db.query_property(PostQuery) |
|---|
| 745 | parser_reason = 'post' |
|---|
| 746 | |
|---|
| 747 | def __init__(self, title, author, text, slug=None, pub_date=None, |
|---|
| 748 | last_update=None, comments_enabled=True, |
|---|
| 749 | pings_enabled=True, status=STATUS_PUBLISHED, |
|---|
| 750 | parser=None, uid=None, content_type='entry', extra=None): |
|---|
| 751 | app = get_application() |
|---|
| 752 | self.content_type = content_type |
|---|
| 753 | self.title = title |
|---|
| 754 | self.author = author |
|---|
| 755 | if parser is None: |
|---|
| 756 | parser = app.cfg['default_parser'] |
|---|
| 757 | |
|---|
| 758 | self.parser = parser |
|---|
| 759 | self.text = text or u'' |
|---|
| 760 | if extra: |
|---|
| 761 | self.extra = dict(extra) |
|---|
| 762 | else: |
|---|
| 763 | self.extra = {} |
|---|
| 764 | |
|---|
| 765 | self.comments_enabled = comments_enabled |
|---|
| 766 | self.pings_enabled = pings_enabled |
|---|
| 767 | self.status = status |
|---|
| 768 | |
|---|
| 769 | # set times now, they depend on status being set |
|---|
| 770 | self.touch_times(pub_date) |
|---|
| 771 | if last_update is not None: |
|---|
| 772 | self.last_update = last_update |
|---|
| 773 | |
|---|
| 774 | # now bind the slug for which we need the times set. |
|---|
| 775 | self.bind_slug(slug) |
|---|
| 776 | |
|---|
| 777 | # generate a UID if none is given |
|---|
| 778 | if uid is None: |
|---|
| 779 | uid = build_tag_uri(app, self.pub_date, content_type, self.slug) |
|---|
| 780 | self.uid = uid |
|---|
| 781 | |
|---|
| 782 | @property |
|---|
| 783 | def comments_closed(self): |
|---|
| 784 | """True if commenting is no longer possible.""" |
|---|
| 785 | app = get_application() |
|---|
| 786 | open_for = app.cfg['comments_open_for'] |
|---|
| 787 | if open_for == 0: |
|---|
| 788 | return False |
|---|
| 789 | return self.pub_date + timedelta(days=open_for) < datetime.utcnow() |
|---|
| 790 | |
|---|
| 791 | |
|---|
| 792 | class SummarizedPost(_PostBase): |
|---|
| 793 | """Like a regular post but without text and parser data.""" |
|---|
| 794 | |
|---|
| 795 | query = db.query_property(SummarizedPostQuery) |
|---|
| 796 | |
|---|
| 797 | def __init__(self): |
|---|
| 798 | raise TypeError('You cannot create %r instance' % type(self).__name__) |
|---|
| 799 | |
|---|
| 800 | |
|---|
| 801 | class PostLink(object): |
|---|
| 802 | """Represents a link in a post. This can be used for podcasts or other |
|---|
| 803 | resources that require ``<link>`` categories. |
|---|
| 804 | """ |
|---|
| 805 | |
|---|
| 806 | def __init__(self, post, href, rel='alternate', type=None, hreflang=None, |
|---|
| 807 | title=None, length=None): |
|---|
| 808 | self.post = post |
|---|
| 809 | self.href = href |
|---|
| 810 | self.rel = rel |
|---|
| 811 | self.type = type |
|---|
| 812 | self.hreflang = hreflang |
|---|
| 813 | self.title = title |
|---|
| 814 | self.length = length |
|---|
| 815 | |
|---|
| 816 | def as_dict(self): |
|---|
| 817 | """Return the values as dict. Useful for feed building.""" |
|---|
| 818 | result = {'href': self.href} |
|---|
| 819 | for key in 'rel', 'type', 'hreflang', 'title', 'length': |
|---|
| 820 | value = getattr(self, key, None) |
|---|
| 821 | if value is not None: |
|---|
| 822 | result[key] = value |
|---|
| 823 | return result |
|---|
| 824 | |
|---|
| 825 | def __repr__(self): |
|---|
| 826 | return '<%s %r>' % ( |
|---|
| 827 | self.__class__.__name__, |
|---|
| 828 | self.href |
|---|
| 829 | ) |
|---|
| 830 | |
|---|
| 831 | |
|---|
| 832 | class CategoryQuery(db.Query): |
|---|
| 833 | """Also categories have their own manager.""" |
|---|
| 834 | |
|---|
| 835 | def get_or_create(self, slug, name=None): |
|---|
| 836 | """Get the category for this slug or create it if it does not exist.""" |
|---|
| 837 | category = self.filter_by(slug=slug).first() |
|---|
| 838 | if category is None: |
|---|
| 839 | if name is None: |
|---|
| 840 | name = slug |
|---|
| 841 | category = Category(name, slug=slug) |
|---|
| 842 | return category |
|---|
| 843 | |
|---|
| 844 | |
|---|
| 845 | class Category(object): |
|---|
| 846 | """Represents a category.""" |
|---|
| 847 | |
|---|
| 848 | query = db.query_property(CategoryQuery) |
|---|
| 849 | |
|---|
| 850 | def __init__(self, name, description='', slug=None): |
|---|
| 851 | self.name = name |
|---|
| 852 | if slug is None: |
|---|
| 853 | self.set_auto_slug() |
|---|
| 854 | else: |
|---|
| 855 | self.slug = slug |
|---|
| 856 | self.description = description |
|---|
| 857 | |
|---|
| 858 | def set_auto_slug(self): |
|---|
| 859 | """Generate a slug for this category.""" |
|---|
| 860 | full_slug = gen_slug(self.name) |
|---|
| 861 | if not full_slug: |
|---|
| 862 | # if slug generation failed we select the highest category |
|---|
| 863 | # id as base for slug generation. |
|---|
| 864 | category = Category.query.autoflush(False) \ |
|---|
| 865 | .order_by(Category.id.desc()).first() |
|---|
| 866 | full_slug = unicode(category and category.id or u'1') |
|---|
| 867 | if full_slug != self.slug: |
|---|
| 868 | while Category.query.autoflush(False) \ |
|---|
| 869 | .filter_by(slug=full_slug).limit(1).count(): |
|---|
| 870 | full_slug = increment_string(full_slug) |
|---|
| 871 | self.slug = full_slug |
|---|
| 872 | |
|---|
| 873 | def get_url_values(self): |
|---|
| 874 | return 'blog/show_category', { |
|---|
| 875 | 'slug': self.slug |
|---|
| 876 | } |
|---|
| 877 | |
|---|
| 878 | def __repr__(self): |
|---|
| 879 | return '<%s %r>' % ( |
|---|
| 880 | self.__class__.__name__, |
|---|
| 881 | self.name |
|---|
| 882 | ) |
|---|
| 883 | |
|---|
| 884 | |
|---|
| 885 | class CommentQuery(db.Query): |
|---|
| 886 | """The manager for comments""" |
|---|
| 887 | |
|---|
| 888 | def post_lightweight(self): |
|---|
| 889 | """Lightweight query that sets loading options for a light post |
|---|
| 890 | (not text etc.) |
|---|
| 891 | """ |
|---|
| 892 | return self.lightweight(deferred=('post.text', 'post.parser_data', |
|---|
| 893 | 'post.extra'), lazy=('user',)) |
|---|
| 894 | |
|---|
| 895 | def approved(self): |
|---|
| 896 | """Return only the approved comments.""" |
|---|
| 897 | return self.filter(Comment.status == COMMENT_MODERATED) |
|---|
| 898 | |
|---|
| 899 | def all_blocked(self): |
|---|
| 900 | """Return all blocked comments, by user, by spam checker or by system. |
|---|
| 901 | """ |
|---|
| 902 | return self.filter(Comment.status.in_([COMMENT_BLOCKED_USER, |
|---|
| 903 | COMMENT_BLOCKED_SPAM, |
|---|
| 904 | COMMENT_BLOCKED_SYSTEM])) |
|---|
| 905 | |
|---|
| 906 | def blocked(self): |
|---|
| 907 | """Filter all comments blocked by user(s) |
|---|
| 908 | """ |
|---|
| 909 | return self.filter(Comment.status == COMMENT_BLOCKED_USER) |
|---|
| 910 | |
|---|
| 911 | def unmoderated(self): |
|---|
| 912 | """Filter all the unmoderated comments and comments blocked by a user |
|---|
| 913 | or system. |
|---|
| 914 | """ |
|---|
| 915 | return self.filter(Comment.status == COMMENT_UNMODERATED) |
|---|
| 916 | |
|---|
| 917 | def spam(self): |
|---|
| 918 | """Filter all the spam comments.""" |
|---|
| 919 | return self.filter(Comment.status == COMMENT_BLOCKED_SPAM) |
|---|
| 920 | |
|---|
| 921 | def system(self): |
|---|
| 922 | """Filter all the spam comments.""" |
|---|
| 923 | return self.filter(Comment.status == COMMENT_BLOCKED_SYSTEM) |
|---|
| 924 | |
|---|
| 925 | def latest(self, limit=None, ignore_privileges=False, ignore_blocked=True): |
|---|
| 926 | """Filter the list of non blocked comments for anonymous users or |
|---|
| 927 | all comments for admin users. |
|---|
| 928 | """ |
|---|
| 929 | query = self |
|---|
| 930 | |
|---|
| 931 | # only the approved if blocked are ignored |
|---|
| 932 | if ignore_blocked: |
|---|
| 933 | query = query.approved() |
|---|
| 934 | |
|---|
| 935 | # otherwise if we don't ignore the privileges we only want |
|---|
| 936 | # the approved if the user does not have the MODERATE_COMMENTS |
|---|
| 937 | # privileges. |
|---|
| 938 | elif not ignore_privileges: |
|---|
| 939 | req = get_request() |
|---|
| 940 | if req: |
|---|
| 941 | user = req.user |
|---|
| 942 | if not user.has_privilege(MODERATE_COMMENTS | |
|---|
| 943 | MODERATE_OWN_ENTRIES | |
|---|
| 944 | MODERATE_OWN_PAGES): |
|---|
| 945 | query = query.approved() |
|---|
| 946 | |
|---|
| 947 | elif user.has_privilege(MODERATE_OWN_ENTRIES | |
|---|
| 948 | MODERATE_OWN_PAGES): |
|---|
| 949 | query = query.for_user(user) |
|---|
| 950 | |
|---|
| 951 | return query |
|---|
| 952 | |
|---|
| 953 | def for_user(self, user=None): |
|---|
| 954 | request = get_request() |
|---|
| 955 | user = user or request.user |
|---|
| 956 | if user.has_privilege(MODERATE_COMMENTS): |
|---|
| 957 | return self |
|---|
| 958 | elif user.has_privilege(MODERATE_OWN_ENTRIES | MODERATE_OWN_PAGES): |
|---|
| 959 | return self.filter(Comment.post_id.in_( |
|---|
| 960 | db.session.query(Post.id).filter(Post.author_id==user.id)) |
|---|
| 961 | ) |
|---|
| 962 | return self |
|---|
| 963 | |
|---|
| 964 | |
|---|
| 965 | def comments_for_post(self, post): |
|---|
| 966 | """Return all comments for the blog post.""" |
|---|
| 967 | return self.filter(Comment.post_id == post.id) |
|---|
| 968 | |
|---|
| 969 | |
|---|
| 970 | class Comment(_ZEMLContainer): |
|---|
| 971 | """Represent one comment.""" |
|---|
| 972 | |
|---|
| 973 | query = db.query_property(CommentQuery) |
|---|
| 974 | parser_reason = 'comment' |
|---|
| 975 | |
|---|
| 976 | def __init__(self, post, author, text, email=None, www=None, parent=None, |
|---|
| 977 | pub_date=None, submitter_ip='0.0.0.0', parser=None, |
|---|
| 978 | is_pingback=False, status=COMMENT_MODERATED): |
|---|
| 979 | self.post = post |
|---|
| 980 | if isinstance(author, basestring): |
|---|
| 981 | self.user = None |
|---|
| 982 | self._author = author |
|---|
| 983 | self._email = email |
|---|
| 984 | self._www = www |
|---|
| 985 | else: |
|---|
| 986 | assert email is www is None, \ |
|---|
| 987 | 'email and www can only be provided if the author is ' \ |
|---|
| 988 | 'an anonymous user' |
|---|
| 989 | self.user = author |
|---|
| 990 | |
|---|
| 991 | if parser is None: |
|---|
| 992 | parser = get_application().cfg['comment_parser'] |
|---|
| 993 | self.parser = parser |
|---|
| 994 | self.text = text or '' |
|---|
| 995 | self.parent = parent |
|---|
| 996 | if pub_date is None: |
|---|
| 997 | pub_date = datetime.utcnow() |
|---|
| 998 | self.pub_date = pub_date |
|---|
| 999 | self.blocked_msg = None |
|---|
| 1000 | self.submitter_ip = submitter_ip |
|---|
| 1001 | self.is_pingback = is_pingback |
|---|
| 1002 | self.status = status |
|---|
| 1003 | |
|---|
| 1004 | def _union_property(attribute, user_attribute=None): |
|---|
| 1005 | """An attribute that can exist on a user and the comment.""" |
|---|
| 1006 | user_attribute = user_attribute or attribute |
|---|
| 1007 | attribute = '_' + attribute |
|---|
| 1008 | def get(self): |
|---|
| 1009 | if self.user: |
|---|
| 1010 | return getattr(self.user, user_attribute) |
|---|
| 1011 | return getattr(self, attribute) |
|---|
| 1012 | def set(self, value): |
|---|
| 1013 | if self.user: |
|---|
| 1014 | raise TypeError('can\'t set this attribute if the comment ' |
|---|
| 1015 | 'does not belong to an anonymous user') |
|---|
| 1016 | setattr(self, attribute, value) |
|---|
| 1017 | return property(get, set) |
|---|
| 1018 | |
|---|
| 1019 | email = _union_property('email') |
|---|
| 1020 | www = _union_property('www') |
|---|
| 1021 | author = _union_property('author', 'display_name') |
|---|
| 1022 | del _union_property |
|---|
| 1023 | |
|---|
| 1024 | def unbind_user(self): |
|---|
| 1025 | """If a user is deleted, the cascading rules would also delete all |
|---|
| 1026 | the comments created by this user, or cause a transaction error |
|---|
| 1027 | because the relation is set to restrict, depending on the current |
|---|
| 1028 | phase of the moon (this changed a lot in the past, i expect it to |
|---|
| 1029 | continue to change). |
|---|
| 1030 | |
|---|
| 1031 | This method unsets the user and updates the comment builtin columns |
|---|
| 1032 | to the values from the user object. |
|---|
| 1033 | """ |
|---|
| 1034 | assert self.user is not None |
|---|
| 1035 | self._email= self.user.email |
|---|
| 1036 | self._www = self.user.www |
|---|
| 1037 | self._author = self.user.display_name |
|---|
| 1038 | self.user = None |
|---|
| 1039 | |
|---|
| 1040 | def _get_status(self): |
|---|
| 1041 | return self._status |
|---|
| 1042 | |
|---|
| 1043 | def _set_status(self, value): |
|---|
| 1044 | was_blocked = self.blocked |
|---|
| 1045 | self._status = value |
|---|
| 1046 | now_blocked = self.blocked |
|---|
| 1047 | |
|---|
| 1048 | # update the comment count on the post if the moderation flag |
|---|
| 1049 | # changed from blocked to unblocked or vice versa |
|---|
| 1050 | if was_blocked != now_blocked: |
|---|
| 1051 | self.post._comment_count = (self.post._comment_count or 0) + \ |
|---|
| 1052 | (now_blocked and -1 or +1) |
|---|
| 1053 | |
|---|
| 1054 | status = property(_get_status, _set_status) |
|---|
| 1055 | del _get_status, _set_status |
|---|
| 1056 | |
|---|
| 1057 | @property |
|---|
| 1058 | def anonymous(self): |
|---|
| 1059 | """True if this comment is an anonymous comment.""" |
|---|
| 1060 | return self.user is None |
|---|
| 1061 | |
|---|
| 1062 | @property |
|---|
| 1063 | def requires_moderation(self): |
|---|
| 1064 | """This is `True` if the comment requires moderation with the |
|---|
| 1065 | current moderation settings. This does not check if the comment |
|---|
| 1066 | is already moderated. |
|---|
| 1067 | """ |
|---|
| 1068 | if not self.anonymous: |
|---|
| 1069 | return False |
|---|
| 1070 | moderate = get_application().cfg['moderate_comments'] |
|---|
| 1071 | if moderate == MODERATE_ALL: |
|---|
| 1072 | return True |
|---|
| 1073 | elif moderate == MODERATE_NONE: |
|---|
| 1074 | return False |
|---|
| 1075 | return db.execute(comments.select( |
|---|
| 1076 | (comments.c.author == self._author) & |
|---|
| 1077 | (comments.c.email == self._email) & |
|---|
| 1078 | (comments.c.status == COMMENT_MODERATED) |
|---|
| 1079 | )).fetchone() is None |
|---|
| 1080 | |
|---|
| 1081 | def make_visible_for_request(self, request=None): |
|---|
| 1082 | """Make the comment visible for the current request.""" |
|---|
| 1083 | if request is None: |
|---|
| 1084 | request = get_request() |
|---|
| 1085 | comments = set(request.session.get('visible_comments', ())) |
|---|
| 1086 | comments.add(self.id) |
|---|
| 1087 | request.session['visible_comments'] = tuple(comments) |
|---|
| 1088 | |
|---|
| 1089 | def visible_for_user(self, user=None): |
|---|
| 1090 | """Check if the current user or the user given can see this comment""" |
|---|
| 1091 | request = get_request() |
|---|
| 1092 | if user is None: |
|---|
| 1093 | user = request.user |
|---|
| 1094 | if self.post.author is user and \ |
|---|
| 1095 | user.has_privilege(MODERATE_OWN_ENTRIES | MODERATE_OWN_PAGES): |
|---|
| 1096 | return True |
|---|
| 1097 | elif user.has_privilege(MODERATE_COMMENTS): |
|---|
| 1098 | # User is able to manage comments. It's visible. |
|---|
| 1099 | return True |
|---|
| 1100 | elif self.id in request.session.get('visible_comments', ()): |
|---|
| 1101 | # Comment was made visible for current request. It's visible |
|---|
| 1102 | return True |
|---|
| 1103 | # Finally, comment is visible if not blocked |
|---|
| 1104 | return not self.blocked |
|---|
| 1105 | |
|---|
| 1106 | @property |
|---|
| 1107 | def visible(self): |
|---|
| 1108 | """Check the current session it can see the comment or check against the |
|---|
| 1109 | current user. To display a comment for a request you can use the |
|---|
| 1110 | `make_visible_for_request` function. This is useful to show a comment |
|---|
| 1111 | to a user that submitted a comment which is not yet moderated. |
|---|
| 1112 | """ |
|---|
| 1113 | request = get_request() |
|---|
| 1114 | if request is None: |
|---|
| 1115 | return True |
|---|
| 1116 | return self.visible_for_user(request.user) |
|---|
| 1117 | |
|---|
| 1118 | @property |
|---|
| 1119 | def visible_children(self): |
|---|
| 1120 | """Only the children that are visible for the current user.""" |
|---|
| 1121 | return [x for x in self.children if x.visible] |
|---|
| 1122 | |
|---|
| 1123 | @property |
|---|
| 1124 | def blocked(self): |
|---|
| 1125 | """This is true if the status is anything but moderated.""" |
|---|
| 1126 | return self.status != COMMENT_MODERATED |
|---|
| 1127 | |
|---|
| 1128 | @property |
|---|
| 1129 | def is_spam(self): |
|---|
| 1130 | """This is true if the comment is currently flagged as spam.""" |
|---|
| 1131 | return self.status == COMMENT_BLOCKED_SPAM |
|---|
| 1132 | |
|---|
| 1133 | @property |
|---|
| 1134 | def is_unmoderated(self): |
|---|
| 1135 | """True if the comment is not yet approved.""" |
|---|
| 1136 | return self.status == COMMENT_UNMODERATED |
|---|
| 1137 | |
|---|
| 1138 | @property |
|---|
| 1139 | def is_deleted(self): |
|---|
| 1140 | """True if the comment has been deleted.""" |
|---|
| 1141 | return self.status == COMMENT_DELETED |
|---|
| 1142 | |
|---|
| 1143 | def get_url_values(self): |
|---|
| 1144 | return url_for(self.post) + '#comment-%d' % self.id |
|---|
| 1145 | |
|---|
| 1146 | def summarize(self, chars=140, ellipsis=u'…'): |
|---|
| 1147 | """Summarizes the comment to the given number of characters.""" |
|---|
| 1148 | words = self.body.to_text(simple=True).split() |
|---|
| 1149 | words.reverse() |
|---|
| 1150 | length = 0 |
|---|
| 1151 | result = [] |
|---|
| 1152 | while words: |
|---|
| 1153 | word = words.pop() |
|---|
| 1154 | length += len(word) + 1 |
|---|
| 1155 | if length >= chars: |
|---|
| 1156 | break |
|---|
| 1157 | result.append(word) |
|---|
| 1158 | if words: |
|---|
| 1159 | result.append(ellipsis) |
|---|
| 1160 | return u' '.join(result) |
|---|
| 1161 | |
|---|
| 1162 | def __repr__(self): |
|---|
| 1163 | return '<%s %r>' % ( |
|---|
| 1164 | self.__class__.__name__, |
|---|
| 1165 | self.author |
|---|
| 1166 | ) |
|---|
| 1167 | |
|---|
| 1168 | |
|---|
| 1169 | class TagQuery(db.Query): |
|---|
| 1170 | |
|---|
| 1171 | def get_cloud(self, max=None, ignore_privileges=False): |
|---|
| 1172 | """Get a categorycloud.""" |
|---|
| 1173 | # XXX: ignore_privileges is currently ignored and no privilege |
|---|
| 1174 | # checking is performed. As a matter of fact only published posts |
|---|
| 1175 | # appear in the cloud. |
|---|
| 1176 | |
|---|
| 1177 | # get a query |
|---|
| 1178 | pt = post_tags.c |
|---|
| 1179 | p = posts.c |
|---|
| 1180 | t = tags.c |
|---|
| 1181 | |
|---|
| 1182 | q = ((pt.tag_id == t.tag_id) & |
|---|
| 1183 | (pt.post_id == p.post_id) & |
|---|
| 1184 | (p.status == STATUS_PUBLISHED) & |
|---|
| 1185 | (p.pub_date <= datetime.utcnow())) |
|---|
| 1186 | |
|---|
| 1187 | s = db.select( |
|---|
| 1188 | [t.tag_id, t.slug, t.name, |
|---|
| 1189 | db.func.count(p.post_id).label('s_count')], |
|---|
| 1190 | q, group_by=[t.slug, t.name, t.tag_id]).alias('post_count_query').c |
|---|
| 1191 | |
|---|
| 1192 | options = {'order_by': [db.asc(s.s_count)]} |
|---|
| 1193 | if max is not None: |
|---|
| 1194 | options['limit'] = max |
|---|
| 1195 | |
|---|
| 1196 | # the label statement circumvents a bug for sqlite3 on windows |
|---|
| 1197 | # see #65 |
|---|
| 1198 | q = db.select([s.tag_id, s.slug, s.name, s.s_count.label('s_count')], |
|---|
| 1199 | **options) |
|---|
| 1200 | |
|---|
| 1201 | items = [{ |
|---|
| 1202 | 'id': row.tag_id, |
|---|
| 1203 | 'slug': row.slug, |
|---|
| 1204 | 'name': row.name, |
|---|
| 1205 | 'count': row.s_count, |
|---|
| 1206 | 'size': 100 + log(row.s_count or 1) * 20 |
|---|
| 1207 | } for row in db.execute(q)] |
|---|
| 1208 | |
|---|
| 1209 | items.sort(key=lambda x: x['name'].lower()) |
|---|
| 1210 | return items |
|---|
| 1211 | |
|---|
| 1212 | |
|---|
| 1213 | class Tag(object): |
|---|
| 1214 | """A single tag.""" |
|---|
| 1215 | query = db.query_property(TagQuery) |
|---|
| 1216 | |
|---|
| 1217 | def __init__(self, name, slug=None): |
|---|
| 1218 | self.name = name |
|---|
| 1219 | if slug is None: |
|---|
| 1220 | self.set_auto_slug() |
|---|
| 1221 | else: |
|---|
| 1222 | self.slug = slug |
|---|
| 1223 | |
|---|
| 1224 | @staticmethod |
|---|
| 1225 | def get_or_create(name): |
|---|
| 1226 | tag = Tag.query.filter_by(name=name).first() |
|---|
| 1227 | if tag is not None: |
|---|
| 1228 | return tag |
|---|
| 1229 | return Tag(name) |
|---|
| 1230 | |
|---|
| 1231 | def set_auto_slug(self): |
|---|
| 1232 | full_slug = gen_slug(self.name) |
|---|
| 1233 | if not full_slug: |
|---|
| 1234 | # if slug generation failed we select the highest category |
|---|
| 1235 | # id as base for slug generation. |
|---|
| 1236 | tag = Tag.query.autoflush(False).order_by(Tag.id.desc()).first() |
|---|
| 1237 | full_slug = unicode(tag and tag.id or u'1') |
|---|
| 1238 | if full_slug != self.slug: |
|---|
| 1239 | while Tag.query.autoflush(False) \ |
|---|
| 1240 | .filter_by(slug=full_slug).limit(1).count(): |
|---|
| 1241 | full_slug = increment_string(full_slug) |
|---|
| 1242 | self.slug = full_slug |
|---|
| 1243 | |
|---|
| 1244 | def get_url_values(self): |
|---|
| 1245 | return 'blog/show_tag', {'slug': self.slug} |
|---|
| 1246 | |
|---|
| 1247 | def __repr__(self): |
|---|
| 1248 | return u'<%s %r>' % ( |
|---|
| 1249 | self.__class__.__name__, |
|---|
| 1250 | self.name |
|---|
| 1251 | ) |
|---|
| 1252 | |
|---|
| 1253 | |
|---|
| 1254 | class NotificationSubscription(object): |
|---|
| 1255 | """NotificationSubscriptions are part of the notification system. |
|---|
| 1256 | An `NotificationSubscription` object expresses that the user the interest |
|---|
| 1257 | belongs to _is interested in_ the occurrence of a certain kind of event. |
|---|
| 1258 | That data is then used to inform the user once such an interesting event |
|---|
| 1259 | occurs. The NotificationSubscription also knows via what notification |
|---|
| 1260 | system the user wants to be notified. |
|---|
| 1261 | """ |
|---|
| 1262 | |
|---|
| 1263 | def __init__(self, user, notification_system, notification_id): |
|---|
| 1264 | self.user = user |
|---|
| 1265 | self.notification_system = notification_system |
|---|
| 1266 | self.notification_id = notification_id |
|---|
| 1267 | |
|---|
| 1268 | def __repr__(self): |
|---|
| 1269 | return "<%s (%s, %r, %r)>" % ( |
|---|
| 1270 | self.__class__.__name__, |
|---|
| 1271 | self.user, |
|---|
| 1272 | self.notification_system, |
|---|
| 1273 | self.notification_id |
|---|
| 1274 | ) |
|---|
| 1275 | |
|---|
| 1276 | |
|---|
| 1277 | # connect the tables. |
|---|
| 1278 | db.mapper(SchemaVersion, schema_versions) |
|---|
| 1279 | db.mapper(User, users, properties={ |
|---|
| 1280 | 'id': users.c.user_id, |
|---|
| 1281 | 'display_name': db.synonym('_display_name', map_column=True), |
|---|
| 1282 | 'posts': db.dynamic_loader(Post, |
|---|
| 1283 | backref=db.backref('author', lazy=True), |
|---|
| 1284 | query_class=PostQuery, |
|---|
| 1285 | cascade='all, delete, delete-orphan'), |
|---|
| 1286 | 'comments': db.dynamic_loader(Comment, |
|---|
| 1287 | backref=db.backref('user', lazy=False), |
|---|
| 1288 | cascade='all, delete'), |
|---|
| 1289 | '_own_privileges': db.relation(_Privilege, lazy=True, |
|---|
| 1290 | secondary=user_privileges, |
|---|
| 1291 | collection_class=set, |
|---|
| 1292 | cascade='all, delete') |
|---|
| 1293 | }) |
|---|
| 1294 | db.mapper(Group, groups, properties={ |
|---|
| 1295 | 'id': groups.c.group_id, |
|---|
| 1296 | 'users': db.dynamic_loader(User, backref=db.backref('groups', lazy=True), |
|---|
| 1297 | query_class=UserQuery, |
|---|
| 1298 | secondary=group_users), |
|---|
| 1299 | '_privileges': db.relation(_Privilege, lazy=True, |
|---|
| 1300 | secondary=group_privileges, |
|---|
| 1301 | collection_class=set, |
|---|
| 1302 | cascade='all, delete') |
|---|
| 1303 | }) |
|---|
| 1304 | db.mapper(_Privilege, privileges, properties={ |
|---|
| 1305 | 'id': privileges.c.privilege_id, |
|---|
| 1306 | }) |
|---|
| 1307 | db.mapper(Category, categories, properties={ |
|---|
| 1308 | 'id': categories.c.category_id, |
|---|
| 1309 | 'posts': db.dynamic_loader(Post, secondary=post_categories, |
|---|
| 1310 | query_class=PostQuery) |
|---|
| 1311 | }, order_by=categories.c.name) |
|---|
| 1312 | db.mapper(Comment, db.join(comments, texts), properties={ |
|---|
| 1313 | 'id': comments.c.comment_id, |
|---|
| 1314 | 'text_id': [comments.c.text_id, texts.c.text_id], |
|---|
| 1315 | '_text': texts.c.text, |
|---|
| 1316 | 'text': db.synonym('_text'), |
|---|
| 1317 | '_author': comments.c.author, |
|---|
| 1318 | 'author': db.synonym('_author'), |
|---|
| 1319 | '_email': comments.c.email, |
|---|
| 1320 | 'email': db.synonym('_email'), |
|---|
| 1321 | '_www': comments.c.www, |
|---|
| 1322 | 'www': db.synonym('_www'), |
|---|
| 1323 | '_status': comments.c.status, |
|---|
| 1324 | 'status': db.synonym('_status'), |
|---|
| 1325 | 'children': db.relation(Comment, |
|---|
| 1326 | primaryjoin=comments.c.parent_id == comments.c.comment_id, |
|---|
| 1327 | order_by=[db.asc(comments.c.pub_date)], |
|---|
| 1328 | backref=db.backref('parent', remote_side=[comments.c.comment_id], |
|---|
| 1329 | primaryjoin=comments.c.parent_id == |
|---|
| 1330 | comments.c.comment_id), |
|---|
| 1331 | lazy=True |
|---|
| 1332 | ) |
|---|
| 1333 | }, order_by=comments.c.pub_date.desc(), primary_key=[comments.c.comment_id]) |
|---|
| 1334 | db.mapper(PostLink, post_links, properties={ |
|---|
| 1335 | 'id': post_links.c.link_id, |
|---|
| 1336 | }) |
|---|
| 1337 | db.mapper(Tag, tags, properties={ |
|---|
| 1338 | 'id': tags.c.tag_id, |
|---|
| 1339 | 'posts': db.dynamic_loader(Post, secondary=post_tags, |
|---|
| 1340 | query_class=PostQuery) |
|---|
| 1341 | }, order_by=tags.c.name) |
|---|
| 1342 | db.mapper(Post, db.join(posts, texts), properties={ |
|---|
| 1343 | 'id': posts.c.post_id, |
|---|
| 1344 | 'text_id': [posts.c.text_id, texts.c.text_id], |
|---|
| 1345 | '_text': texts.c.text, |
|---|
| 1346 | 'text': db.synonym('_text'), |
|---|
| 1347 | 'comments': db.relation(Comment, backref=db.backref('post', lazy=True), |
|---|
| 1348 | primaryjoin=posts.c.post_id == |
|---|
| 1349 | comments.c.post_id, |
|---|
| 1350 | order_by=[db.asc(comments.c.pub_date)], |
|---|
| 1351 | lazy=False, |
|---|
| 1352 | cascade='all, delete, delete-orphan', |
|---|
| 1353 | extension=CommentCounterExtension()), |
|---|
| 1354 | 'links': db.relation(PostLink, backref='post', |
|---|
| 1355 | cascade='all, delete, delete-orphan'), |
|---|
| 1356 | 'categories': db.relation(Category, secondary=post_categories, lazy=False, |
|---|
| 1357 | order_by=[db.asc(categories.c.name)]), |
|---|
| 1358 | 'tags': db.relation(Tag, secondary=post_tags, lazy=False, |
|---|
| 1359 | order_by=[tags.c.name]), |
|---|
| 1360 | '_comment_count': posts.c.comment_count, |
|---|
| 1361 | 'comment_count': db.synonym('_comment_count') |
|---|
| 1362 | }, order_by=posts.c.pub_date.desc(), primary_key=[posts.c.post_id]) |
|---|
| 1363 | db.mapper(SummarizedPost, posts, properties={ |
|---|
| 1364 | 'id': posts.c.post_id, |
|---|
| 1365 | 'comments': db.relation(Comment, |
|---|
| 1366 | primaryjoin=posts.c.post_id == |
|---|
| 1367 | comments.c.post_id, |
|---|
| 1368 | order_by=[db.asc(comments.c.pub_date)], |
|---|
| 1369 | lazy=True, viewonly=True), |
|---|
| 1370 | 'links': db.relation(PostLink, viewonly=True, lazy=True), |
|---|
| 1371 | 'categories': db.relation(Category, secondary=post_categories, lazy=True, |
|---|
| 1372 | order_by=[db.asc(categories.c.name)], |
|---|
| 1373 | viewonly=True), |
|---|
| 1374 | 'tags': db.relation(Tag, secondary=post_tags, lazy=True, |
|---|
| 1375 | viewonly=True, order_by=[tags.c.name]), |
|---|
| 1376 | 'comment_count': db.synonym('_comment_count', map_column=True) |
|---|
| 1377 | }, order_by=posts.c.pub_date.desc()) |
|---|
| 1378 | db.mapper(NotificationSubscription, notification_subscriptions, properties={ |
|---|
| 1379 | 'id': notification_subscriptions.c.subscription_id, |
|---|
| 1380 | 'user': db.relation(User, uselist=False, lazy=False, |
|---|
| 1381 | backref=db.backref('notification_subscriptions', |
|---|
| 1382 | lazy='dynamic' |
|---|
| 1383 | ) |
|---|
| 1384 | ) |
|---|
| 1385 | }) |
|---|