Beispiel #1
0
        if params:
            s += ';' + params
        if query:
            s += '?' + query
        if fragment:
            s += '#' + fragment
        return s

    @classmethod
    def log(cls, message, *args, **kwargs):
        project = kwargs.pop('project', c.project)
        user = kwargs.pop('user', c.user)
        url = kwargs.pop('url', request.url)
        if args:
            message = message % args
        elif kwargs:
            message = message % kwargs
        return cls(project_id=project._id,
                   user_id=user._id,
                   url=url,
                   message=message)


main_orm_session.mapper(AuditLog,
                        audit_log,
                        properties=dict(
                            project_id=ForeignIdProperty('Project'),
                            project=RelationProperty('Project'),
                            user_id=ForeignIdProperty('User'),
                            user=RelationProperty('User')))
Beispiel #2
0
class ProjectRole(MappedClass):
    """
    Per-project roles, called "Groups" in the UI.
    This can be a proxy for a single user.  It can also inherit roles.

    :var user_id: used if this role is for a single user
    :var project_id:
    :var name:
    :var roles: a list of other ProjectRole objectids
    """
    class __mongometa__:
        session = main_orm_session
        name = 'project_role'
        unique_indexes = [('user_id', 'project_id', 'name')]
        indexes = [
            ('user_id', ),
            ('project_id', 'name'),  # used in ProjectRole.by_name()
            ('roles', ),
        ]

    _id = FieldProperty(S.ObjectId)
    user_id = ForeignIdProperty('User', if_missing=None)
    project_id = ForeignIdProperty('Project', if_missing=None)
    name = FieldProperty(str)
    roles = FieldProperty([S.ObjectId])

    user = RelationProperty('User')
    project = RelationProperty('Project')

    def __init__(self, **kw):
        assert 'project_id' in kw, 'Project roles must specify a project id'
        super(ProjectRole, self).__init__(**kw)

    def display(self):
        if self.name: return self.name
        if self.user_id:
            u = self.user
            if u.username: uname = u.username
            elif u.get_pref('display_name'): uname = u.get_pref('display_name')
            else: uname = u._id
            return '*user-%s' % uname
        return '**unknown name role: %s' % self._id  # pragma no cover

    @classmethod
    def by_user(cls, user=None, project=None):
        if user is None and project is None:
            return c.user.current_project_role
        if user is None: user = c.user
        if project is None: project = c.project
        pr = cls.query.get(user_id=user._id,
                           project_id=project.root_project._id)
        if pr is None:
            pr = cls.query.get(user_id=user._id, project_id={'$exists': False})
        return pr

    @classmethod
    def by_name(cls, name, project=None):
        if project is None: project = c.project
        if hasattr(project, 'root_project'):
            project = project.root_project
        if hasattr(project, '_id'):
            project_id = project._id
        else:
            project_id = project
        role = cls.query.get(name=name, project_id=project_id)
        return role

    @classmethod
    def anonymous(cls, project=None):
        return cls.by_name('*anonymous', project)

    @classmethod
    def authenticated(cls, project=None):
        return cls.by_name('*authenticated', project)

    @classmethod
    def upsert(cls, **kw):
        obj = cls.query.get(**kw)
        if obj is not None: return obj
        try:
            obj = cls(**kw)
            session(obj).insert_now(obj, state(obj))
        except pymongo.errors.DuplicateKeyError:
            session(obj).expunge(obj)
            obj = cls.query.get(**kw)
        return obj

    @property
    def special(self):
        if self.name:
            return '*' == self.name[0]
        if self.user_id:
            return True
        return False  # pragma no cover

    @property
    def user(self):
        if (self.user_id is None and self.name and self.name != '*anonymous'):
            return None
        return User.query.get(_id=self.user_id)

    @property
    def settings_href(self):
        if self.name in ('Admin', 'Developer', 'Member'):
            return None
        return self.project.url() + 'admin/groups/' + str(self._id) + '/'

    def parent_roles(self):
        return self.query.find({'roles': self._id}).all()

    def child_roles(self):
        to_check = [] + self.roles
        found_roles = []
        while to_check:
            checking = to_check.pop()
            for role in self.query.find({'_id': checking}).all():
                if role not in found_roles:
                    found_roles.append(role)
                    to_check = to_check + role.roles
        return found_roles

    def users_with_role(self, project=None):
        if not project:
            project = c.project
        return self.query.find(
            dict(project_id=project._id, user_id={'$ne': None},
                 roles=self._id)).all()
