Example #1
0
class WikiComment(MappedClass):
    class __mongometa__:
        session = session
        name = 'wiki_comment'

    _id = FieldProperty(schema.ObjectId)
    page_id = ForeignIdProperty('WikiPage')
    text = FieldProperty(str, if_missing='')
    page = RelationProperty('WikiPage')
Example #2
0
class DiscussionAttachment(BaseAttachment):
    DiscussionClass = Discussion
    ThreadClass = Thread
    PostClass = Post
    ArtifactClass = Post
    thumbnail_size = (100, 100)

    class __mongometa__:
        polymorphic_identity = 'DiscussionAttachment'
        indexes = ['filename', 'discussion_id', 'thread_id', 'post_id']

    discussion_id = FieldProperty(schema.ObjectId)
    thread_id = FieldProperty(str)
    post_id = FieldProperty(str)
    artifact_id = FieldProperty(str)
    attachment_type = FieldProperty(str, if_missing='DiscussionAttachment')

    @property
    def discussion(self):
        return self.DiscussionClass.query.get(_id=self.discussion_id)

    @property
    def thread(self):
        return self.ThreadClass.query.get(_id=self.thread_id)

    @property
    def post(self):
        return self.PostClass.query.get(_id=self.post_id)

    @classmethod
    def metadata_for(cls, post):
        return dict(
            post_id=post._id,
            thread_id=post.thread_id,
            discussion_id=post.discussion_id,
            app_config_id=post.app_config_id)

    def url(self):
        if self.post_id:
            return (self.post.url() + 'attachment/' +
                    h.urlquote(self.filename))
        elif self.thread_id:
            return (self.thread.url() + 'attachment/' +
                    h.urlquote(self.filename))
        else:
            return (self.discussion.url() + 'attachment/' +
                    h.urlquote(self.filename))
Example #3
0
class WikiPage(MappedClass):
    class __mongometa__:
        session = session
        name = 'pages'

    _id = FieldProperty(schema.ObjectId)
    author = FieldProperty(str)
    title = FieldProperty(str)
    tags = FieldProperty([str])
    date = FieldProperty(datetime.datetime)
    text = FieldProperty(str)
    comments = RelationProperty('WikiComment')
