Zine

open source content publishing system


source: zine/models.py @ 1338:e3a2b003c799

Revision 1338:e3a2b003c799, 47.7 KB checked in by Georg Brandl <georg@…>, 2 years ago (diff)

merge with trunk

Line 
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"""
11from math import log
12from datetime import date, datetime, timedelta
13from urlparse import urljoin
14
15from werkzeug.exceptions import NotFound
16
17from 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
21from zine.utils import zeml
22from zine.utils.text import gen_slug, gen_timestamped_slug, build_tag_uri, \
23     increment_string
24from zine.utils.pagination import Pagination
25from zine.utils.crypto import gen_pwhash, check_pwhash
26from zine.utils.http import make_external_url
27from 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
30from zine.application import get_application, get_request, url_for
31
32from zine.i18n import to_blog_timezone
33
34#: all kind of states for a post
35STATUS_DRAFT = 1
36STATUS_PUBLISHED = 2
37STATUS_PROTECTED = 3
38STATUS_PRIVATE = 4
39
40#: Comment Status
41COMMENT_MODERATED = 0
42COMMENT_UNMODERATED = 1
43COMMENT_BLOCKED_USER = 2
44COMMENT_BLOCKED_SPAM = 3
45COMMENT_BLOCKED_SYSTEM = 4
46COMMENT_DELETED = 5
47
48#: moderation modes
49MODERATE_NONE = 0
50MODERATE_ALL = 1
51MODERATE_UNKNOWN = 2
52
53
54class _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
131class _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
146class 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
171class 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
181class 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
192class 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
284class 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
306class 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
324class _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
508class _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
721class 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
737class SummarizedPostQuery(_PostQueryBase):
738    """Add some extra methods to the summarized post model."""
739
740
741class 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
792class 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
801class 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
832class 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
845class 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
885class 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
970class 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
1169class 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
1213class 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
1254class 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.
1278db.mapper(SchemaVersion, schema_versions)
1279db.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})
1294db.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})
1304db.mapper(_Privilege, privileges, properties={
1305    'id':               privileges.c.privilege_id,
1306})
1307db.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)
1312db.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])
1334db.mapper(PostLink, post_links, properties={
1335    'id':           post_links.c.link_id,
1336})
1337db.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)
1342db.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])
1363db.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())
1378db.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})
Note: See TracBrowser for help on using the repository browser.