Beispiel #3
0
class BlogPost(M.VersionedArtifact, ActivityObject):
    class __mongometa__:
        name = str('blog_post')
        history_class = BlogPostSnapshot
        unique_indexes = [('app_config_id', 'slug')]
        indexes = [
            # for [[project_blog_posts]] macro
            ('app_config_id', 'state', 'timestamp'),
            # for [[neighborhood_blog_posts]] macro
            ('neighborhood_id', 'state', 'timestamp'),
        ]

    type_s = 'Blog Post'

    title = FieldProperty(str, if_missing='Untitled')
    text = FieldProperty(str, if_missing='')
    text_cache = FieldProperty(MarkdownCache)
    timestamp = FieldProperty(datetime, if_missing=datetime.utcnow)
    slug = FieldProperty(str)
    state = FieldProperty(schema.OneOf('draft', 'published'),
                          if_missing='draft')
    neighborhood_id = ForeignIdProperty('Neighborhood', if_missing=None)

    link_regex = re.compile(
        r'^[^#]+$')  # no target in the link, meaning no comments

    @property
    def activity_name(self):
        return 'a blog post'

    @property
    def activity_extras(self):
        d = ActivityObject.activity_extras.fget(self)
        d.update(summary=self.title)
        return d

    def author(self):
        '''The author of the first snapshot of this BlogPost'''
        return M.User.query.get(
            _id=self.get_version(1).author.id) or M.User.anonymous()

    def _get_date(self):
        return self.timestamp.date()

    def _set_date(self, value):
        self.timestamp = datetime.combine(value, self.time)

    date = property(_get_date, _set_date)

    def _get_time(self):
        return self.timestamp.time()

    def _set_time(self, value):
        self.timestamp = datetime.combine(self.date, value)

    time = property(_get_time, _set_time)

    @property
    def html_text(self):
        return g.markdown.cached_convert(self, 'text')

    @property
    def html_text_preview(self):
        """Return an html preview of the BlogPost text.

        Truncation happens at paragraph boundaries to avoid chopping markdown
        in inappropriate places.

        If the entire post is one paragraph, the full text is returned.
        If the entire text is <= 400 chars, the full text is returned.
        Else, at least 400 chars are returned, rounding up to the nearest
        whole paragraph.

        If truncation occurs, a hyperlink to the full text is appended.

        """
        # Splitting on spaces or single lines breaks isn't sufficient as some
        # markup can span spaces and single line breaks. Converting to HTML
        # first and *then* truncating doesn't work either, because the
        # ellipsis tag ends up orphaned from the main text.
        ellipsis = '... [read more](%s)' % self.url()
        paragraphs = self.text.replace('\r', '').split('\n\n')
        total_length = 0
        for i, p in enumerate(paragraphs):
            total_length += len(p)
            if total_length >= 400:
                break
        text = '\n\n'.join(paragraphs[:i + 1])
        return g.markdown.convert(
            text + (ellipsis if i + 1 < len(paragraphs) else ''))

    @property
    def email_address(self):
        if self.config.options.get('AllowEmailPosting', True):
            domain = self.email_domain
            return '%s@%s%s' % (self.title.replace(
                '/', '.'), domain, config.common_suffix)
        else:
            return tg_config.get('forgemail.return_path')

    @staticmethod
    def make_base_slug(title, timestamp):
        slugsafe = ''.join(ch.lower() for ch in title.replace(' ', '-')
                           if ch.isalnum() or ch == '-')
        return '%s/%s' % (timestamp.strftime('%Y/%m'), slugsafe)

    def make_slug(self):
        base = BlogPost.make_base_slug(self.title, self.timestamp)
        self.slug = base
        while True:
            try:
                session(self).insert_now(self, state(self))
                return self.slug
            except DuplicateKeyError:
                self.slug = base + '-%.3d' % randint(0, 999)

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

    def shorthand_id(self):
        return self.slug

    def index(self):
        result = super(BlogPost, self).index()
        result.update(title=self.title,
                      type_s=self.type_s,
                      state_s=self.state,
                      snippet_s='%s: %s' %
                      (self.title, h.text.truncate(self.text, 200)),
                      text=self.text)
        return result

    def get_version(self, version):
        HC = self.__mongometa__.history_class
        return HC.query.find({
            'artifact_id': self._id,
            'version': int(version)
        }).one()

    def commit(self, subscribe=False):
        activity = functools.partial(g.director.create_activity,
                                     c.user,
                                     related_nodes=[c.project],
                                     tags=['blog'])
        if subscribe:
            self.subscribe()
        super(BlogPost, self).commit()
        if self.version > 1:
            v1 = self.get_version(self.version - 1)
            v2 = self
            la = [line + '\n' for line in v1.text.splitlines()]
            lb = [line + '\n' for line in v2.text.splitlines()]
            diff = ''.join(
                difflib.unified_diff(la, lb, 'v%d' % v1.version,
                                     'v%d' % v2.version))
            description = diff
            if v1.state != 'published' and v2.state == 'published':
                activity('created', self)
                M.Feed.post(self,
                            self.title,
                            self.text,
                            author=self.author(),
                            pubdate=self.get_version(1).timestamp)
                description = self.text
                subject = '%s created post %s' % (c.user.username, self.title)
            elif v2.state == 'published':
                feed_item = self.feed_item()
                if feed_item:
                    feed_item.title = self.title
                    feed_item.description = g.markdown.convert(self.text)
                else:
                    M.Feed.post(self,
                                self.title,
                                self.text,
                                author=self.author(),
                                pubdate=self.get_version(1).timestamp)
                if v1.title != v2.title:
                    activity('renamed', self)
                    subject = '%s renamed post %s to %s' % (c.user.username,
                                                            v1.title, v2.title)
                else:
                    activity('modified', self)
                    subject = '%s modified post %s' % (c.user.username,
                                                       self.title)
            elif v1.state == 'published' and v2.state == 'draft':
                feed_item = self.feed_item()
                if feed_item:
                    feed_item.delete()
        else:
            description = self.text
            subject = '%s created post %s' % (c.user.username, self.title)
            if self.state == 'published':
                activity('created', self)
                M.Feed.post(self,
                            self.title,
                            self.text,
                            author=self.author(),
                            pubdate=self.timestamp)
        if self.state == 'published':
            M.Notification.post(artifact=self,
                                topic='metadata',
                                text=description,
                                subject=subject)

    @classmethod
    def new(cls, **kw):
        post = cls()
        subscribe = kw.pop('subscribe', False)
        for k, v in six.iteritems(kw):
            setattr(post, k, v)
        post.neighborhood_id = c.project.neighborhood_id
        post.make_slug()
        post.commit(subscribe=subscribe)
        M.Thread.new(discussion_id=post.app_config.discussion_id,
                     ref_id=post.index_id(),
                     subject='%s discussion' % post.title)
        return post

    def __json__(self, posts_limit=None, is_export=False):
        return dict(super(BlogPost, self).__json__(posts_limit=posts_limit,
                                                   is_export=is_export),
                    author=self.author().username,
                    title=self.title,
                    url=h.absurl('/rest' + self.url()),
                    text=self.text,
                    labels=list(self.labels),
                    state=self.state)

    def feed_item(self):
        return M.Feed.query.get(ref_id=self.index_id(), link=self.link_regex)

    def delete(self):
        feed_item = self.feed_item()
        if feed_item:
            feed_item.delete()
        super(BlogPost, self).delete()

    @classmethod
    def attachment_class(cls):
        return BlogAttachment
Beispiel #4
0
                project=parts[0],
                project_id=p_id,
                app=parts[1],
                artifact=parts[2])
        elif len(parts) == 2:
            return dict(
                nbhd=p_nbhd,
                project=p_shortname,
                project_id=p_id,
                app=parts[0],
                artifact=parts[1])
        elif len(parts) == 1:
            return dict(
                nbhd=p_nbhd,
                project=p_shortname,
                project_id=p_id,
                app=None,
                artifact=parts[0])
        else:
            return None

# Mapper definitions
mapper(ArtifactReference, ArtifactReferenceDoc, main_orm_session)
mapper(Shortlink, ShortlinkDoc, main_orm_session, properties=dict(
    ref_id = ForeignIdProperty(ArtifactReference),
    project_id = ForeignIdProperty('Project'),
    app_config_id = ForeignIdProperty('AppConfig'),
    project = RelationProperty('Project'),
    app_config = RelationProperty('AppConfig'),
    ref = RelationProperty(ArtifactReference)))