Example #4
0
class Post(Message, VersionedArtifact, ActivityObject):

    class __mongometa__:
        name = 'post'
        history_class = PostHistory
        indexes = [
            # used in general lookups, last_post, etc
            ('discussion_id', 'status', 'timestamp'),
            'thread_id'
        ]
    type_s = 'Post'

    thread_id = ForeignIdProperty(Thread)
    discussion_id = ForeignIdProperty(Discussion)
    subject = FieldProperty(schema.Deprecated)
    status = FieldProperty(schema.OneOf('ok', 'pending', 'spam',
                                        if_missing='pending'))
    last_edit_date = FieldProperty(datetime, if_missing=None)
    last_edit_by_id = AlluraUserProperty()
    edit_count = FieldProperty(int, if_missing=0)
    spam_check_id = FieldProperty(str, if_missing='')
    text_cache = FieldProperty(MarkdownCache)
    # meta comment - system generated, describes changes to an artifact
    is_meta = FieldProperty(bool, if_missing=False)

    thread = RelationProperty(Thread)
    discussion = RelationProperty(Discussion)

    def __json__(self):
        author = self.author()
        return dict(
            _id=str(self._id),
            thread_id=self.thread_id,
            slug=self.slug,
            subject=self.subject,
            status=self.status,
            text=self.text,
            timestamp=self.timestamp,
            last_edited=self.last_edit_date,
            author_id=str(author._id),
            author=author.username)

    @property
    def activity_name(self):
        return 'a comment'

    @property
    def activity_url(self):
        return self.url_paginated()

    def has_activity_access(self, perm, user, activity):
        """Return True if user has perm access to this object, otherwise
        return False.

        For the purposes of activitystreams, we're saying that the user does
        not have access to a 'comment' activity unless he also has access to
        the artifact on which it was posted (if there is one).
        """
        if self.project is None or self.deleted or self.status != 'ok':
            return False
        artifact_access = True
        if self.thread.artifact:
            if self.thread.artifact.project is None:
                return False
            if self.thread.artifact.deleted:
                return False
            artifact_access = security.has_access(self.thread.artifact, perm,
                                                  user, self.thread.artifact.project)

        return artifact_access and security.has_access(self, perm, user,
                                                       self.project)

    @property
    def activity_extras(self):
        d = ActivityObject.activity_extras.fget(self)
        # For activity summary, convert Post text to html,
        # strip all tags, and truncate
        LEN = 500
        summary = jinja2.Markup.escape(
            g.markdown.cached_convert(self, 'text')).striptags()
        if len(summary) > LEN:
            split = max(summary.find(' ', LEN), LEN)
            summary = summary[:split] + '...'
        d.update(summary=summary)
        return d

    def index(self):
        result = super(Post, self).index()
        result.update(
            title='Post by %s on %s' % (
                self.author().username, self.subject),
            name_s=self.subject,
            type_s='Post',
            text=self.text)
        return result

    @classmethod
    def discussion_class(cls):
        return cls.discussion.related

    @classmethod
    def thread_class(cls):
        return cls.thread.related

    @classmethod
    def attachment_class(cls):
        return DiscussionAttachment

    @property
    def parent(self):
        if self.parent_id:
            return self.query.get(_id=self.parent_id)

    @property
    def subject(self):
        subject = None
        if self.thread:
            subject = self.thread.subject
            if not subject:
                artifact = self.thread.artifact
                if artifact:
                    subject = getattr(artifact, 'email_subject', '')
        return subject or '(no subject)'

    @LazyProperty
    def attachments(self):
        atts = self.attachment_class().query.find(dict(
            post_id=self._id, type='attachment')).all()
        return utils.unique_attachments(atts)

    def add_multiple_attachments(self, file_info):
        if isinstance(file_info, list):
            map(self.add_attachment, file_info)
        else:
            self.add_attachment(file_info)

    def add_attachment(self, file_info):
        if hasattr(file_info, 'file'):
            mime_type = file_info.type
            if not mime_type or '/' not in mime_type:
                mime_type = utils.guess_mime_type(file_info.filename)
            self.attach(
                file_info.filename, file_info.file, content_type=mime_type,
                post_id=self._id,
                thread_id=self.thread_id,
                discussion_id=self.discussion_id)

    def last_edit_by(self):
        return User.query.get(_id=self.last_edit_by_id) or User.anonymous()

    def primary(self):
        return self.thread.primary()

    def url(self):
        if self.thread:
            return self.thread.url() + h.urlquote(self.slug) + '/'
        else:  # pragma no cover
            return None

    def parent_artifact(self):
        """
        :return: the artifact (e.g Ticket, Wiki Page) that this Post belongs to.  May return None.
        """
        aref = ArtifactReference.query.get(_id=self.thread.ref_id)
        if aref and aref.artifact:
            return aref.artifact
        else:
            return None

    def main_url(self):
        """
        :return: the URL for the artifact (e.g Ticket, Wiki Page) that this Post belongs to,
                 else the default thread URL
        """
        parent_artifact = self.parent_artifact()
        if parent_artifact:
            url = parent_artifact.url()
        else:
            url = self.thread.url()
        return url

    def url_paginated(self):
        '''Return link to the thread with a #target that poins to this comment.

        Also handle pagination properly.
        '''
        if not self.thread:  # pragma no cover
            return None
        limit, p, s = g.handle_paging(None, 0)  # get paging limit
        if self.query.find(dict(thread_id=self.thread._id)).count() <= limit:
            # all posts in a single page
            page = 0
        else:
            posts = self.thread.find_posts()
            posts = self.thread.create_post_threads(posts)

            def find_i(posts):
                '''Find the index number of this post in the display order'''
                q = []

                def traverse(posts):
                    for p in posts:
                        if p['post']._id == self._id:
                            return True  # found
                        q.append(p)
                        if traverse(p['children']):
                            return True
                traverse(posts)
                return len(q)

            page = find_i(posts) / limit

        slug = h.urlquote(self.slug)
        url = self.main_url()
        if page == 0:
            return '%s?limit=%s#%s' % (url, limit, slug)
        return '%s?limit=%s&page=%s#%s' % (url, limit, page, slug)

    def shorthand_id(self):
        if self.thread:
            return '%s#%s' % (self.thread.shorthand_id(), self.slug)
        else:  # pragma no cover
            return None

    def link_text(self):
        return self.subject

    def reply_subject(self):
        if self.subject and self.subject.lower().startswith('re:'):
            return self.subject
        else:
            return 'Re: ' + (self.subject or '(no subject)')

    def delete(self):
        self.deleted = True
        session(self).flush(self)
        self.thread.update_stats()

    def approve(self, file_info=None, notify=True, notification_text=None):
        if self.status == 'ok':
            return
        self.status = 'ok'
        author = self.author()
        author_role = ProjectRole.by_user(
            author, project=self.project, upsert=True)
        if not author.is_anonymous():
            security.simple_grant(
                self.acl, author_role._id, 'moderate')
        self.commit()
        if (c.app.config.options.get('PostingPolicy') == 'ApproveOnceModerated'
                and author._id != None):
            security.simple_grant(
                self.acl, author_role._id, 'unmoderated_post')
        if notify:
            self.notify(file_info=file_info, notification_text=notification_text)
        artifact = self.thread.artifact or self.thread
        session(self).flush()
        self.thread.last_post_date = max(
            self.thread.last_post_date,
            self.mod_date)
        self.thread.update_stats()
        if hasattr(artifact, 'update_stats'):
            artifact.update_stats()
        if self.text and not self.is_meta:
            g.director.create_activity(author, 'posted', self, target=artifact,
                                       related_nodes=[self.app_config.project],
                                       tags=['comment'])

    def notify(self, file_info=None, notification_text=None):
        if self.project.notifications_disabled:
            return  # notifications disabled for entire project
        artifact = self.thread.artifact or self.thread
        msg_id = artifact.url() + self._id
        notification_params = dict(
            post=self,
            text=notification_text,
            file_info=file_info)
        n = Notification.query.get(_id=msg_id)
        if n and 'Moderation action required' in n.subject:
            # Existing notification for this artifact is for moderators only,
            # this means artifact was not auto approved, and all the
            # subscribers did not receive notification. Now, moderator approved
            # artifact/post, so we should re-send actual notification
            msg_id = u'approved-' + msg_id
            n = Notification.query.get(_id=msg_id)
            if n:
                # 'approved' notification also exists, re-send
                n.fire_notification_task([artifact, self.thread], 'message')
            else:
                # 'approved' notification does not exist, create
                notification_params['message_id'] = msg_id
        if not n:
            # artifact is Forum (or artifact like WikiPage)
            n = Notification.post(artifact, 'message',
                                  additional_artifacts_to_match_subscriptions=self.thread,
                                  **notification_params)
        if not n:
            return
        if getattr(artifact, 'monitoring_email', None):
            if hasattr(artifact, 'notify_post'):
                if artifact.notify_post:
                    n.send_simple(artifact.monitoring_email)
            else:  # Send if no extra checks required
                n.send_simple(artifact.monitoring_email)

    def spam(self, submit_spam_feedback=True):
        self.status = 'spam'
        if submit_spam_feedback:
            g.spam_checker.submit_spam(self.text, artifact=self, user=self.author())
        session(self).flush(self)
        self.thread.update_stats()

    def undo(self, prev_status):
        if prev_status in ('ok', 'pending'):
            self.status = prev_status
            session(self).flush(self)
            self.thread.update_stats()
