You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by he...@apache.org on 2015/05/29 22:40:42 UTC

[20/45] allura git commit: [#7878] Used 2to3 to see what issues would come up

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/model/monq_model.py
----------------------------------------------------------------------
diff --git a/model/monq_model.py b/model/monq_model.py
new file mode 100644
index 0000000..4aa49c1
--- /dev/null
+++ b/model/monq_model.py
@@ -0,0 +1,297 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import sys
+import time
+import traceback
+import logging
+from datetime import datetime, timedelta
+
+import pymongo
+from pylons import tmpl_context as c, app_globals as g
+from tg import config
+from paste.deploy.converters import asbool
+
+import ming
+from ming.utils import LazyProperty
+from ming import schema as S
+from ming.orm import session, FieldProperty
+from ming.orm.declarative import MappedClass
+
+from allura.lib.helpers import log_output, null_contextmanager
+from .session import task_orm_session
+
+log = logging.getLogger(__name__)
+
+
+class MonQTask(MappedClass):
+
+    '''Task to be executed by the taskd daemon.
+
+    Properties
+
+        - _id - bson.ObjectId() for this task
+        - state - 'ready', 'busy', 'error', 'complete', or 'skipped' task status
+        - priority - integer priority, higher is more priority
+        - result_type - either 'keep' or 'forget', what to do with the task when
+          it's done
+        - time_queue - time the task was queued
+        - time_start - time taskd began working on the task
+        - time_stop - time taskd stopped working on the task
+        - task_name - full dotted name of the task function to run
+        - process - identifier for which taskd process is working on the task
+        - context - values used to set c.project, c.app, c.user for the task
+        - args - ``*args`` to be sent to the task function
+        - kwargs - ``**kwargs`` to be sent to the task function
+        - result - if the task is complete, the return value. If in error, the traceback.
+    '''
+    states = ('ready', 'busy', 'error', 'complete', 'skipped')
+    result_types = ('keep', 'forget')
+
+    class __mongometa__:
+        session = task_orm_session
+        name = 'monq_task'
+        indexes = [
+            [
+                # used in MonQTask.get() method
+                # also 'state' queries exist in several other methods
+                ('state', ming.ASCENDING),
+                ('priority', ming.DESCENDING),
+                ('time_queue', ming.ASCENDING)
+            ],
+            [
+                # used by repo tarball status check, etc
+                'state', 'task_name', 'time_queue'
+            ],
+        ]
+
+    _id = FieldProperty(S.ObjectId)
+    state = FieldProperty(S.OneOf(*states))
+    priority = FieldProperty(int)
+    result_type = FieldProperty(S.OneOf(*result_types))
+    time_queue = FieldProperty(datetime, if_missing=datetime.utcnow)
+    time_start = FieldProperty(datetime, if_missing=None)
+    time_stop = FieldProperty(datetime, if_missing=None)
+
+    task_name = FieldProperty(str)
+    process = FieldProperty(str)
+    context = FieldProperty(dict(
+        project_id=S.ObjectId,
+        app_config_id=S.ObjectId,
+        user_id=S.ObjectId,
+        notifications_disabled=bool))
+    args = FieldProperty([])
+    kwargs = FieldProperty({None: None})
+    result = FieldProperty(None, if_missing=None)
+
+    def __repr__(self):
+        from allura import model as M
+        project = M.Project.query.get(_id=self.context.project_id)
+        app = None
+        if project:
+            app_config = M.AppConfig.query.get(_id=self.context.app_config_id)
+            if app_config:
+                app = project.app_instance(app_config)
+        user = M.User.query.get(_id=self.context.user_id)
+        project_url = project and project.url() or None
+        app_mount = app and app.config.options.mount_point or None
+        username = user and user.username or None
+        return '<%s %s (%s) P:%d %s %s project:%s app:%s user:%s>' % (
+            self.__class__.__name__,
+            self._id,
+            self.state,
+            self.priority,
+            self.task_name,
+            self.process,
+            project_url,
+            app_mount,
+            username)
+
+    @LazyProperty
+    def function(self):
+        '''The function that is called by this task'''
+        smod, sfunc = self.task_name.rsplit('.', 1)
+        cur = __import__(smod, fromlist=[sfunc])
+        return getattr(cur, sfunc)
+
+    @classmethod
+    def post(cls,
+             function,
+             args=None,
+             kwargs=None,
+             result_type='forget',
+             priority=10,
+             delay=0):
+        '''Create a new task object based on the current context.'''
+        if args is None:
+            args = ()
+        if kwargs is None:
+            kwargs = {}
+        task_name = '%s.%s' % (
+            function.__module__,
+            function.__name__)
+        context = dict(
+            project_id=None,
+            app_config_id=None,
+            user_id=None,
+            notifications_disabled=False)
+        if getattr(c, 'project', None):
+            context['project_id'] = c.project._id
+            context[
+                'notifications_disabled'] = c.project.notifications_disabled
+        if getattr(c, 'app', None):
+            context['app_config_id'] = c.app.config._id
+        if getattr(c, 'user', None):
+            context['user_id'] = c.user._id
+        obj = cls(
+            state='ready',
+            priority=priority,
+            result_type=result_type,
+            task_name=task_name,
+            args=args,
+            kwargs=kwargs,
+            process=None,
+            result=None,
+            context=context,
+            time_queue=datetime.utcnow() + timedelta(seconds=delay))
+        session(obj).flush(obj)
+        return obj
+
+    @classmethod
+    def get(cls, process='worker', state='ready', waitfunc=None, only=None):
+        '''Get the highest-priority, oldest, ready task and lock it to the
+        current process.  If no task is available and waitfunc is supplied, call
+        the waitfunc before trying to get the task again.  If waitfunc is None
+        and no tasks are available, return None.  If waitfunc raises a
+        StopIteration, stop waiting for a task
+        '''
+        sort = [
+            ('priority', ming.DESCENDING),
+            ('time_queue', ming.ASCENDING)]
+        while True:
+            try:
+                query = dict(state=state)
+                query['time_queue'] = {'$lte': datetime.utcnow()}
+                if only:
+                    query['task_name'] = {'$in': only}
+                obj = cls.query.find_and_modify(
+                    query=query,
+                    update={
+                        '$set': dict(
+                            state='busy',
+                            process=process)
+                    },
+                    new=True,
+                    sort=sort)
+                if obj is not None:
+                    return obj
+            except pymongo.errors.OperationFailure as exc:
+                if 'No matching object found' not in exc.args[0]:
+                    raise
+            if waitfunc is None:
+                return None
+            try:
+                waitfunc()
+            except StopIteration:
+                return None
+
+    @classmethod
+    def timeout_tasks(cls, older_than):
+        '''Mark all busy tasks older than a certain datetime as 'ready' again.
+        Used to retry 'stuck' tasks.'''
+        spec = dict(state='busy')
+        spec['time_start'] = {'$lt': older_than}
+        cls.query.update(spec, {'$set': dict(state='ready')}, multi=True)
+
+    @classmethod
+    def clear_complete(cls):
+        '''Delete the task objects for complete tasks'''
+        spec = dict(state='complete')
+        cls.query.remove(spec)
+
+    @classmethod
+    def run_ready(cls, worker=None):
+        '''Run all the tasks that are currently ready'''
+        i = 0
+        for i, task in enumerate(cls.query.find(dict(state='ready')).all()):
+            task.process = worker
+            task()
+        return i
+
+    def __call__(self, restore_context=True, nocapture=False):
+        '''Call the task function with its context.  If restore_context is True,
+        c.project/app/user will be restored to the values they had before this
+        function was called.
+        '''
+        from allura import model as M
+        self.time_start = datetime.utcnow()
+        session(self).flush(self)
+        log.info('starting %r', self)
+        old_cproject = getattr(c, 'project', None)
+        old_capp = getattr(c, 'app', None)
+        old_cuser = getattr(c, 'user', None)
+        try:
+            func = self.function
+            c.project = M.Project.query.get(_id=self.context.project_id)
+            c.app = None
+            if c.project:
+                c.project.notifications_disabled = self.context.get(
+                    'notifications_disabled', False)
+                app_config = M.AppConfig.query.get(
+                    _id=self.context.app_config_id)
+                if app_config:
+                    c.app = c.project.app_instance(app_config)
+            c.user = M.User.query.get(_id=self.context.user_id)
+            with null_contextmanager() if nocapture else log_output(log):
+                self.result = func(*self.args, **self.kwargs)
+            self.state = 'complete'
+            return self.result
+        except Exception as exc:
+            if asbool(config.get('monq.raise_errors')):
+                raise
+            else:
+                log.exception('Error "%s" on job %s', exc, self)
+                self.state = 'error'
+                if hasattr(exc, 'format_error'):
+                    self.result = exc.format_error()
+                    log.error(self.result)
+                else:
+                    self.result = traceback.format_exc()
+        finally:
+            self.time_stop = datetime.utcnow()
+            session(self).flush(self)
+            if restore_context:
+                c.project = old_cproject
+                c.app = old_capp
+                c.user = old_cuser
+
+    def join(self, poll_interval=0.1):
+        '''Wait until this task is either complete or errors out, then return the result.'''
+        while self.state not in ('complete', 'error'):
+            time.sleep(poll_interval)
+            self.query.find(dict(_id=self._id), refresh=True).first()
+        return self.result
+
+    @classmethod
+    def list(cls, state='ready'):
+        '''Print all tasks of a certain status to sys.stdout.  Used for debugging.'''
+        for t in cls.query.find(dict(state=state)):
+            sys.stdout.write('%r\n' % t)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/model/neighborhood.py
----------------------------------------------------------------------
diff --git a/model/neighborhood.py b/model/neighborhood.py
new file mode 100644
index 0000000..8ee7173
--- /dev/null
+++ b/model/neighborhood.py
@@ -0,0 +1,295 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import re
+import json
+import logging
+from collections import OrderedDict
+
+from ming import schema as S
+from ming.orm import FieldProperty, RelationProperty
+from ming.orm.declarative import MappedClass
+from ming.utils import LazyProperty
+
+from pylons import request
+from pylons import tmpl_context as c, app_globals as g
+
+from allura.lib import plugin
+
+from .session import main_orm_session
+from .filesystem import File
+from .types import MarkdownCache
+
+log = logging.getLogger(__name__)
+
+
+class NeighborhoodFile(File):
+
+    class __mongometa__:
+        session = main_orm_session
+        indexes = ['neighborhood_id']
+
+    neighborhood_id = FieldProperty(S.ObjectId)
+
+re_picker_css_type = re.compile('^/\*(.+)\*/')
+re_font_project_title = re.compile('font-family:(.+);\}')
+re_color_project_title = re.compile('color:(.+);\}')
+re_bgcolor_barontop = re.compile('background\-color:([^;}]+);')
+re_bgcolor_titlebar = re.compile('background\-color:([^;}]+);')
+re_color_titlebar = re.compile('color:([^;}]+);')
+re_icon_theme = re.compile('neo-icon-set-(ffffff|454545)-256x350.png')
+
+
+class Neighborhood(MappedClass):
+
+    '''Provide a grouping of related projects.
+
+    url_prefix - location of neighborhood (may include scheme and/or host)
+    css - block of CSS text to add to all neighborhood pages
+    '''
+    class __mongometa__:
+        session = main_orm_session
+        name = 'neighborhood'
+        unique_indexes = ['url_prefix']
+
+    _id = FieldProperty(S.ObjectId)
+    name = FieldProperty(str)
+    # e.g. http://adobe.openforge.com/ or projects/
+    url_prefix = FieldProperty(str)
+    shortname_prefix = FieldProperty(str, if_missing='')
+    css = FieldProperty(str, if_missing='')
+    homepage = FieldProperty(str, if_missing='')
+    homepage_cache = FieldProperty(MarkdownCache)
+    redirect = FieldProperty(str, if_missing='')
+    projects = RelationProperty('Project')
+    allow_browse = FieldProperty(bool, if_missing=True)
+    show_title = FieldProperty(bool, if_missing=True)
+    site_specific_html = FieldProperty(str, if_missing='')
+    project_template = FieldProperty(str, if_missing='')
+    tracking_id = FieldProperty(str, if_missing='')
+    project_list_url = FieldProperty(str, if_missing='')
+    level = FieldProperty(S.Deprecated)
+    allow_private = FieldProperty(S.Deprecated)
+    features = FieldProperty(dict(
+        private_projects=bool,
+        max_projects=S.Int,
+        css=str,
+        google_analytics=bool))
+    anchored_tools = FieldProperty(str, if_missing='')
+    prohibited_tools = FieldProperty(str, if_missing='')
+
+    def parent_security_context(self):
+        return None
+
+    @LazyProperty
+    def neighborhood_project(self):
+        from .project import Project
+        p = Project.query.get(
+            neighborhood_id=self._id,
+            is_nbhd_project=True)
+        assert p
+        return p
+
+    @property
+    def acl(self):
+        return self.neighborhood_project.acl
+
+    def url(self):
+        url = self.url_prefix
+        if url.startswith('//'):
+            try:
+                return request.scheme + ':' + url
+            except TypeError:  # pragma no cover
+                return 'http:' + url
+        else:
+            return url
+
+    def register_project(self, shortname, user=None, project_name=None, user_project=False, private_project=False, apps=None):
+        '''Register a new project in the neighborhood.  The given user will
+        become the project's superuser.  If no user is specified, c.user is used.
+        '''
+        provider = plugin.ProjectRegistrationProvider.get()
+        if project_name is None:
+            project_name = shortname
+        return provider.register_project(
+            self, shortname, project_name, user or getattr(c, 'user', None), user_project, private_project, apps)
+
+    def bind_controller(self, controller):
+        from allura.controllers.project import NeighborhoodController
+        controller_attr = self.url_prefix[1:-1]
+        setattr(controller, controller_attr, NeighborhoodController(self))
+
+    def get_custom_css(self):
+        if self.allow_custom_css:
+            return self.css
+        return ""
+
+    @property
+    def has_home_tool(self):
+        return (self.neighborhood_project
+                    .app_config_by_tool_type('home') is not None)
+
+    @property
+    def icon(self):
+        return NeighborhoodFile.query.get(neighborhood_id=self._id)
+
+    @property
+    def allow_custom_css(self):
+        return self.features['css'] == 'custom' or self.features['css'] == 'picker'
+
+    def get_project_template(self):
+        if self.project_template:
+            return json.loads(self.project_template)
+        return {}
+
+    def get_max_projects(self):
+        return self.features['max_projects']
+
+    def get_css_for_picker(self):
+        projecttitlefont = {'label': 'Project title, font',
+                            'name': 'projecttitlefont', 'value': '', 'type': 'font'}
+        projecttitlecolor = {'label': 'Project title, color',
+                             'name': 'projecttitlecolor', 'value': '', 'type': 'color'}
+        barontop = {'label': 'Bar on top', 'name':
+                    'barontop', 'value': '', 'type': 'color'}
+        titlebarbackground = {'label': 'Title bar, background',
+                              'name': 'titlebarbackground', 'value': '', 'type': 'color'}
+        titlebarcolor = {
+            'label': 'Title bar, foreground', 'name': 'titlebarcolor', 'value': '', 'type': 'color',
+            'additional': """<label>Icons theme:</label> <select name="css-addopt-icon-theme" class="add_opt">
+                        <option value="default">default</option>
+                        <option value="dark"%(titlebarcolor_dark)s>dark</option>
+                        <option value="white"%(titlebarcolor_white)s>white</option>
+                      </select>"""}
+        titlebarcolor_dark = ''
+        titlebarcolor_white = ''
+
+        if self.css is not None:
+            for css_line in self.css.split('\n'):
+                m = re_picker_css_type.search(css_line)
+                if not m:
+                    continue
+
+                css_type = m.group(1)
+                if css_type == "projecttitlefont":
+                    m = re_font_project_title.search(css_line)
+                    if m:
+                        projecttitlefont['value'] = m.group(1)
+
+                elif css_type == "projecttitlecolor":
+                    m = re_color_project_title.search(css_line)
+                    if m:
+                        projecttitlecolor['value'] = m.group(1)
+
+                elif css_type == "barontop":
+                    m = re_bgcolor_barontop.search(css_line)
+                    if m:
+                        barontop['value'] = m.group(1)
+
+                elif css_type == "titlebarbackground":
+                    m = re_bgcolor_titlebar.search(css_line)
+                    if m:
+                        titlebarbackground['value'] = m.group(1)
+
+                elif css_type == "titlebarcolor":
+                    m = re_color_titlebar.search(css_line)
+                    if m:
+                        titlebarcolor['value'] = m.group(1)
+                        m = re_icon_theme.search(css_line)
+                        if m:
+                            icon_theme = m.group(1)
+                            if icon_theme == "ffffff":
+                                titlebarcolor_dark = ' selected="selected"'
+                            elif icon_theme == "454545":
+                                titlebarcolor_white = ' selected="selected"'
+
+        titlebarcolor[
+            'additional'] = titlebarcolor['additional'] % {'titlebarcolor_dark': titlebarcolor_dark,
+                                                           'titlebarcolor_white': titlebarcolor_white}
+
+        styles_list = []
+        styles_list.append(projecttitlefont)
+        styles_list.append(projecttitlecolor)
+        styles_list.append(barontop)
+        styles_list.append(titlebarbackground)
+        styles_list.append(titlebarcolor)
+        return styles_list
+
+    @staticmethod
+    def compile_css_for_picker(css_form_dict):
+        # Check css values
+        for key in list(css_form_dict.keys()):
+            if ';' in css_form_dict[key] or '}' in css_form_dict[key]:
+                css_form_dict[key] = ''
+
+        css_text = ""
+        if 'projecttitlefont' in css_form_dict and css_form_dict['projecttitlefont'] != '':
+            css_text += "/*projecttitlefont*/.project_title{font-family:%s;}\n" % (
+                css_form_dict['projecttitlefont'])
+
+        if 'projecttitlecolor' in css_form_dict and css_form_dict['projecttitlecolor'] != '':
+            css_text += "/*projecttitlecolor*/.project_title{color:%s;}\n" % (
+                css_form_dict['projecttitlecolor'])
+
+        if 'barontop' in css_form_dict and css_form_dict['barontop'] != '':
+            css_text += "/*barontop*/.pad h2.colored {background-color:%(bgcolor)s; background-image: none;}\n" % \
+                        {'bgcolor': css_form_dict['barontop']}
+
+        if 'titlebarbackground' in css_form_dict and css_form_dict['titlebarbackground'] != '':
+            css_text += "/*titlebarbackground*/.pad h2.title{background-color:%(bgcolor)s; background-image: none;}\n" % \
+                        {'bgcolor': css_form_dict['titlebarbackground']}
+
+        if 'titlebarcolor' in css_form_dict and css_form_dict['titlebarcolor'] != '':
+            icon_theme = ''
+            if 'addopt-icon-theme' in css_form_dict:
+                if css_form_dict['addopt-icon-theme'] == "dark":
+                    icon_theme = ".pad h2.dark small b.ico {background-image: url('%s%s');}" % (
+                                 g.theme_href(''),
+                        'images/neo-icon-set-ffffff-256x350.png')
+                elif css_form_dict['addopt-icon-theme'] == "white":
+                    icon_theme = ".pad h2.dark small b.ico {background-image: url('%s%s');}" % (
+                                 g.theme_href(''),
+                        'images/neo-icon-set-454545-256x350.png')
+
+            css_text += "/*titlebarcolor*/.pad h2.title, .pad h2.title small a {color:%s;} %s\n" % (
+                css_form_dict['titlebarcolor'], icon_theme)
+
+        return css_text
+
+    def migrate_css_for_picker(self):
+        self.css = ""
+
+    def get_anchored_tools(self):
+        if not self.anchored_tools:
+            return dict()
+        try:
+            anchored_tools = [at.strip()
+                              for at in self.anchored_tools.split(',')]
+            return OrderedDict((tool.split(':')[0].lower(), tool.split(':')[1]) for tool in anchored_tools)
+        except Exception:
+            log.warning("anchored_tools isn't valid", exc_info=True)
+            return dict()
+
+    def get_prohibited_tools(self):
+        prohibited_tools = []
+        if self.prohibited_tools:
+            prohibited_tools = [tool.lower().strip() for tool in self.prohibited_tools.split(',')]
+        return prohibited_tools

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/model/notification.py
----------------------------------------------------------------------
diff --git a/model/notification.py b/model/notification.py
new file mode 100644
index 0000000..84064bd
--- /dev/null
+++ b/model/notification.py
@@ -0,0 +1,721 @@
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+'''Manage notifications and subscriptions
+
+When an artifact is modified:
+
+- Notification generated by tool app
+- Search is made for subscriptions matching the notification
+- Notification is added to each matching subscriptions' queue
+
+Periodically:
+
+- For each subscriptions with notifications and direct delivery:
+   - For each notification, enqueue as a separate email message
+   - Clear subscription's notification list
+- For each subscription with notifications and delivery due:
+   - Enqueue one email message with all notifications
+   - Clear subscription's notification list
+
+'''
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import logging
+from bson import ObjectId
+from datetime import datetime, timedelta
+from collections import defaultdict
+
+from pylons import tmpl_context as c, app_globals as g
+from tg import config
+import pymongo
+import jinja2
+from paste.deploy.converters import asbool
+
+from ming import schema as S
+from ming.orm import FieldProperty, ForeignIdProperty, RelationProperty, session
+from ming.orm.declarative import MappedClass
+
+from allura.lib import helpers as h
+from allura.lib import security
+from allura.lib.utils import take_while_true
+import allura.tasks.mail_tasks
+
+from .session import main_orm_session
+from .auth import User, AlluraUserProperty
+
+
+log = logging.getLogger(__name__)
+
+MAILBOX_QUIESCENT = None  # Re-enable with [#1384]: timedelta(minutes=10)
+
+
+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'''
+        import allura.tasks.notification_tasks
+        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)
+            allura.tasks.notification_tasks.notify.post(
+                n._id, artifact.index_id(), topic)
+        return n
+
+    @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\nAttachment:" % 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)
+                    text = "%s %s (%s; %s) " % (
+                        text, attach.filename, 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 = 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 = list(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)
+
+
+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.items():
+                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)
+
+
+class MailFooter(object):
+    view = jinja2.Environment(
+        loader=jinja2.PackageLoader('allura', 'templates'),
+        auto_reload=asbool(config.get('auto_reload_templates', True)),
+    )
+
+    @classmethod
+    def _render(cls, template, **kw):
+        return cls.view.get_template(template).render(kw)
+
+    @classmethod
+    def standard(cls, notification, allow_email_posting=True, **kw):
+        return cls._render('mail/footer.txt',
+                           domain=config['domain'],
+                           notification=notification,
+                           prefix=config['forgemail.url'],
+                           allow_email_posting=allow_email_posting,
+                           **kw)
+
+    @classmethod
+    def monitored(cls, toaddr, app_url, setting_url):
+        return cls._render('mail/monitor_email_footer.txt',
+                           domain=config['domain'],
+                           email=toaddr,
+                           app_url=app_url,
+                           setting_url=setting_url)
+
+
+class SiteNotification(MappedClass):
+
+    """
+    Storage for site-wide notification.
+    """
+
+    class __mongometa__:
+        session = main_orm_session
+        name = 'site_notification'
+
+    _id = FieldProperty(S.ObjectId)
+    content = FieldProperty(str, if_missing='')
+    active = FieldProperty(bool, if_missing=True)
+    impressions = FieldProperty(
+        int, if_missing=lambda: config.get('site_notification.impressions', 0))
+
+    @classmethod
+    def current(cls):
+        note = cls.query.find().sort('_id', -1).limit(1).first()
+        if note is None or not note.active:
+            return None
+        return note

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/model/oauth.py
----------------------------------------------------------------------
diff --git a/model/oauth.py b/model/oauth.py
new file mode 100644
index 0000000..1e0953b
--- /dev/null
+++ b/model/oauth.py
@@ -0,0 +1,148 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import logging
+
+import oauth2 as oauth
+from pylons import tmpl_context as c, app_globals as g
+
+import pymongo
+from paste.deploy.converters import aslist
+from tg import config
+from ming import schema as S
+from ming.orm import session
+from ming.orm import FieldProperty, RelationProperty, ForeignIdProperty
+from ming.orm.declarative import MappedClass
+
+from allura.lib import helpers as h
+from .session import main_orm_session
+from .types import MarkdownCache
+from .auth import AlluraUserProperty
+
+log = logging.getLogger(__name__)
+
+
+class OAuthToken(MappedClass):
+
+    class __mongometa__:
+        session = main_orm_session
+        name = 'oauth_token'
+        indexes = ['api_key']
+        polymorphic_on = 'type'
+        polymorphic_identity = None
+
+    _id = FieldProperty(S.ObjectId)
+    type = FieldProperty(str)
+    api_key = FieldProperty(str, if_missing=lambda: h.nonce(20))
+    secret_key = FieldProperty(str, if_missing=h.cryptographic_nonce)
+
+    def to_string(self):
+        return oauth.Token(self.api_key, self.secret_key).to_string()
+
+    def as_token(self):
+        return oauth.Token(self.api_key, self.secret_key)
+
+
+class OAuthConsumerToken(OAuthToken):
+
+    class __mongometa__:
+        polymorphic_identity = 'consumer'
+        name = 'oauth_consumer_token'
+        unique_indexes = [('name', 'user_id')]
+
+    type = FieldProperty(str, if_missing='consumer')
+    user_id = AlluraUserProperty(if_missing=lambda: c.user._id)
+    name = FieldProperty(str)
+    description = FieldProperty(str, if_missing='')
+    description_cache = FieldProperty(MarkdownCache)
+
+    user = RelationProperty('User')
+
+    @classmethod
+    def upsert(cls, name):
+        t = cls.query.get(name=name)
+        if t is not None:
+            return t
+        try:
+            t = cls(name=name)
+            session(t).flush(t)
+        except pymongo.errors.DuplicateKeyError:
+            session(t).expunge(t)
+            t = cls.query.get(name=name)
+        return t
+
+    @property
+    def description_html(self):
+        return g.markdown.cached_convert(self, 'description')
+
+    @property
+    def consumer(self):
+        '''OAuth compatible consumer object'''
+        return oauth.Consumer(self.api_key, self.secret_key)
+
+    @classmethod
+    def for_user(cls, user=None):
+        if user is None:
+            user = c.user
+        return cls.query.find(dict(user_id=user._id)).all()
+
+
+class OAuthRequestToken(OAuthToken):
+
+    class __mongometa__:
+        polymorphic_identity = 'request'
+
+    type = FieldProperty(str, if_missing='request')
+    consumer_token_id = ForeignIdProperty('OAuthConsumerToken')
+    user_id = AlluraUserProperty(if_missing=lambda: c.user._id)
+    callback = FieldProperty(str)
+    validation_pin = FieldProperty(str)
+
+    consumer_token = RelationProperty('OAuthConsumerToken')
+
+
+class OAuthAccessToken(OAuthToken):
+
+    class __mongometa__:
+        polymorphic_identity = 'access'
+
+    type = FieldProperty(str, if_missing='access')
+    consumer_token_id = ForeignIdProperty('OAuthConsumerToken')
+    request_token_id = ForeignIdProperty('OAuthToken')
+    user_id = AlluraUserProperty(if_missing=lambda: c.user._id)
+    is_bearer = FieldProperty(bool, if_missing=False)
+
+    user = RelationProperty('User')
+    consumer_token = RelationProperty(
+        'OAuthConsumerToken', via='consumer_token_id')
+    request_token = RelationProperty('OAuthToken', via='request_token_id')
+
+    @classmethod
+    def for_user(cls, user=None):
+        if user is None:
+            user = c.user
+        return cls.query.find(dict(user_id=user._id, type='access')).all()
+
+    def can_import_forum(self):
+        tokens = aslist(config.get('oauth.can_import_forum', ''), ',')
+        if self.api_key in tokens:
+            return True
+        return False