Beispiel #5
0
class Artifact(MappedClass, SearchIndexable):
    """
    Base class for anything you want to keep track of.

    - Automatically indexed into Solr (see index() method)
    - Has a discussion thread that can have files attached to it

    :var mod_date: last-modified :class:`datetime`
    :var acl: dict of permission name => [roles]
    :var labels: list of plain old strings

    """
    class __mongometa__:
        session = artifact_orm_session
        name = str('artifact')
        indexes = [
            ('app_config_id', 'labels'),
        ]

        def before_save(data):
            _session = artifact_orm_session._get()
            skip_mod_date = getattr(_session, 'skip_mod_date', False)
            skip_last_updated = getattr(_session, 'skip_last_updated', False)
            if not skip_mod_date:
                data['mod_date'] = datetime.utcnow()
            else:
                log.debug('Not updating mod_date')
            if c.project and not skip_last_updated:
                c.project.last_updated = datetime.utcnow()

    type_s = 'Generic Artifact'

    # Artifact base schema
    _id = FieldProperty(S.ObjectId)
    mod_date = FieldProperty(datetime, if_missing=datetime.utcnow)
    app_config_id = ForeignIdProperty('AppConfig',
                                      if_missing=lambda: c.app.config._id)
    plugin_verson = FieldProperty(S.Deprecated)
    tool_version = FieldProperty(S.Deprecated)
    acl = FieldProperty(ACL)
    tags = FieldProperty(S.Deprecated)
    labels = FieldProperty([str])
    references = FieldProperty(S.Deprecated)
    backreferences = FieldProperty(S.Deprecated)
    app_config = RelationProperty('AppConfig')
    # Not null if artifact originated from external import.  The import ID is
    # implementation specific, but should probably be an object indicating
    # the source, original ID, and any other info needed to identify where
    # the artifact came from.  But if you only have one source, a str might do.
    import_id = FieldProperty(None, if_missing=None)
    deleted = FieldProperty(bool, if_missing=False)

    def __json__(self, posts_limit=None, is_export=False, user=None):
        """Return a JSON-encodable :class:`dict` representation of this
        Artifact.

        """
        return dict(
            _id=str(self._id),
            mod_date=self.mod_date,
            labels=list(self.labels),
            related_artifacts=[
                a.url() for a in self.related_artifacts(user=user or c.user)
            ],
            discussion_thread=self.discussion_thread.__json__(
                limit=posts_limit, is_export=is_export),
            discussion_thread_url=h.absurl('/rest%s' %
                                           self.discussion_thread.url()),
        )

    def parent_security_context(self):
        """Return the :class:`allura.model.project.AppConfig` instance for
        this Artifact.

        ACL processing for this Artifact continues at the AppConfig object.
        This lets AppConfigs provide a 'default' ACL for all artifacts in the
        tool.

        """
        return self.app_config

    @classmethod
    def attachment_class(cls):
        raise NotImplementedError('attachment_class')

    @LazyProperty
    def ref(self):
        """Return :class:`allura.model.index.ArtifactReference` for this
        Artifact.

        """
        return ArtifactReference.from_artifact(self)

    @LazyProperty
    def refs(self):
        """Artifacts referenced by this one.

        :return: list of :class:`allura.model.index.ArtifactReference`
        """
        return self.ref.references

    @LazyProperty
    def backrefs(self):
        """Artifacts that reference this one.

        :return: list of :attr:`allura.model.index.ArtifactReference._id`'s

        """
        q = ArtifactReference.query.find(dict(references=self.index_id()))
        return [aref._id for aref in q]

    def related_artifacts(self, user=None):
        """Return all Artifacts that are related to this one.

        """
        related_artifacts = []
        for ref_id in self.refs + self.backrefs:
            ref = ArtifactReference.query.get(_id=ref_id)
            if ref is None:
                continue
            artifact = ref.artifact
            if artifact is None:
                continue
            artifact = artifact.primary()
            if artifact is None:
                continue
            # don't link to artifacts in deleted tools
            if hasattr(artifact, 'app_config') and artifact.app_config is None:
                continue
            try:
                if user and not h.has_access(artifact, 'read', user):
                    continue
            except Exception:
                log.debug(
                    'Error doing permission check on related artifacts of {}, '
                    'probably because the "artifact" is a Commit not a real artifact'
                    .format(self.index_id()),
                    exc_info=True)

            # TODO: This should be refactored. We shouldn't be checking
            # artifact type strings in platform code.
            if artifact.type_s == 'Commit' and not artifact.repo:
                ac = AppConfig.query.get(
                    _id=ref.artifact_reference['app_config_id'])
                app = ac.project.app_instance(ac) if ac else None
                if app:
                    artifact.set_context(app.repo)
            if artifact not in related_artifacts and (getattr(
                    artifact, 'deleted', False) is False):
                related_artifacts.append(artifact)
        return sorted(related_artifacts, key=lambda a: a.url())

    def subscribe(self, user=None, topic=None, type='direct', n=1, unit='day'):
        """Subscribe ``user`` to the :class:`allura.model.notification.Mailbox`
        for this Artifact.

        :param user: :class:`allura.model.auth.User`

        If ``user`` is None, ``c.user`` will be subscribed.

        """
        from allura.model import Mailbox
        if user is None:
            user = c.user
        return Mailbox.subscribe(user_id=user._id,
                                 project_id=self.app_config.project_id,
                                 app_config_id=self.app_config._id,
                                 artifact=self,
                                 topic=topic,
                                 type=type,
                                 n=n,
                                 unit=unit)

    def unsubscribe(self, user=None):
        """Unsubscribe ``user`` from the
        :class:`allura.model.notification.Mailbox` for this Artifact.

        :param user: :class:`allura.model.auth.User`

        If ``user`` is None, ``c.user`` will be unsubscribed.

        """
        from allura.model import Mailbox
        if user is None:
            user = c.user
        Mailbox.unsubscribe(user_id=user._id,
                            project_id=self.app_config.project_id,
                            app_config_id=self.app_config._id,
                            artifact_index_id=self.index_id())

    @memoize  # since its called many times from edit_post.html within threaded comments
    def subscribed(self, user=None, include_parents=True):
        from allura.model import Mailbox
        if user is None:
            user = c.user
        user_proj_app_q = dict(user_id=user._id,
                               project_id=self.app_config.project_id,
                               app_config_id=self.app_config._id)
        art_subscribed = Mailbox.subscribed(artifact=self, **user_proj_app_q)
        if art_subscribed:
            return True
        if include_parents:
            tool_subscribed = Mailbox.subscribed(**user_proj_app_q)
            if tool_subscribed:
                return True
        return False

    def primary(self):
        """If an artifact is a "secondary" artifact (discussion of a ticket, for
        instance), return the artifact that is the "primary".

        """
        return self

    @classmethod
    def artifacts_labeled_with(cls, label, app_config):
        """Return all artifacts of type ``cls`` that have the label ``label`` and
        are in the tool denoted by ``app_config``.

        :param label: str
        :param app_config: :class:`allura.model.project.AppConfig` instance

        """
        return cls.query.find({
            'labels': label,
            'app_config_id': app_config._id
        })

    def email_link(self, subject='artifact'):
        """Return a 'mailto' URL for this Artifact, with optional subject.

        """
        if subject:
            return 'mailto:%s?subject=[%s:%s:%s] Re: %s' % (
                self.email_address, self.app_config.project.shortname,
                self.app_config.options.mount_point, self.shorthand_id(),
                subject)
        else:
            return 'mailto:%s' % self.email_address

    @property
    def email_domain(self):
        """Return domain part of email address for this Artifact"""
        url = self.app.url[1:-1].split('/')
        return '.'.join(reversed(url)).replace('_', '-')

    @property
    def project(self):
        """Return the :class:`allura.model.project.Project` instance to which
        this Artifact belongs.

        """
        return getattr(self.app_config, 'project', None)

    @property
    def project_id(self):
        """Return the ``_id`` of the :class:`allura.model.project.Project`
        instance to which this Artifact belongs.

        """
        return self.app_config.project_id

    @LazyProperty
    def app(self):
        """Return the :class:`allura.model.app.Application` instance to which
        this Artifact belongs.

        """
        if not self.app_config:
            return None
        if getattr(c, 'app', None) and c.app.config._id == self.app_config._id:
            return c.app
        else:
            return self.app_config.load()(self.project, self.app_config)

    def index(self):
        project = self.project
        return dict(id=self.index_id(),
                    mod_date_dt=self.mod_date,
                    title='Artifact %s' % self._id,
                    project_id_s=str(project._id),
                    project_name_t=project.name,
                    project_shortname_t=project.shortname,
                    tool_name_s=self.app_config.tool_name,
                    mount_point_s=self.app_config.options.mount_point,
                    is_history_b=False,
                    url_s=self.url(),
                    type_s=self.type_s,
                    labels_t=' '.join(l for l in self.labels),
                    snippet_s='',
                    deleted_b=self.deleted)

    @property
    def type_name(self):
        """
        :return: a presentation name for this type of artifact
        :rtype: str
        """
        return self.type_s.lower()

    def url(self):
        """Return the URL for this Artifact.

        Subclasses must implement this.

        """
        raise NotImplementedError('url')  # pragma no cover

    def shorthand_id(self):
        """How to refer to this artifact within the app instance context.

        For a wiki page, it might be the title.  For a ticket, it might be the
        ticket number.  For a discussion, it might be the message ID.  Generally
        this should have a strong correlation to the URL.

        """
        return str(self._id)  # pragma no cover

    def link_text(self):
        """Return the link text to use when a shortlink to this artifact
        is expanded into an <a></a> tag.

        By default this method returns :attr:`type_s` + :meth:`shorthand_id`. Subclasses should
        override this method to provide more descriptive link text.

        """
        return self.shorthand_id()

    def get_discussion_thread(self, data=None):
        """Return the discussion thread and parent_id for this artifact.

        :return: (:class:`allura.model.discuss.Thread`, parent_thread_id (int))

        """
        from .discuss import Thread
        threads = Thread.query.find(dict(ref_id=self.index_id())).all()
        if not threads:
            idx = self.index()
            t = Thread.new(app_config_id=self.app_config_id,
                           discussion_id=self.app_config.discussion_id,
                           ref_id=idx['id'],
                           subject='%s discussion' % h.get_first(idx, 'title'))
        elif len(threads) == 1:
            t = threads[0]
        else:
            # there should not be multiple threads, we'll merge them
            destination = threads.pop()
            for thread in threads:
                for post in thread.posts:
                    post.thread_id = destination._id
                    destination.num_replies += 1
                    destination.last_post_date = max(
                        destination.last_post_date, post.mod_date)
                    session(post).flush(post)
                    session(post).expunge(
                        post
                    )  # so thread.posts ref later in the code doesn't use stale posts
                Thread.query.remove({
                    '_id': thread._id
                })  # NOT thread.delete() since that would remove its posts too
                thread.attachment_class().query.update(
                    {'thread_id': thread._id},
                    {'$set': {
                        'thread_id': destination._id
                    }},
                    multi=True)
            t = destination

        parent_id = None
        if data:
            in_reply_to = data.get('in_reply_to', [])
            if in_reply_to:
                parent_id = in_reply_to[0]

        return t, parent_id

    @LazyProperty
    def discussion_thread(self):
        """Return the :class:`discussion thread <allura.model.discuss.Thread>`
        for this Artifact.

        """
        return self.get_discussion_thread()[0]

    def add_multiple_attachments(self, file_info):
        if not isinstance(file_info, list):
            file_info = [file_info]
        for attach in file_info:
            if hasattr(attach, 'file'):
                self.attach(attach.filename,
                            attach.file,
                            content_type=attach.type)

    def attach(self, filename, fp, **kw):
        """Attach a file to this Artifact.

        :param filename: file name
        :param fp: a file-like object (implements ``read()``)
        :param \*\*kw: passed through to Attachment class constructor

        """
        att = self.attachment_class().save_attachment(filename=filename,
                                                      fp=fp,
                                                      artifact_id=self._id,
                                                      **kw)
        return att

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

    def delete(self):
        """Delete this Artifact.

        """
        ArtifactReference.query.remove(dict(_id=self.index_id()))
        super(Artifact, self).delete()

    def get_mail_footer(self, notification, toaddr):
        allow_email_posting = self.app.config.options.get(
            'AllowEmailPosting', True)
        return MailFooter.standard(notification, allow_email_posting)

    def message_id(self):
        '''Persistent, email-friendly (Message-ID header) id of this artifact'''
        return h.gen_message_id(self._id)

    @classmethod
    def is_limit_exceeded(cls, app_config, user=None, count_by_user=None):
        """
        Returns True if any of artifact creation rate limits are exceeded,
        False otherwise
        """
        pkg = cls.__module__.split('.', 1)[0]
        opt = '{}.rate_limits'.format(pkg)

        def count_in_app():
            return cls.query.find(dict(app_config_id=app_config._id)).count()

        provider = plugin.ProjectRegistrationProvider.get()
        start = provider.registration_date(app_config.project)

        try:
            h.rate_limit(opt, count_in_app, start)
            if user and not user.is_anonymous() and count_by_user is not None:
                h.rate_limit(opt + '_per_user', count_by_user,
                             user.registration_date())
        except forge_exc.RatelimitError:
            return True
        return False