Example #5
0
class Discussion(Artifact, ActivityObject):

    class __mongometa__:
        name = 'discussion'
    type_s = 'Discussion'

    parent_id = FieldProperty(schema.Deprecated)
    shortname = FieldProperty(str)
    name = FieldProperty(str)
    description = FieldProperty(str, if_missing='')
    description_cache = FieldProperty(MarkdownCache)
    num_topics = FieldProperty(int, if_missing=0)
    num_posts = FieldProperty(int, if_missing=0)
    subscriptions = FieldProperty({str: bool})

    threads = RelationProperty('Thread', via='discussion_id')
    posts = RelationProperty('Post', via='discussion_id')

    def __json__(self, limit=None, posts_limit=None, is_export=False):
        return dict(
            _id=str(self._id),
            shortname=self.shortname,
            name=self.name,
            description=self.description,
            threads=[t.__json__(limit=posts_limit, is_export=is_export) for t
                     in self.thread_class().query.find(dict(discussion_id=self._id)).limit(limit or 0)]
        )

    @property
    def activity_name(self):
        return 'discussion %s' % self.name

    @classmethod
    def thread_class(cls):
        return cls.threads.related

    @classmethod
    def post_class(cls):
        return cls.posts.related

    @classmethod
    def attachment_class(cls):
        return DiscussionAttachment

    def update_stats(self):
        self.num_topics = self.thread_class().query.find(
            dict(discussion_id=self._id)).count()
        self.num_posts = self.post_class().query.find(
            dict(discussion_id=self._id, status='ok', deleted=False)).count()

    @LazyProperty
    def last_post(self):
        q = self.post_class().query.find(dict(
            discussion_id=self._id,
            status='ok',
            deleted=False,
        )).sort('timestamp', pymongo.DESCENDING).limit(1)
        return q.first()

    def url(self):
        return self.app.url + '_discuss/'

    def shorthand_id(self):
        return self.shortname

    def index(self):
        result = Artifact.index(self)
        result.update(
            title=self.name,
            name_s=self.name,
            text=self.description)
        return result

    def delete(self):
        # Delete all the threads, posts, and artifacts
        self.thread_class().query.remove(dict(discussion_id=self._id))
        self.post_class().query.remove(dict(discussion_id=self._id))
        self.attachment_class().remove(dict(discussion_id=self._id))
        super(Discussion, self).delete()

    def find_posts(self, **kw):
        q = dict(kw, discussion_id=self._id, deleted=False)
        return self.post_class().query.find(q)