Beispiel #6
0
class Feed(MappedClass):
    """
    Used to generate rss/atom feeds.  This does not need to be extended;
    all feed items go into the same collection
    """
    class __mongometa__:
        session = project_orm_session
        name = str('artifact_feed')
        indexes = [
            'pubdate',
            ('artifact_ref.project_id', 'artifact_ref.mount_point'),
            (('ref_id', pymongo.ASCENDING), ('pubdate', pymongo.DESCENDING)),
            (('project_id', pymongo.ASCENDING),
             ('app_config_id', pymongo.ASCENDING), ('pubdate',
                                                    pymongo.DESCENDING)),
            # used in ext/user_profile/user_main.py for user feeds
            'author_link',
            # used in project feed
            (('project_id', pymongo.ASCENDING), ('pubdate', pymongo.DESCENDING)
             ),
        ]

    _id = FieldProperty(S.ObjectId)
    ref_id = ForeignIdProperty('ArtifactReference')
    neighborhood_id = ForeignIdProperty('Neighborhood')
    project_id = ForeignIdProperty('Project')
    app_config_id = ForeignIdProperty('AppConfig')
    tool_name = FieldProperty(str)
    title = FieldProperty(str)
    link = FieldProperty(str)
    pubdate = FieldProperty(datetime, if_missing=datetime.utcnow)
    description = FieldProperty(str)
    description_cache = FieldProperty(MarkdownCache)
    unique_id = FieldProperty(str, if_missing=lambda: h.nonce(40))
    author_name = FieldProperty(
        str,
        if_missing=lambda: c.user.get_pref('display_name')
        if hasattr(c, 'user') else None)
    author_link = FieldProperty(str,
                                if_missing=lambda: c.user.url()
                                if hasattr(c, 'user') else None)
    artifact_reference = FieldProperty(S.Deprecated)

    def clear_user_data(self):
        """ Redact author data """
        self.author_name = ""
        self.author_link = ""
        title_parts = self.title.partition(" modified by ")
        self.title = "".join(
            title_parts[0:2]) + ("<REDACTED>" if title_parts[2] else '')

    @classmethod
    def from_username(cls, username):
        return cls.query.find({'author_link': "/u/{}/".format(username)}).all()

    @classmethod
    def has_access(cls, artifact):
        # Enable only for development.
        # return True
        from allura import model as M
        anon = M.User.anonymous()
        if not security.has_access(artifact, 'read', user=anon):
            return False
        if not security.has_access(c.project, 'read', user=anon):
            return False
        return True

    @classmethod
    def post(cls,
             artifact,
             title=None,
             description=None,
             author=None,
             author_link=None,
             author_name=None,
             pubdate=None,
             link=None,
             **kw):
        """
        Create a Feed item.  Returns the item.
        But if anon doesn't have read access, create does not happen and None is
        returned.
        """
        if not Feed.has_access(artifact):
            return
        idx = artifact.index()
        if author is None:
            author = c.user
        if author_name is None:
            author_name = author.get_pref('display_name')
        if title is None:
            title = '%s modified by %s' % (h.get_first(idx,
                                                       'title'), author_name)
        if description is None:
            description = title
        if pubdate is None:
            pubdate = datetime.utcnow()
        if link is None:
            link = artifact.url()
        item = cls(ref_id=artifact.index_id(),
                   neighborhood_id=artifact.app_config.project.neighborhood_id,
                   project_id=artifact.app_config.project_id,
                   app_config_id=artifact.app_config_id,
                   tool_name=artifact.app_config.tool_name,
                   title=title,
                   description=g.markdown.convert(description),
                   link=link,
                   pubdate=pubdate,
                   author_name=author_name,
                   author_link=author_link or author.url())
        unique_id = kw.pop('unique_id', None)
        if unique_id:
            item.unique_id = unique_id
        return item

    @classmethod
    def feed(cls,
             q,
             feed_type,
             title,
             link,
             description,
             since=None,
             until=None,
             page=None,
             limit=None):
        "Produces feedgenerator Feed"
        d = dict(title=title,
                 link=h.absurl(h.urlquote(link)),
                 description=description,
                 language='en',
                 feed_url=request.url)
        if feed_type == 'atom':
            feed = FG.Atom1Feed(**d)
        elif feed_type == 'rss':
            feed = RssFeed(**d)
        limit, page = h.paging_sanitizer(limit or 10, page)
        query = defaultdict(dict)
        if callable(q):
            q = q(since, until, page, limit)
        query.update(q)
        if since is not None:
            query['pubdate']['$gte'] = since
        if until is not None:
            query['pubdate']['$lte'] = until
        cur = cls.query.find(query)
        cur = cur.sort('pubdate', pymongo.DESCENDING)
        cur = cur.limit(limit)
        cur = cur.skip(limit * page)
        for r in cur:
            feed.add_item(title=r.title,
                          link=h.absurl(h.urlquote_path_only(r.link)),
                          pubdate=r.pubdate,
                          description=r.description,
                          unique_id=h.absurl(r.unique_id),
                          author_name=r.author_name,
                          author_link=h.absurl(r.author_link))
        return feed
Beispiel #7
0
class ForumPostHistory(M.PostHistory):
    class __mongometa__:
        name = str('post_history')

    artifact_id = ForeignIdProperty('ForumPost')
Beispiel #8
0
class ForumThread(M.Thread):
    class __mongometa__:
        name = str('forum_thread')
        indexes = [
            'flags',
            'discussion_id',
            'import_id',  # may be used by external legacy systems
        ]

    type_s = 'Thread'

    discussion_id = ForeignIdProperty(Forum)
    first_post_id = ForeignIdProperty('ForumPost')
    flags = FieldProperty([str])

    discussion = RelationProperty(Forum)
    posts = RelationProperty('ForumPost', via='thread_id')
    first_post = RelationProperty('ForumPost', via='first_post_id')

    @property
    def type_name(self):
        return 'topic'

    @property
    def status(self):
        if len(self.posts) == 1:
            return self.posts[0].status
        else:
            return 'ok'

    @classmethod
    def attachment_class(cls):
        return ForumAttachment

    @property
    def email_address(self):
        return self.discussion.email_address

    def primary(self):
        return self

    def subscribed(self, user=None, include_parents=True):
        subbed = super(ForumThread,
                       self).subscribed(user=user,
                                        include_parents=include_parents)
        if subbed:
            return subbed
        if include_parents:
            if user is None:
                user = c.user
            forum = self.discussion
            forum_subscribed = M.Mailbox.subscribed(artifact=forum,
                                                    user_id=user._id)
            if forum_subscribed:
                return True
        return False

    def post(self, subject, text, message_id=None, parent_id=None, **kw):
        post = super(ForumThread, self).post(text,
                                             message_id=message_id,
                                             parent_id=parent_id,
                                             **kw)
        if not self.first_post_id:
            self.first_post_id = post._id
            self.num_replies = 1
        return post

    def set_forum(self, new_forum):
        self.post_class().query.update(
            dict(discussion_id=self.discussion_id, thread_id=self._id),
            {'$set': dict(discussion_id=new_forum._id)},
            multi=True)
        self.attachment_class().query.update(
            {
                'discussion_id': self.discussion_id,
                'thread_id': self._id
            }, {'$set': dict(discussion_id=new_forum._id)},
            multi=True)
        self.discussion_id = new_forum._id
Beispiel #9
0
class BlogPost(M.VersionedArtifact, ActivityObject):
    class __mongometa__:
        name = 'blog_post'
        history_class = BlogPostSnapshot
        unique_indexes = [('project_id', 'app_config_id', 'slug')]

    type_s = 'Blog Post'

    title = FieldProperty(str, if_missing='Untitled')
    text = FieldProperty(str, if_missing='')
    timestamp = FieldProperty(datetime, if_missing=datetime.utcnow)
    slug = FieldProperty(str)
    state = FieldProperty(schema.OneOf('draft', 'published'),
                          if_missing='draft')
    neighborhood_id = ForeignIdProperty('Neighborhood', if_missing=None)

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

    def author(self):
        '''The author of the first snapshot of this BlogPost'''
        return M.User.query.get(
            _id=self.get_version(1).author.id) or M.User.anonymous()

    def _get_date(self):
        return self.timestamp.date()

    def _set_date(self, value):
        self.timestamp = datetime.combine(value, self.time)

    date = property(_get_date, _set_date)

    def _get_time(self):
        return self.timestamp.time()

    def _set_time(self, value):
        self.timestamp = datetime.combine(self.date, value)

    time = property(_get_time, _set_time)

    @property
    def html_text(self):
        return g.markdown.convert(self.text)

    @property
    def html_text_preview(self):
        """Return an html preview of the BlogPost text.

        Truncation happens at paragraph boundaries to avoid chopping markdown
        in inappropriate places.

        If the entire post is one paragraph, the full text is returned.
        If the entire text is <= 400 chars, the full text is returned.
        Else, at least 400 chars are returned, rounding up to the nearest
        whole paragraph.

        If truncation occurs, a hyperlink to the full text is appended.

        """
        # Splitting on spaces or single lines breaks isn't sufficient as some
        # markup can span spaces and single line breaks. Converting to HTML
        # first and *then* truncating doesn't work either, because the
        # ellipsis tag ends up orphaned from the main text.
        ellipsis = '... [read more](%s)' % self.url()
        paragraphs = self.text.replace('\r', '').split('\n\n')
        total_length = 0
        for i, p in enumerate(paragraphs):
            total_length += len(p)
            if total_length >= 400:
                break
        text = '\n\n'.join(paragraphs[:i + 1])
        return g.markdown.convert(
            text + (ellipsis if i + 1 < len(paragraphs) else ''))

    @property
    def email_address(self):
        domain = '.'.join(reversed(self.app.url[1:-1].split('/'))).replace(
            '_', '-')
        return '%s@%s%s' % (self.title.replace(
            '/', '.'), domain, config.common_suffix)

    @staticmethod
    def make_base_slug(title, timestamp):
        slugsafe = ''.join(ch.lower() for ch in title.replace(' ', '-')
                           if ch.isalnum() or ch == '-')
        return '%s/%s' % (timestamp.strftime('%Y/%m'), slugsafe)

    def make_slug(self):
        base = BlogPost.make_base_slug(self.title, self.timestamp)
        self.slug = base
        while True:
            try:
                session(self).insert_now(self, state(self))
                return self.slug
            except DuplicateKeyError:
                self.slug = base + '-%.3d' % randint(0, 999)

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

    def shorthand_id(self):
        return self.slug

    def index(self):
        result = super(BlogPost, self).index()
        result.update(title_s=self.slug,
                      type_s=self.type_s,
                      state_s=self.state,
                      snippet_s='%s: %s' %
                      (self.title, h.text.truncate(self.text, 200)),
                      text=self.text)
        return result

    def get_version(self, version):
        HC = self.__mongometa__.history_class
        return HC.query.find({
            'artifact_id': self._id,
            'version': int(version)
        }).one()

    def commit(self):
        self.subscribe()
        super(BlogPost, self).commit()
        if self.version > 1:
            v1 = self.get_version(self.version - 1)
            v2 = self
            la = [line + '\n' for line in v1.text.splitlines()]
            lb = [line + '\n' for line in v2.text.splitlines()]
            diff = ''.join(
                difflib.unified_diff(la, lb, 'v%d' % v1.version,
                                     'v%d' % v2.version))
            description = diff
            if v1.state != 'published' and v2.state == 'published':
                M.Feed.post(self, self.title, self.text, author=self.author())
                description = self.text
                subject = '%s created post %s' % (c.user.username, self.title)
            elif v1.title != v2.title:
                subject = '%s renamed post %s to %s' % (c.user.username,
                                                        v2.title, v1.title)
            else:
                subject = '%s modified post %s' % (c.user.username, self.title)
        else:
            description = self.text
            subject = '%s created post %s' % (c.user.username, self.title)
            if self.state == 'published':
                M.Feed.post(self, self.title, self.text, author=self.author())
        if self.state == 'published':
            M.Notification.post(artifact=self,
                                topic='metadata',
                                text=description,
                                subject=subject)