Example #6
0
class Thread(Artifact, ActivityObject):

    class __mongometa__:
        name = 'thread'
        indexes = [
            ('artifact_id',),
            ('ref_id',),
            (('app_config_id', pymongo.ASCENDING),
             ('last_post_date', pymongo.DESCENDING),
             ('mod_date', pymongo.DESCENDING)),
            ('discussion_id',),
        ]
    type_s = 'Thread'

    _id = FieldProperty(str, if_missing=lambda: h.nonce(8))
    discussion_id = ForeignIdProperty(Discussion)
    ref_id = ForeignIdProperty('ArtifactReference')
    subject = FieldProperty(str, if_missing='')
    num_replies = FieldProperty(int, if_missing=0)
    num_views = FieldProperty(int, if_missing=0)
    subscriptions = FieldProperty({str: bool})
    first_post_id = ForeignIdProperty('Post')
    last_post_date = FieldProperty(datetime, if_missing=datetime(1970, 1, 1))
    artifact_reference = FieldProperty(schema.Deprecated)
    artifact_id = FieldProperty(schema.Deprecated)

    discussion = RelationProperty(Discussion)
    posts = RelationProperty('Post', via='thread_id')
    first_post = RelationProperty('Post', via='first_post_id')
    ref = RelationProperty('ArtifactReference')

    def should_update_index(self, old_doc, new_doc):
        """Skip index update if only `num_views` has changed.

        Value of `num_views` is updated whenever user loads thread page.
        This generates a lot of unnecessary `add_artifacts` tasks.
        """
        old_doc.pop('num_views', None)
        new_doc.pop('num_views', None)
        return old_doc != new_doc

    def attachment_for_export(self, page):
        return [dict(bytes=attach.length,
                     url=h.absurl(attach.url()),
                     path=os.path.join(
                         self.artifact.app_config.options.mount_point,
                         str(self.artifact._id),
                         self._id,
                         page.slug,
                         os.path.basename(attach.filename))
                     ) for attach in page.attachments]

    def attachments_for_json(self, page):
        return [dict(bytes=attach.length,
                     url=h.absurl(attach.url())) for attach in page.attachments]

    def __json__(self, limit=None, page=None, is_export=False):
        return dict(
            _id=self._id,
            discussion_id=str(self.discussion_id),
            subject=self.subject,
            limit=limit,
            page=page,
            posts=[dict(slug=p.slug,
                        text=p.text,
                        subject=p.subject,
                        author=p.author().username,
                        author_icon_url=h.absurl(p.author().icon_url()),
                        timestamp=p.timestamp,
                        last_edited=p.last_edit_date,
                        attachments=self.attachment_for_export(p) if is_export else self.attachments_for_json(p))
                   for p in self.query_posts(status='ok', style='chronological', limit=limit, page=page)
                   ]
        )

    @property
    def activity_name(self):
        return 'thread %s' % self.subject

    def parent_security_context(self):
        return self.discussion

    @classmethod
    def new(cls, **props):
        '''Creates a new Thread instance, ensuring a unique _id.'''
        for i in range(5):
            try:
                thread = cls(**props)
                session(thread).flush(thread)
                return thread
            except DuplicateKeyError as err:
                log.warning(
                    'Got DuplicateKeyError: attempt #%s, trying again. %s', i, err)
                if i == 4:
                    raise
                session(thread).expunge(thread)
                continue

    @classmethod
    def discussion_class(cls):
        return cls.discussion.related

    @classmethod
    def post_class(cls):
        return cls.posts.related

    @classmethod
    def attachment_class(cls):
        return DiscussionAttachment

    @property
    def artifact(self):
        # Threads attached to a wiki page, ticket, etc will have a .ref.artifact pointing to that WikiPage etc
        # Threads that are part of a forum will not have that
        if self.ref is None:
            return self.discussion
        return self.ref.artifact

    # Use wisely - there's .num_replies also
    @property
    def post_count(self):
        return Post.query.find(dict(
            discussion_id=self.discussion_id,
            thread_id=self._id,
            status={'$in': ['ok', 'pending']},
            deleted=False,
        )).count()

    def primary(self):
        if self.ref is None:
            return self
        return self.ref.artifact

    def post_to_feed(self, post):
        if post.status == 'ok':
            Feed.post(
                self.primary(),
                title=post.subject,
                description=post.text,
                link=post.url_paginated(),
                pubdate=post.mod_date,
            )

    def add_post(self, **kw):
        """Helper function to avoid code duplication."""
        p = self.post(**kw)
        p.commit(update_stats=False)
        session(self).flush(self)
        self.update_stats()
        if not self.first_post:
            self.first_post_id = p._id
        self.post_to_feed(p)
        return p

    def include_subject_in_spam_check(self, post):
        return (post.primary() == post  # only artifacts where the discussion is the main thing i.e. ForumPost
                and
                self.num_replies == 0)  # only first post in thread

    def is_spam(self, post):
        roles = [r.name for r in c.project.named_roles]
        spam_check_text = post.text
        if self.include_subject_in_spam_check(post):
            spam_check_text = self.subject + u'\n' + spam_check_text
        spammy = g.spam_checker.check(spam_check_text, artifact=post, user=c.user)
        if c.user in c.project.users_with_role(*roles):
            # always run the check, so it's logged.  But don't act on it for admins/developers of their own project
            return False
        else:
            return spammy

    def post(self, text, message_id=None, parent_id=None, notify=True,
             notification_text=None, timestamp=None, ignore_security=False,
             is_meta=False, **kw):
        if not ignore_security:
            require_access(self, 'post')
        if self.ref_id and self.artifact:
            self.artifact.subscribe()
        if message_id is None:
            message_id = h.gen_message_id()
        parent = parent_id and self.post_class().query.get(_id=parent_id)
        slug, full_slug = self.post_class().make_slugs(parent, timestamp)
        kwargs = dict(
            discussion_id=self.discussion_id,
            full_slug=full_slug,
            slug=slug,
            thread_id=self._id,
            parent_id=parent_id,
            text=text,
            status='pending',
            is_meta=is_meta)
        if timestamp is not None:
            kwargs['timestamp'] = timestamp
        if message_id is not None:
            kwargs['_id'] = message_id
        post = self.post_class()(**kwargs)

        # unmoderated post -> autoapprove
        # unmoderated post but is spammy -> don't approve it, it goes into moderation
        # moderated post -> moderation
        # moderated post but is spammy -> mark as spam
        spammy = self.is_spam(post)
        if ignore_security or (not spammy and has_access(self, 'unmoderated_post')):
            log.info('Auto-approving message from %s', c.user.username)
            file_info = kw.get('file_info', None)
            post.approve(file_info, notify=notify,
                         notification_text=notification_text)
        elif not has_access(self, 'unmoderated_post') and spammy:
            post.spam(submit_spam_feedback=False)  # no feedback since we're marking as spam automatically not manually
        else:
            self.notify_moderators(post)
        return post

    def notify_moderators(self, post):
        ''' Notify moderators that a post needs approval [#2963] '''
        artifact = self.artifact or self
        subject = '[%s:%s] Moderation action required' % (
            c.project.shortname, c.app.config.options.mount_point)
        author = post.author()
        url = self.discussion_class().query.get(_id=self.discussion_id).url()
        text = ('The following submission requires approval at %s before '
                'it can be approved for posting:\n\n%s'
                % (h.absurl(url + 'moderate'), post.text))
        n = Notification(
            ref_id=artifact.index_id(),
            topic='message',
            link=artifact.url(),
            _id=artifact.url() + post._id,
            from_address=str(author._id) if author != User.anonymous()
            else None,
            reply_to_address=g.noreply,
            subject=subject,
            text=text,
            in_reply_to=post.parent_id,
            author_id=author._id,
            pubdate=datetime.utcnow())
        users = self.app_config.project.users()
        for u in users:
            if (has_access(self, 'moderate', u)
                and Mailbox.subscribed(user_id=u._id,
                                       app_config_id=post.app_config_id)):
                    n.send_direct(str(u._id))

    def update_stats(self):
        self.num_replies = self.post_class().query.find(
            dict(thread_id=self._id, status='ok', deleted=False)).count()

    @LazyProperty
    def last_post(self):
        q = self.post_class().query.find(dict(
            thread_id=self._id,
            deleted=False,
        )).sort('timestamp', pymongo.DESCENDING)
        return q.first()

    def create_post_threads(self, posts):
        result = []
        post_index = {}
        for p in sorted(posts, key=lambda p: p.full_slug):
            pi = dict(post=p, children=[])
            post_index[p._id] = pi
            if p.parent_id in post_index:
                post_index[p.parent_id]['children'].append(pi)
            else:
                result.append(pi)
        return result

    def query_posts(self, page=None, limit=None,
                    timestamp=None, style='threaded', status=None):
        if timestamp:
            terms = dict(discussion_id=self.discussion_id, thread_id=self._id,
                         status={'$in': ['ok', 'pending']}, timestamp=timestamp)
        else:
            terms = dict(discussion_id=self.discussion_id, thread_id=self._id,
                         status={'$in': ['ok', 'pending']})
        if status:
            terms['status'] = status
        terms['deleted'] = False
        q = self.post_class().query.find(terms)
        if style == 'threaded':
            q = q.sort('full_slug')
        else:
            q = q.sort('timestamp')
        if limit is not None:
            limit = int(limit)
            if page is not None:
                q = q.skip(page * limit)
            q = q.limit(limit)
        return q

    def find_posts(self, page=None, limit=None, timestamp=None,
                   style='threaded'):
        return self.query_posts(page=page, limit=limit,
                                timestamp=timestamp, style=style).all()

    def url(self):
        # Can't use self.discussion because it might change during the req
        discussion = self.discussion_class().query.get(_id=self.discussion_id)
        return discussion.url() + 'thread/' + str(self._id) + '/'

    def shorthand_id(self):
        return self._id

    def index(self):
        result = Artifact.index(self)
        result.update(
            title=self.subject or '(no subject)',
            name_s=self.subject,
            views_i=self.num_views,
            text=self.subject)
        return result

    def delete(self):
        for p in self.post_class().query.find(dict(thread_id=self._id)):
            p.delete()
        self.attachment_class().remove(dict(thread_id=self._id))
        super(Thread, self).delete()

    def spam(self):
        """Mark this thread as spam."""
        for p in self.post_class().query.find(dict(thread_id=self._id)):
            p.spam()
Example #7
0
class Post(Message, VersionedArtifact, ActivityObject):
    class __mongometa__:
        name = 'post'
        history_class = PostHistory
        indexes = [
            ('discussion_id', 'status',
             'timestamp'),  # used in general lookups, last_post, etc
            'thread_id'
        ]

    type_s = 'Post'

    thread_id = ForeignIdProperty(Thread)
    discussion_id = ForeignIdProperty(Discussion)
    subject = FieldProperty(schema.Deprecated)
    status = FieldProperty(
        schema.OneOf('ok', 'pending', 'spam', if_missing='pending'))
    flagged_by = FieldProperty([schema.ObjectId])
    flags = FieldProperty(int, if_missing=0)
    last_edit_date = FieldProperty(datetime, if_missing=None)
    last_edit_by_id = ForeignIdProperty(User)
    edit_count = FieldProperty(int, if_missing=0)
    spam_check_id = FieldProperty(str, if_missing='')
    text_cache = FieldProperty(MarkdownCache)

    thread = RelationProperty(Thread)
    discussion = RelationProperty(Discussion)

    def __json__(self):
        author = self.author()
        return dict(_id=str(self._id),
                    thread_id=self.thread_id,
                    slug=self.slug,
                    subject=self.subject,
                    status=self.status,
                    text=self.text,
                    flagged_by=map(str, self.flagged_by),
                    timestamp=self.timestamp,
                    author_id=str(author._id),
                    author=author.username)

    @property
    def activity_name(self):
        return 'a comment'

    def has_activity_access(self, perm, user):
        """Return True if user has perm access to this object, otherwise
        return False.

        For the purposes of activitystreams, we're saying that the user does
        not have access to a 'comment' activity unless he also has access to
        the artifact on which it was posted (if there is one).
        """
        artifact_access = True
        if self.thread.artifact:
            artifact_access = security.has_access(self.thread.artifact, perm,
                                                  user,
                                                  self.thread.artifact.project)

        return artifact_access and security.has_access(self, perm, user,
                                                       self.project)

    def index(self):
        result = super(Post, self).index()
        result.update(title='Post by %s on %s' %
                      (self.author().username, self.subject),
                      name_s=self.subject,
                      type_s='Post',
                      text=self.text)
        return result

    @classmethod
    def discussion_class(cls):
        return cls.discussion.related

    @classmethod
    def thread_class(cls):
        return cls.thread.related

    @classmethod
    def attachment_class(cls):
        return DiscussionAttachment

    @property
    def parent(self):
        return self.query.get(_id=self.parent_id)

    @property
    def subject(self):
        subject = self.thread.subject
        if not subject:
            artifact = self.thread.artifact
            if artifact:
                subject = getattr(artifact, 'email_subject', '')
        return subject or '(no subject)'

    @LazyProperty
    def attachments(self):
        return self.attachment_class().query.find(
            dict(post_id=self._id, type='attachment')).all()

    def add_multiple_attachments(self, file_info):
        if isinstance(file_info, list):
            map(self.add_attachment, file_info)
        else:
            self.add_attachment(file_info)

    def add_attachment(self, file_info):
        if hasattr(file_info, 'file'):
            mime_type = file_info.type
            if not mime_type or '/' not in mime_type:
                mime_type = utils.guess_mime_type(file_info.filename)
            self.attach(file_info.filename,
                        file_info.file,
                        content_type=mime_type,
                        post_id=self._id,
                        thread_id=self.thread_id,
                        discussion_id=self.discussion_id)

    def last_edit_by(self):
        return User.query.get(_id=self.last_edit_by_id) or User.anonymous()

    def primary(self):
        return self.thread.primary()

    def url(self):
        if self.thread:
            return self.thread.url() + h.urlquote(self.slug) + '/'
        else:  # pragma no cover
            return None

    def url_paginated(self):
        '''Return link to the thread with a #target that poins to this comment.

        Also handle pagination properly.
        '''
        if not self.thread:  # pragma no cover
            return None
        limit, p, s = g.handle_paging(None, 0)  # get paging limit
        if self.query.find(dict(thread_id=self.thread._id)).count() <= limit:
            # all posts in a single page
            page = 0
        else:
            posts = self.thread.find_posts()
            posts = self.thread.create_post_threads(posts)

            def find_i(posts):
                '''Find the index number of this post in the display order'''
                q = []

                def traverse(posts):
                    for p in posts:
                        if p['post']._id == self._id:
                            return True  # found
                        q.append(p)
                        if traverse(p['children']):
                            return True

                traverse(posts)
                return len(q)

            page = find_i(posts) / limit

        slug = h.urlquote(self.slug)
        aref = ArtifactReference.query.get(_id=self.thread.ref_id)
        if aref and aref.artifact:
            url = aref.artifact.url()
        else:
            url = self.thread.url()
        if page == 0:
            return '%s?limit=%s#%s' % (url, limit, slug)
        return '%s?limit=%s&page=%s#%s' % (url, limit, page, slug)

    def shorthand_id(self):
        if self.thread:
            return '%s#%s' % (self.thread.shorthand_id(), self.slug)
        else:  # pragma no cover
            return None

    def link_text(self):
        return self.subject

    def reply_subject(self):
        if self.subject and self.subject.lower().startswith('re:'):
            return self.subject
        else:
            return 'Re: ' + (self.subject or '(no subject)')

    def delete(self):
        self.attachment_class().remove(dict(post_id=self._id))
        super(Post, self).delete()
        self.thread.num_replies = max(0, self.thread.num_replies - 1)

    def approve(self, file_info=None, notify=True):
        if self.status == 'ok':
            return
        self.status = 'ok'
        author = self.author()
        security.simple_grant(self.acl,
                              author.project_role(self.project)._id,
                              'moderate')
        self.commit()
        if (c.app.config.options.get('PostingPolicy') == 'ApproveOnceModerated'
                and author._id != None):
            security.simple_grant(self.acl,
                                  author.project_role()._id,
                                  'unmoderated_post')
        if notify:
            self.notify(file_info=file_info)
        artifact = self.thread.artifact or self.thread
        session(self).flush()
        self.thread.last_post_date = max(self.thread.last_post_date,
                                         self.mod_date)
        self.thread.update_stats()
        if hasattr(artifact, 'update_stats'):
            artifact.update_stats()
        if self.text:
            g.director.create_activity(author,
                                       'posted',
                                       self,
                                       target=artifact,
                                       related_nodes=[self.app_config.project])

    def notify(self, file_info=None, check_dup=False):
        if self.project.notifications_disabled:
            return  # notifications disabled for entire project
        artifact = self.thread.artifact or self.thread
        n = Notification.query.get(_id=artifact.url() +
                                   self._id) if check_dup else None
        if not n:
            n = Notification.post(artifact,
                                  'message',
                                  post=self,
                                  file_info=file_info)
        if not n: return
        if (hasattr(artifact, "monitoring_email")
                and artifact.monitoring_email):
            if hasattr(artifact, 'notify_post'):
                if artifact.notify_post:
                    n.send_simple(artifact.monitoring_email)
            else:  # Send if no extra checks required
                n.send_simple(artifact.monitoring_email)

    def spam(self):
        self.status = 'spam'
        self.thread.num_replies = max(0, self.thread.num_replies - 1)
        g.spam_checker.submit_spam(self.text, artifact=self, user=c.user)