Beispiel #10
0
class Notification(MappedClass):
    '''
    Temporarily store notifications that will be emailed or displayed as a web flash.
    This does not contain any recipient information.
    '''
    class __mongometa__:
        session = main_orm_session
        name = 'notification'
        indexes = ['project_id']

    _id = FieldProperty(str, if_missing=h.gen_message_id)

    # Classify notifications
    neighborhood_id = ForeignIdProperty(
        'Neighborhood', if_missing=lambda: c.project.neighborhood._id)
    project_id = ForeignIdProperty('Project', if_missing=lambda: c.project._id)
    app_config_id = ForeignIdProperty('AppConfig',
                                      if_missing=lambda: c.app.config._id)
    tool_name = FieldProperty(str, if_missing=lambda: c.app.config.tool_name)
    ref_id = ForeignIdProperty('ArtifactReference')
    topic = FieldProperty(str)

    # Notification Content
    in_reply_to = FieldProperty(str)
    references = FieldProperty([str])
    from_address = FieldProperty(str)
    reply_to_address = FieldProperty(str)
    subject = FieldProperty(str)
    text = FieldProperty(str)
    link = FieldProperty(str)
    author_id = AlluraUserProperty()
    feed_meta = FieldProperty(S.Deprecated)
    artifact_reference = FieldProperty(S.Deprecated)
    pubdate = FieldProperty(datetime, if_missing=datetime.utcnow)

    ref = RelationProperty('ArtifactReference')

    view = jinja2.Environment(
        loader=jinja2.PackageLoader('allura', 'templates'),
        auto_reload=asbool(config.get('auto_reload_templates', True)),
    )

    @classmethod
    def post(cls, artifact, topic, **kw):
        '''Create a notification and  send the notify message'''
        n = cls._make_notification(artifact, topic, **kw)
        if n:
            # make sure notification is flushed in time for task to process it
            session(n).flush(n)
            n.fire_notification_task(artifact, topic)
        return n

    def fire_notification_task(self, artifact, topic):
        import allura.tasks.notification_tasks
        allura.tasks.notification_tasks.notify.post(self._id,
                                                    artifact.index_id(), topic)

    @classmethod
    def post_user(cls, user, artifact, topic, **kw):
        '''Create a notification and deliver directly to a user's flash
    mailbox'''
        try:
            mbox = Mailbox(user_id=user._id,
                           is_flash=True,
                           project_id=None,
                           app_config_id=None)
            session(mbox).flush(mbox)
        except pymongo.errors.DuplicateKeyError:
            session(mbox).expunge(mbox)
            mbox = Mailbox.query.get(user_id=user._id, is_flash=True)
        n = cls._make_notification(artifact, topic, **kw)
        if n:
            mbox.queue.append(n._id)
            mbox.queue_empty = False
        return n

    @classmethod
    def _make_notification(cls, artifact, topic, **kwargs):
        '''
        Create a Notification instance based on an artifact.  Special handling
        for comments when topic=='message'
        '''

        from allura.model import Project
        idx = artifact.index() if artifact else None
        subject_prefix = '[%s:%s] ' % (c.project.shortname,
                                       c.app.config.options.mount_point)
        post = ''
        if topic == 'message':
            post = kwargs.pop('post')
            text = kwargs.get('text') or post.text
            file_info = kwargs.pop('file_info', None)
            if file_info is not None:
                text = "%s\n\n\nAttachments:\n" % text
                if not isinstance(file_info, list):
                    file_info = [file_info]
                for attach in file_info:
                    attach.file.seek(0, 2)
                    bytecount = attach.file.tell()
                    attach.file.seek(0)
                    url = h.absurl('{}attachment/{}'.format(
                        post.url(), h.urlquote(attach.filename)))
                    text = "%s\n- [%s](%s) (%s; %s)" % (
                        text, attach.filename, url,
                        h.do_filesizeformat(bytecount), attach.type)

            subject = post.subject or ''
            if post.parent_id and not subject.lower().startswith('re:'):
                subject = 'Re: ' + subject
            author = post.author()
            msg_id = kwargs.get('message_id') or artifact.url() + post._id
            parent_msg_id = artifact.url() + \
                post.parent_id if post.parent_id else artifact.message_id()
            d = dict(_id=msg_id,
                     from_address=str(author._id)
                     if author != User.anonymous() else None,
                     reply_to_address='"%s" <%s>' %
                     (subject_prefix,
                      getattr(artifact, 'email_address', g.noreply)),
                     subject=subject_prefix + subject,
                     text=text,
                     in_reply_to=parent_msg_id,
                     references=cls._references(artifact, post),
                     author_id=author._id,
                     pubdate=datetime.utcnow())
        elif topic == 'flash':
            n = cls(topic=topic,
                    text=kwargs['text'],
                    subject=kwargs.pop('subject', ''))
            return n
        else:
            subject = kwargs.pop(
                'subject', '%s modified by %s' %
                (h.get_first(idx, 'title'), c.user.get_pref('display_name')))
            reply_to = '"%s" <%s>' % (h.get_first(
                idx, 'title'), getattr(artifact, 'email_address', g.noreply))
            d = dict(from_address=reply_to,
                     reply_to_address=reply_to,
                     subject=subject_prefix + subject,
                     text=kwargs.pop('text', subject),
                     author_id=c.user._id,
                     pubdate=datetime.utcnow())
            if kwargs.get('message_id'):
                d['_id'] = kwargs['message_id']
            if c.user.get_pref('email_address'):
                d['from_address'] = '"%s" <%s>' % (c.user.get_pref(
                    'display_name'), c.user.get_pref('email_address'))
            elif c.user.email_addresses:
                d['from_address'] = '"%s" <%s>' % (
                    c.user.get_pref('display_name'), c.user.email_addresses[0])
        if not d.get('text'):
            d['text'] = ''
        try:
            ''' Add addional text to the notification e-mail based on the artifact type '''
            template = cls.view.get_template('mail/' + artifact.type_s +
                                             '.txt')
            d['text'] += template.render(
                dict(c=c, g=g, config=config, data=artifact, post=post, h=h))
        except jinja2.TemplateNotFound:
            pass
        except:
            ''' Catch any errors loading or rendering the template,
            but the notification still gets sent if there is an error
            '''
            log.warn('Could not render notification template %s' %
                     artifact.type_s,
                     exc_info=True)

        assert d['reply_to_address'] is not None
        project = c.project
        if d.get('project_id', c.project._id) != c.project._id:
            project = Project.query.get(_id=d['project_id'])
        if project.notifications_disabled:
            log.debug(
                'Notifications disabled for project %s, not sending %s(%r)',
                project.shortname, topic, artifact)
            return None
        n = cls(ref_id=artifact.index_id(),
                topic=topic,
                link=kwargs.pop('link', artifact.url()),
                **d)
        return n

    def footer(self, toaddr=''):
        return self.ref.artifact.get_mail_footer(self, toaddr)

    def _sender(self):
        from allura.model import AppConfig
        app_config = AppConfig.query.get(_id=self.app_config_id)
        app = app_config.project.app_instance(app_config)
        return app.email_address if app else None

    @classmethod
    def _references(cls, artifact, post):
        msg_ids = []
        while post and post.parent_id:
            msg_ids.append(artifact.url() + post.parent_id)
            post = post.parent
        msg_ids.append(artifact.message_id())
        msg_ids.reverse()
        return msg_ids

    def send_simple(self, toaddr):
        allura.tasks.mail_tasks.sendsimplemail.post(
            toaddr=toaddr,
            fromaddr=self.from_address,
            reply_to=self.reply_to_address,
            subject=self.subject,
            sender=self._sender(),
            message_id=self._id,
            in_reply_to=self.in_reply_to,
            references=self.references,
            text=(self.text or '') + self.footer(toaddr))

    def send_direct(self, user_id):
        user = User.query.get(_id=ObjectId(user_id),
                              disabled=False,
                              pending=False)
        artifact = self.ref.artifact
        log.debug('Sending direct notification %s to user %s', self._id,
                  user_id)
        # Don't send if user disabled
        if not user:
            log.debug("Skipping notification - enabled user %s not found" %
                      user_id)
            return
        # Don't send if user doesn't have read perms to the artifact
        if user and artifact and \
                not security.has_access(artifact, 'read', user)():
            log.debug("Skipping notification - User %s doesn't have read "
                      "access to artifact %s" % (user_id, str(self.ref_id)))
            log.debug(
                "User roles [%s]; artifact ACL [%s]; PSC ACL [%s]", ', '.join([
                    str(r) for r in security.Credentials.get().user_roles(
                        user_id=user_id,
                        project_id=artifact.project._id).reaching_ids
                ]), ', '.join([str(a) for a in artifact.acl]), ', '.join(
                    [str(a) for a in artifact.parent_security_context().acl]))
            return
        allura.tasks.mail_tasks.sendmail.post(destinations=[str(user_id)],
                                              fromaddr=self.from_address,
                                              reply_to=self.reply_to_address,
                                              subject=self.subject,
                                              message_id=self._id,
                                              in_reply_to=self.in_reply_to,
                                              references=self.references,
                                              sender=self._sender(),
                                              text=(self.text or '') +
                                              self.footer())

    @classmethod
    def send_digest(self,
                    user_id,
                    from_address,
                    subject,
                    notifications,
                    reply_to_address=None):
        if not notifications:
            return
        user = User.query.get(_id=ObjectId(user_id),
                              disabled=False,
                              pending=False)
        if not user:
            log.debug("Skipping notification - enabled user %s not found " %
                      user_id)
            return
        # Filter out notifications for which the user doesn't have read
        # permissions to the artifact.
        artifact = self.ref.artifact

        def perm_check(notification):
            return not (user and artifact) or \
                security.has_access(artifact, 'read', user)()

        notifications = filter(perm_check, notifications)

        log.debug('Sending digest of notifications [%s] to user %s',
                  ', '.join([n._id for n in notifications]), user_id)
        if reply_to_address is None:
            reply_to_address = from_address
        text = ['Digest of %s' % subject]
        for n in notifications:
            text.append('From: %s' % n.from_address)
            text.append('Subject: %s' % (n.subject or '(no subject)'))
            text.append('Message-ID: %s' % n._id)
            text.append('')
            text.append(n.text or '-no text-')
        text.append(n.footer())
        text = '\n'.join(text)
        allura.tasks.mail_tasks.sendmail.post(destinations=[str(user_id)],
                                              fromaddr=from_address,
                                              reply_to=reply_to_address,
                                              subject=subject,
                                              message_id=h.gen_message_id(),
                                              text=text)

    @classmethod
    def send_summary(self, user_id, from_address, subject, notifications):
        if not notifications:
            return
        log.debug('Sending summary of notifications [%s] to user %s',
                  ', '.join([n._id for n in notifications]), user_id)
        text = ['Digest of %s' % subject]
        for n in notifications:
            text.append('From: %s' % n.from_address)
            text.append('Subject: %s' % (n.subject or '(no subject)'))
            text.append('Message-ID: %s' % n._id)
            text.append('')
            text.append(h.text.truncate(n.text or '-no text-', 128))
        text.append(n.footer())
        text = '\n'.join(text)
        allura.tasks.mail_tasks.sendmail.post(destinations=[str(user_id)],
                                              fromaddr=from_address,
                                              reply_to=from_address,
                                              subject=subject,
                                              message_id=h.gen_message_id(),
                                              text=text)