Example #8
0
class Thread(Artifact, ActivityObject):
    class __mongometa__:
        name = 'thread'
        indexes = [
            ('artifact_id', ),
            ('ref_id', ),
            (('app_config_id', pymongo.ASCENDING),
             ('last_post_date', pymongo.DESCENDING), ('mod_date',
                                                      pymongo.DESCENDING)),
            ('discussion_id', ),
        ]

    type_s = 'Thread'

    _id = FieldProperty(str, if_missing=lambda: h.nonce(8))
    discussion_id = ForeignIdProperty(Discussion)
    ref_id = ForeignIdProperty('ArtifactReference')
    subject = FieldProperty(str, if_missing='')
    num_replies = FieldProperty(int, if_missing=0)
    num_views = FieldProperty(int, if_missing=0)
    subscriptions = FieldProperty({str: bool})
    first_post_id = ForeignIdProperty('Post')
    last_post_date = FieldProperty(datetime, if_missing=datetime(1970, 1, 1))
    artifact_reference = FieldProperty(schema.Deprecated)
    artifact_id = FieldProperty(schema.Deprecated)

    discussion = RelationProperty(Discussion)
    posts = RelationProperty('Post', via='thread_id')
    first_post = RelationProperty('Post', via='first_post_id')
    ref = RelationProperty('ArtifactReference')

    def __json__(self, limit=None, page=None):
        return dict(
            _id=self._id,
            discussion_id=str(self.discussion_id),
            subject=self.subject,
            posts=[
                dict(slug=p.slug,
                     text=p.text,
                     subject=p.subject,
                     author=p.author().username,
                     timestamp=p.timestamp,
                     attachments=[
                         dict(bytes=attach.length, url=h.absurl(attach.url()))
                         for attach in p.attachments
                     ])
                for p in self.query_posts(
                    status='ok', style='chronological', limit=limit, page=page)
            ])

    @property
    def activity_name(self):
        return 'thread %s' % self.subject

    def parent_security_context(self):
        return self.discussion

    @classmethod
    def new(cls, **props):
        '''Creates a new Thread instance, ensuring a unique _id.'''
        for i in range(5):
            try:
                thread = cls(**props)
                session(thread).flush(thread)
                return thread
            except DuplicateKeyError as err:
                log.warning(
                    'Got DuplicateKeyError: attempt #%s, trying again. %s', i,
                    err)
                if i == 4:
                    raise
                session(thread).expunge(thread)
                continue

    @classmethod
    def discussion_class(cls):
        return cls.discussion.related

    @classmethod
    def post_class(cls):
        return cls.posts.related

    @classmethod
    def attachment_class(cls):
        return DiscussionAttachment

    @property
    def artifact(self):
        if self.ref is None:
            return self.discussion
        return self.ref.artifact

    # Use wisely - there's .num_replies also
    @property
    def post_count(self):
        return Post.query.find(
            dict(discussion_id=self.discussion_id,
                 thread_id=self._id,
                 status={'$in': ['ok', 'pending']})).count()

    def primary(self):
        if self.ref is None:
            return self
        return self.ref.artifact

    def add_post(self, **kw):
        """Helper function to avoid code duplication."""
        p = self.post(**kw)
        p.commit(update_stats=False)
        self.num_replies += 1
        if not self.first_post:
            self.first_post_id = p._id
        link = None
        if self.app.tool_label.lower() == 'tickets':
            link = p.url_paginated()
        if self.ref:
            Feed.post(self.primary(),
                      title=p.subject,
                      description=p.text,
                      link=link)
        return p

    def is_spam(self, post):
        if c.user in c.project.users_with_role('Admin', 'Developer'):
            return False
        else:
            return g.spam_checker.check(post.text, artifact=post, user=c.user)

    def post(self,
             text,
             message_id=None,
             parent_id=None,
             timestamp=None,
             ignore_security=False,
             **kw):
        if not ignore_security:
            require_access(self, 'post')
        if self.ref_id and self.artifact:
            self.artifact.subscribe()
        if message_id is None:
            message_id = h.gen_message_id()
        parent = parent_id and self.post_class().query.get(_id=parent_id)
        slug, full_slug = self.post_class().make_slugs(parent, timestamp)
        kwargs = dict(discussion_id=self.discussion_id,
                      full_slug=full_slug,
                      slug=slug,
                      thread_id=self._id,
                      parent_id=parent_id,
                      text=text,
                      status='pending')
        if timestamp is not None:
            kwargs['timestamp'] = timestamp
        if message_id is not None:
            kwargs['_id'] = message_id
        post = self.post_class()(**kwargs)
        if ignore_security or not self.is_spam(post) and has_access(
                self, 'unmoderated_post')():
            log.info('Auto-approving message from %s', c.user.username)
            file_info = kw.get('file_info', None)
            post.approve(file_info, notify=kw.get('notify', True))
        else:
            self.notify_moderators(post)
        return post

    def notify_moderators(self, post):
        ''' Notify moderators that a post needs approval [#2963] '''
        artifact = self.artifact or self
        subject = '[%s:%s] Moderation action required' % (
            c.project.shortname, c.app.config.options.mount_point)
        author = post.author()
        url = self.discussion_class().query.get(_id=self.discussion_id).url()
        text = ('The following submission requires approval at %s before '
                'it can be approved for posting:\n\n%s' %
                (h.absurl(url + 'moderate'), post.text))
        n = Notification(ref_id=artifact.index_id(),
                         topic='message',
                         link=artifact.url(),
                         _id=artifact.url() + post._id,
                         from_address=str(author._id)
                         if author != User.anonymous() else None,
                         reply_to_address=u'*****@*****.**',
                         subject=subject,
                         text=text,
                         in_reply_to=post.parent_id,
                         author_id=author._id,
                         pubdate=datetime.utcnow())
        users = self.app_config.project.users()
        for u in users:
            if (has_access(self, 'moderate', u)
                    and Mailbox.subscribed(user_id=u._id,
                                           app_config_id=post.app_config_id)):
                n.send_direct(str(u._id))

    def update_stats(self):
        self.num_replies = self.post_class().query.find(
            dict(thread_id=self._id, status='ok')).count() - 1

    @property
    def last_post(self):
        q = self.post_class().query.find(dict(thread_id=self._id)).sort(
            'timestamp', pymongo.DESCENDING)
        return q.first()

    def create_post_threads(self, posts):
        result = []
        post_index = {}
        for p in sorted(posts, key=lambda p: p.full_slug):
            pi = dict(post=p, children=[])
            post_index[p._id] = pi
            if p.parent_id in post_index:
                post_index[p.parent_id]['children'].append(pi)
            else:
                result.append(pi)
        return result

    def query_posts(self,
                    page=None,
                    limit=None,
                    timestamp=None,
                    style='threaded',
                    status=None):
        if timestamp:
            terms = dict(discussion_id=self.discussion_id,
                         thread_id=self._id,
                         status={'$in': ['ok', 'pending']},
                         timestamp=timestamp)
        else:
            terms = dict(discussion_id=self.discussion_id,
                         thread_id=self._id,
                         status={'$in': ['ok', 'pending']})
        if status:
            terms['status'] = status
        q = self.post_class().query.find(terms)
        if style == 'threaded':
            q = q.sort('full_slug')
        else:
            q = q.sort('timestamp')
        if limit is not None:
            limit = int(limit)
            if page is not None:
                q = q.skip(page * limit)
            q = q.limit(limit)
        return q

    def find_posts(self,
                   page=None,
                   limit=None,
                   timestamp=None,
                   style='threaded'):
        return self.query_posts(page=page,
                                limit=limit,
                                timestamp=timestamp,
                                style=style).all()

    def url(self):
        # Can't use self.discussion because it might change during the req
        discussion = self.discussion_class().query.get(_id=self.discussion_id)
        return discussion.url() + 'thread/' + str(self._id) + '/'

    def shorthand_id(self):
        return self._id

    def index(self):
        result = Artifact.index(self)
        result.update(title=self.subject or '(no subject)',
                      name_s=self.subject,
                      views_i=self.num_views,
                      text=self.subject)
        return result

    def _get_subscription(self):
        return self.subscriptions.get(str(c.user._id))

    def _set_subscription(self, value):
        if value:
            self.subscriptions[str(c.user._id)] = True
        else:
            self.subscriptions.pop(str(c.user._id), None)

    subscription = property(_get_subscription, _set_subscription)

    def delete(self):
        for p in self.post_class().query.find(dict(thread_id=self._id)):
            p.delete()
        self.attachment_class().remove(dict(thread_id=self._id))
        super(Thread, self).delete()

    def spam(self):
        """Mark this thread as spam."""
        for p in self.post_class().query.find(dict(thread_id=self._id)):
            p.spam()
Example #9
0
class Post(Message, VersionedArtifact, ActivityObject):
    class __mongometa__:
        name = 'post'
        history_class = PostHistory
        indexes = ['discussion_id', 'thread_id']

    type_s = 'Post'

    thread_id = ForeignIdProperty(Thread)
    discussion_id = ForeignIdProperty(Discussion)
    subject = FieldProperty(schema.Deprecated)
    status = FieldProperty(
        schema.OneOf('ok', 'pending', 'spam', if_missing='pending'))
    flagged_by = FieldProperty([schema.ObjectId])
    flags = FieldProperty(int, if_missing=0)
    last_edit_date = FieldProperty(datetime, if_missing=None)
    last_edit_by_id = ForeignIdProperty(User)
    edit_count = FieldProperty(int, if_missing=0)

    thread = RelationProperty(Thread)
    discussion = RelationProperty(Discussion)

    def __json__(self):
        author = self.author()
        return dict(_id=str(self._id),
                    thread_id=self.thread_id,
                    slug=self.slug,
                    subject=self.subject,
                    status=self.status,
                    text=self.text,
                    flagged_by=map(str, self.flagged_by),
                    timestamp=self.timestamp,
                    author_id=str(author._id),
                    author=author.username)

    @property
    def activity_name(self):
        return 'post %s' % self.subject

    def index(self):
        result = super(Post, self).index()
        result.update(title_s='Post by %s on %s' %
                      (self.author().username, self.subject),
                      name_s=self.subject,
                      type_s='Post',
                      text=self.text)
        return result

    @classmethod
    def discussion_class(cls):
        return cls.discussion.related

    @classmethod
    def thread_class(cls):
        return cls.thread.related

    @classmethod
    def attachment_class(cls):
        return DiscussionAttachment

    @property
    def parent(self):
        return self.query.get(_id=self.parent_id)

    @property
    def subject(self):
        subject = self.thread.subject
        if not subject:
            artifact = self.thread.artifact
            if artifact:
                subject = getattr(artifact, 'email_subject', '')
        return subject or '(no subject)'

    @property
    def attachments(self):
        return self.attachment_class().query.find(
            dict(post_id=self._id, type='attachment'))

    def last_edit_by(self):
        return User.query.get(_id=self.last_edit_by_id) or User.anonymous()

    def primary(self):
        return self.thread.primary()

    def summary(self):
        return '<a href="%s">%s</a> %s' % (
            self.author().url(), self.author().get_pref('display_name'),
            h.ago(self.timestamp))

    def url(self):
        if self.thread:
            return self.thread.url() + h.urlquote(self.slug) + '/'
        else:  # pragma no cover
            return None

    def shorthand_id(self):
        if self.thread:
            return '%s#%s' % (self.thread.shorthand_id(), self.slug)
        else:  # pragma no cover
            return None

    def link_text(self):
        return self.subject

    def reply_subject(self):
        if self.subject and self.subject.lower().startswith('re:'):
            return self.subject
        else:
            return 'Re: ' + (self.subject or '(no subject)')

    def delete(self):
        self.attachment_class().remove(dict(post_id=self._id))
        super(Post, self).delete()
        self.thread.num_replies = max(0, self.thread.num_replies - 1)

    def approve(self, file_info=None):
        from allura.model.notification import Notification
        if self.status == 'ok': return
        self.status = 'ok'
        if self.parent_id is None:
            thd = self.thread_class().query.get(_id=self.thread_id)
            g.post_event('discussion.new_thread', thd._id)
        author = self.author()
        security.simple_grant(self.acl, author.project_role()._id, 'moderate')
        self.commit()
        if (c.app.config.options.get('PostingPolicy') == 'ApproveOnceModerated'
                and author._id != None):
            security.simple_grant(self.acl,
                                  author.project_role()._id,
                                  'unmoderated_post')
        g.post_event('discussion.new_post', self.thread_id, self._id)
        artifact = self.thread.artifact or self.thread
        n = Notification.post(artifact,
                              'message',
                              post=self,
                              file_info=file_info)
        if hasattr(self.discussion,
                   "monitoring_email") and self.discussion.monitoring_email:
            n.send_simple(self.discussion.monitoring_email)
        session(self).flush()
        self.thread.last_post_date = max(self.thread.last_post_date,
                                         self.mod_date)
        self.thread.update_stats()
        self.discussion.update_stats()

    def spam(self):
        self.status = 'spam'
        self.thread.num_replies = max(0, self.thread.num_replies - 1)
        g.post_event('spam', self.index_id())