Beispiel #11
0
class Mailbox(MappedClass):
    '''
    Holds a queue of notifications for an artifact, or a user (webflash messages)
    for a subscriber.
    FIXME: describe the Mailbox concept better.
    '''
    class __mongometa__:
        session = main_orm_session
        name = 'mailbox'
        unique_indexes = [
            ('user_id', 'project_id', 'app_config_id', 'artifact_index_id',
             'topic', 'is_flash'),
        ]
        indexes = [
            ('project_id', 'artifact_index_id'),
            ('is_flash', 'user_id'),
            ('type', 'next_scheduled'),  # for q_digest
            ('type', 'queue_empty'),  # for q_direct
            # for deliver()
            ('project_id', 'app_config_id', 'artifact_index_id', 'topic'),
        ]

    _id = FieldProperty(S.ObjectId)
    user_id = AlluraUserProperty(if_missing=lambda: c.user._id)
    project_id = ForeignIdProperty('Project', if_missing=lambda: c.project._id)
    app_config_id = ForeignIdProperty('AppConfig',
                                      if_missing=lambda: c.app.config._id)

    # Subscription filters
    artifact_title = FieldProperty(str)
    artifact_url = FieldProperty(str)
    artifact_index_id = FieldProperty(str)
    topic = FieldProperty(str)

    # Subscription type
    is_flash = FieldProperty(bool, if_missing=False)
    type = FieldProperty(S.OneOf('direct', 'digest', 'summary', 'flash'))
    frequency = FieldProperty(dict(n=int, unit=S.OneOf('day', 'week',
                                                       'month')))
    next_scheduled = FieldProperty(datetime, if_missing=datetime.utcnow)
    last_modified = FieldProperty(datetime, if_missing=datetime(2000, 1, 1))

    # a list of notification _id values
    queue = FieldProperty([str])
    queue_empty = FieldProperty(bool)

    project = RelationProperty('Project')
    app_config = RelationProperty('AppConfig')

    @classmethod
    def subscribe(cls,
                  user_id=None,
                  project_id=None,
                  app_config_id=None,
                  artifact=None,
                  topic=None,
                  type='direct',
                  n=1,
                  unit='day'):
        if user_id is None:
            user_id = c.user._id
        if project_id is None:
            project_id = c.project._id
        if app_config_id is None:
            app_config_id = c.app.config._id
        tool_already_subscribed = cls.query.get(user_id=user_id,
                                                project_id=project_id,
                                                app_config_id=app_config_id,
                                                artifact_index_id=None)
        if tool_already_subscribed:
            return
        if artifact is None:
            artifact_title = 'All artifacts'
            artifact_url = None
            artifact_index_id = None
        else:
            i = artifact.index()
            artifact_title = h.get_first(i, 'title')
            artifact_url = artifact.url()
            artifact_index_id = i['id']
            artifact_already_subscribed = cls.query.get(
                user_id=user_id,
                project_id=project_id,
                app_config_id=app_config_id,
                artifact_index_id=artifact_index_id)
            if artifact_already_subscribed:
                return
        d = dict(user_id=user_id,
                 project_id=project_id,
                 app_config_id=app_config_id,
                 artifact_index_id=artifact_index_id,
                 topic=topic)
        sess = session(cls)
        try:
            mbox = cls(type=type,
                       frequency=dict(n=n, unit=unit),
                       artifact_title=artifact_title,
                       artifact_url=artifact_url,
                       **d)
            sess.flush(mbox)
        except pymongo.errors.DuplicateKeyError:
            sess.expunge(mbox)
            mbox = cls.query.get(**d)
            mbox.artifact_title = artifact_title
            mbox.artifact_url = artifact_url
            mbox.type = type
            mbox.frequency.n = n
            mbox.frequency.unit = unit
            sess.flush(mbox)
        if not artifact_index_id:
            # Unsubscribe from individual artifacts when subscribing to the
            # tool
            for other_mbox in cls.query.find(
                    dict(user_id=user_id,
                         project_id=project_id,
                         app_config_id=app_config_id)):
                if other_mbox is not mbox:
                    other_mbox.delete()

    @classmethod
    def unsubscribe(cls,
                    user_id=None,
                    project_id=None,
                    app_config_id=None,
                    artifact_index_id=None,
                    topic=None):
        if user_id is None:
            user_id = c.user._id
        if project_id is None:
            project_id = c.project._id
        if app_config_id is None:
            app_config_id = c.app.config._id
        cls.query.remove(
            dict(user_id=user_id,
                 project_id=project_id,
                 app_config_id=app_config_id,
                 artifact_index_id=artifact_index_id,
                 topic=topic))

    @classmethod
    def subscribed(cls,
                   user_id=None,
                   project_id=None,
                   app_config_id=None,
                   artifact=None,
                   topic=None):
        if user_id is None:
            user_id = c.user._id
        if project_id is None:
            project_id = c.project._id
        if app_config_id is None:
            app_config_id = c.app.config._id
        if artifact is None:
            artifact_index_id = None
        else:
            i = artifact.index()
            artifact_index_id = i['id']
        return cls.query.find(
            dict(user_id=user_id,
                 project_id=project_id,
                 app_config_id=app_config_id,
                 artifact_index_id=artifact_index_id)).count() != 0

    @classmethod
    def deliver(cls, nid, artifact_index_id, topic):
        '''Called in the notification message handler to deliver notification IDs
        to the appropriate mailboxes.  Atomically appends the nids
        to the appropriate mailboxes.
        '''
        d = {
            'project_id': c.project._id,
            'app_config_id': c.app.config._id,
            'artifact_index_id': {
                '$in': [None, artifact_index_id]
            },
            'topic': {
                '$in': [None, topic]
            }
        }
        mboxes = cls.query.find(d).all()
        log.debug('Delivering notification %s to mailboxes [%s]', nid,
                  ', '.join([str(m._id) for m in mboxes]))
        for mbox in mboxes:
            try:
                mbox.query.update({
                    '$push':
                    dict(queue=nid),
                    '$set':
                    dict(last_modified=datetime.utcnow(), queue_empty=False),
                })
                # Make sure the mbox doesn't stick around to be flush()ed
                session(mbox).expunge(mbox)
            except:
                # log error but try to keep processing, lest all the other eligible
                # mboxes for this notification get skipped and lost forever
                log.exception(
                    'Error adding notification: %s for artifact %s on project %s to user %s',
                    nid, artifact_index_id, c.project._id, mbox.user_id)

    @classmethod
    def fire_ready(cls):
        '''Fires all direct subscriptions with notifications as well as
        all summary & digest subscriptions with notifications that are ready.
        Clears the mailbox queue.
        '''
        now = datetime.utcnow()
        # Queries to find all matching subscription objects
        q_direct = dict(
            type='direct',
            queue_empty=False,
        )
        if MAILBOX_QUIESCENT:
            q_direct['last_modified'] = {'$lt': now - MAILBOX_QUIESCENT}
        q_digest = dict(type={'$in': ['digest', 'summary']},
                        next_scheduled={'$lt': now})

        def find_and_modify_direct_mbox():
            return cls.query.find_and_modify(
                query=q_direct,
                update={'$set': dict(
                    queue=[],
                    queue_empty=True,
                )},
                new=False)

        for mbox in take_while_true(find_and_modify_direct_mbox):
            try:
                mbox.fire(now)
            except:
                log.exception('Error firing mbox: %s with queue: [%s]',
                              str(mbox._id), ', '.join(mbox.queue))
                # re-raise so we don't keep (destructively) trying to process
                # mboxes
                raise

        for mbox in cls.query.find(q_digest):
            next_scheduled = now
            if mbox.frequency.unit == 'day':
                next_scheduled += timedelta(days=mbox.frequency.n)
            elif mbox.frequency.unit == 'week':
                next_scheduled += timedelta(days=7 * mbox.frequency.n)
            elif mbox.frequency.unit == 'month':
                next_scheduled += timedelta(days=30 * mbox.frequency.n)
            mbox = cls.query.find_and_modify(
                query=dict(_id=mbox._id),
                update={
                    '$set':
                    dict(
                        next_scheduled=next_scheduled,
                        queue=[],
                        queue_empty=True,
                    )
                },
                new=False)
            mbox.fire(now)

    def fire(self, now):
        '''
        Send all notifications that this mailbox has enqueued.
        '''
        notifications = Notification.query.find(dict(_id={'$in': self.queue}))
        notifications = notifications.all()
        if len(notifications) != len(self.queue):
            log.error(
                'Mailbox queue error: Mailbox %s queued [%s], found [%s]',
                str(self._id), ', '.join(self.queue),
                ', '.join([n._id for n in notifications]))
        else:
            log.debug('Firing mailbox %s notifications [%s], found [%s]',
                      str(self._id), ', '.join(self.queue),
                      ', '.join([n._id for n in notifications]))
        if self.type == 'direct':
            ngroups = defaultdict(list)
            for n in notifications:
                try:
                    if n.topic == 'message':
                        n.send_direct(self.user_id)
                        # Messages must be sent individually so they can be replied
                        # to individually
                    else:
                        key = (n.subject, n.from_address, n.reply_to_address,
                               n.author_id)
                        ngroups[key].append(n)
                except:
                    # log error but keep trying to deliver other notifications,
                    # lest the other notifications (which have already been removed
                    # from the mobx's queue in mongo) be lost
                    log.exception(
                        'Error sending notification: %s to mbox %s (user %s)',
                        n._id, self._id, self.user_id)
            # Accumulate messages from same address with same subject
            for (subject, from_address, reply_to_address,
                 author_id), ns in ngroups.iteritems():
                try:
                    if len(ns) == 1:
                        ns[0].send_direct(self.user_id)
                    else:
                        Notification.send_digest(self.user_id, from_address,
                                                 subject, ns, reply_to_address)
                except:
                    # log error but keep trying to deliver other notifications,
                    # lest the other notifications (which have already been removed
                    # from the mobx's queue in mongo) be lost
                    log.exception(
                        'Error sending notifications: [%s] to mbox %s (user %s)',
                        ', '.join([n._id for n in ns]), self._id, self.user_id)
        elif self.type == 'digest':
            Notification.send_digest(self.user_id, g.noreply, 'Digest Email',
                                     notifications)
        elif self.type == 'summary':
            Notification.send_summary(self.user_id, g.noreply, 'Digest Email',
                                      notifications)