Example #10
0
class Discussion(Artifact, ActivityObject):
    class __mongometa__:
        name = 'discussion'
    type_s = 'Discussion'

    parent_id = FieldProperty(schema.Deprecated)
    shortname = FieldProperty(str)
    name = FieldProperty(str)
    description = FieldProperty(str, if_missing='')
    num_topics = FieldProperty(int, if_missing=0)
    num_posts = FieldProperty(int, if_missing=0)
    subscriptions = FieldProperty({str: bool})

    threads = RelationProperty('Thread')
    posts = RelationProperty('Post')

    def __json__(self):
        return dict(
            _id=str(self._id),
            shortname=self.shortname,
            name=self.name,
            description=self.description,
            threads=[dict(_id=t._id, subject=t.subject)
                     for t in self.threads])

    @property
    def activity_name(self):
        return 'discussion %s' % self.name

    @classmethod
    def thread_class(cls):
        return cls.threads.related

    @classmethod
    def post_class(cls):
        return cls.posts.related

    @classmethod
    def attachment_class(cls):
        return DiscussionAttachment

    def update_stats(self):
        self.num_topics = self.thread_class().query.find(
            dict(discussion_id=self._id)).count()
        self.num_posts = self.post_class().query.find(
            dict(discussion_id=self._id, status='ok')).count()

    @LazyProperty
    def last_post(self):
        q = self.post_class().query.find(dict(
                discussion_id=self._id))\
            .sort('timestamp', pymongo.DESCENDING)\
            .limit(1)\
            .hint([('discussion_id', pymongo.ASCENDING)])
            # hint is to try to force the index to be used, since mongo wouldn't select it sometimes
            # https://groups.google.com/forum/#!topic/mongodb-user/0TEqPfXxQU8
        return q.first()

    def url(self):
        return self.app.url + '_discuss/'

    def shorthand_id(self):
        return self.shortname

    def index(self):
        result = Artifact.index(self)
        result.update(
            title_s='Discussion: %s' % self.name,
            name_s=self.name,
            text=self.description)
        return result

    def subscription(self):
        return self.subscriptions.get(str(c.user._id))

    def delete(self):
        # Delete all the threads, posts, and artifacts
        self.thread_class().query.remove(dict(discussion_id=self._id))
        self.post_class().query.remove(dict(discussion_id=self._id))
        self.attachment_class().remove(dict(discussion_id=self._id))
        super(Discussion, self).delete()

    def find_posts(self, **kw):
        q = dict(kw, discussion_id=self._id)
        return self.post_class().query.find(q)