#496 Rework of the hub configuration
Merged 6 years ago by abompard. Opened 6 years ago by abompard.
abompard/fedora-hubs feature/rework-hub-config  into  develop

file modified
+1 -1
@@ -12,7 +12,7 @@ 

  Authorization

  -------------

  

- A hub's visibility is controlled by the ``HubConfig.visibility`` parameter. It

+ A hub's visibility is controlled by the ``hub.config["visibility"]`` parameter. It

  can have 3 values:

  

  - ``public``: the hub is visible to everyone

file modified
+5
@@ -20,3 +20,8 @@ 

  -----------------

  

  .. automodule:: hubs.utils.datagrepper

+ 

+ Validators

+ ----------

+ 

+ .. automodule:: hubs.utils.validators

file modified
-5
@@ -22,11 +22,6 @@ 

  

  .. automodule:: hubs.widgets.parameters

  

- Widget parameter validators

- ---------------------------

- 

- .. automodule:: hubs.widgets.validators

- 

  Widget view

  -----------

  

file modified
+2 -1
@@ -75,4 +75,5 @@ 

  

  # Now setup the default scoped session maker.

  fedmsg_config = get_fedmsg_config()

- init(fedmsg_config['hubs.sqlalchemy.uri'])

+ if BASE.metadata.bind is None:

+     init(fedmsg_config['hubs.sqlalchemy.uri'])

file modified
+2 -2
@@ -10,8 +10,8 @@ 

  import pymongo

  from fedmsg.encoding import loads, dumps

  

- import hubs.app

  from hubs.models import Hub, User, Association

+ from hubs.utils import get_fedmsg_config

  

  

  log = logging.getLogger(__name__)
@@ -142,7 +142,7 @@ 

                  "You must subclass Feed and set self.msgtype.")

          self.owner = owner

          self.db = None

-         fedmsg_config = hubs.app.fedmsg_config

+         fedmsg_config = get_fedmsg_config()

          self.db_config = {

              "url": fedmsg_config.get('hubs.mongodb.url'),

              "db": fedmsg_config.get('hubs.mongodb.database', "hubs"),

@@ -0,0 +1,83 @@ 

+ # This Alembic database migration is part of the Fedora Hubs project.

+ # Copyright (C) 2017  The Fedora Project

+ #

+ # This program is free software: you can redistribute it and/or modify

+ # it under the terms of the GNU Affero General Public License as published by

+ # the Free Software Foundation, either version 3 of the License, or

+ # (at your option) any later version.

+ #

+ # This program is distributed in the hope that it will be useful,

+ # but WITHOUT ANY WARRANTY; without even the implied warranty of

+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the

+ # GNU Affero General Public License for more details.

+ #

+ # You should have received a copy of the GNU Affero General Public License

+ # along with this program.  If not, see <http://www.gnu.org/licenses/>.

+ """

+ Refactored hubs config table

+ 

+ Revision ID: 20b23e867aeb

+ Revises: bed8bbc0f78e

+ Create Date: 2017-12-08 11:58:15.289144

+ """

+ 

+ from __future__ import absolute_import, unicode_literals

+ 

+ from alembic import op

+ import sqlalchemy as sa

+ 

+ 

+ # revision identifiers, used by Alembic.

+ revision = '20b23e867aeb'

+ down_revision = 'bed8bbc0f78e'

+ branch_labels = None

+ depends_on = None

+ 

+ 

+ def upgrade():

+     # Don't attempt to migrate the data.

+     op.drop_table('hubs_config')

+     op.create_table(

+         'hubs_config',

+         sa.Column('id', sa.Integer(), nullable=False),

+         sa.Column('hub_id', sa.String(length=50), nullable=False),

+         sa.Column('key', sa.String(length=256), nullable=False),

+         sa.Column('value', sa.Text(), nullable=False),

+         sa.ForeignKeyConstraint(['hub_id'], ['hubs.name'], ),

+         sa.PrimaryKeyConstraint('id')

+     )

+     op.create_index(

+         op.f('ix_hubs_config_hub_id'), 'hubs_config', ['hub_id'], unique=False)

+     op.create_index(

+         op.f('ix_hubs_config_key'), 'hubs_config', ['key'], unique=False)

+     op.create_index(

+         op.f('ix_hubs_config_value'), 'hubs_config', ['value'], unique=False)

+     # http://alembic.zzzcomputing.com/en/latest/batch.html

+     with op.batch_alter_table("hubs") as batch_op:

+         batch_op.drop_column('archived')

+ 

+ 

+ def downgrade():

+     op.add_column('hubs', sa.Column('archived', sa.BOOLEAN(), nullable=True))

+     op.drop_index(op.f('ix_hubs_config_value'), table_name='hubs_config')

+     op.drop_index(op.f('ix_hubs_config_key'), table_name='hubs_config')

+     op.drop_index(op.f('ix_hubs_config_hub_id'), table_name='hubs_config')

+     op.drop_table('hubs_config')

+     op.create_table(

+         'hubs_config',

+         sa.Column('id', sa.INTEGER(), nullable=False),

+         sa.Column('hub_id', sa.VARCHAR(length=50), nullable=False),

+         sa.Column('summary', sa.VARCHAR(length=128), nullable=True),

+         sa.Column('left_width', sa.INTEGER(), nullable=False),

+         sa.Column('avatar', sa.VARCHAR(length=256), nullable=True),

+         sa.Column('header_img', sa.VARCHAR(length=256), nullable=True),

+         sa.Column('chat_channel', sa.VARCHAR(length=256), nullable=True),

+         sa.Column('chat_domain', sa.VARCHAR(length=256), nullable=True),

+         sa.Column('auth_group', sa.VARCHAR(length=256), nullable=True),

+         sa.Column('visibility', sa.VARCHAR(length=7), nullable=False),

+         sa.CheckConstraint(

+             "visibility IN ('public', 'preview', 'private')",

+             name='hub_visibility'),

+         sa.ForeignKeyConstraint(['hub_id'], ['hubs.name'], ),

+         sa.PrimaryKeyConstraint('id')

+     )

file removed
-686
@@ -1,686 +0,0 @@ 

- # -*- coding: utf-8 -*-

- #

- # Copyright © 2015  Red Hat, Inc.

- #

- # This copyrighted material is made available to anyone wishing to use,

- # modify, copy, or redistribute it subject to the terms and conditions

- # of the GNU Lesser General Public License (LGPL) version 2, or

- # (at your option) any later version.  This program is distributed in the

- # hope that it will be useful, but WITHOUT ANY WARRANTY expressed or

- # implied, including the implied warranties of MERCHANTABILITY or FITNESS

- # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

- # more details.  You should have received a copy of the GNU Lesser General

- # Public License along with this program; if not, write to the Free

- # Software Foundation, Inc.,

- # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

- #

- # Any Red Hat trademarks that are incorporated in the source

- # code or documentation are not subject to the GNU General Public

- # License and may only be used or replicated with the express permission

- # of Red Hat, Inc.

- #

- 

- from __future__ import unicode_literals

- 

- import datetime

- import json

- import logging

- import operator

- import os

- import random

- from collections import defaultdict

- 

- import bleach

- import flask

- import sqlalchemy as sa

- from sqlalchemy.orm import relation

- from sqlalchemy.orm import backref

- from sqlalchemy.orm.session import object_session

- 

- import hubs.defaults

- import hubs.widgets

- from hubs.authz import ObjectAuthzMixin, AccessLevel

- from hubs.database import BASE, Session

- from hubs.utils import username2avatar

- from hubs.signals import hub_created, user_created

- 

- log = logging.getLogger(__name__)

- 

- 

- def randomheader():

-     location = '/static/img/headers/'

-     header_dir = os.path.dirname(__file__) + location

-     choice = random.choice(os.listdir(header_dir))

-     return location + choice

- 

- 

- ROLES = ['subscriber', 'member', 'owner', 'stargazer']

- 

- 

- class Association(BASE):

-     __tablename__ = 'association'

- 

-     hub_id = sa.Column(sa.String(50),

-                        sa.ForeignKey('hubs.name'),

-                        primary_key=True)

-     user_id = sa.Column(sa.Text,

-                         sa.ForeignKey('users.username'),

-                         primary_key=True)

-     role = sa.Column(

-         sa.Enum(*ROLES, name="roles"), primary_key=True)

- 

-     user = relation("User", backref=backref(

-         'associations', cascade="all, delete, delete-orphan"))

-     hub = relation("Hub", backref=backref(

-         'associations', cascade="all, delete, delete-orphan"))

- 

-     @classmethod

-     def get(cls, hub, user, role):

-         return cls.query\

-             .filter_by(hub=hub)\

-             .filter_by(user=user)\

-             .filter_by(role=role)\

-             .first()

- 

- 

- class Hub(ObjectAuthzMixin, BASE):

-     __tablename__ = 'hubs'

-     name = sa.Column(sa.String(50), primary_key=True)

-     created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

-     widgets = relation('Widget', cascade='all,delete', backref='hub',

-                        order_by="Widget.index")

-     config = relation('HubConfig', uselist=False, cascade='all,delete',

-                       backref='hub')

-     archived = sa.Column(sa.Boolean, default=False)

-     user_hub = sa.Column(sa.Boolean, default=False)

-     # Timestamps about various kinds of "freshness"

-     last_refreshed = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

-     last_edited = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

- 

-     # fas_group = sa.Column(sa.String(32), nullable=False)

- 

-     @property

-     def days_idle(self):

-         return (datetime.datetime.utcnow() - self.last_refreshed).days

- 

-     @property

-     def activity_class(self):

-         idle = self.days_idle

-         limits = [

-             (356 * 5,   '5years'),

-             (356 * 2,   '2years'),

-             (356,       'year'),

-             (31 * 3,    'quarter'),

-             (31,        'month'),

-             (7,         'week'),

-             (1,         'day'),

-             (0,         'none'),

-         ]

-         for limit, name in limits:

-             if idle > limit:

-                 return name

- 

-     @property

-     def owners(self):

-         return [assoc.user for assoc in self.associations

-                 if assoc.role == 'owner']

- 

-     @property

-     def members(self):

-         return [assoc.user for assoc in self.associations

-                 if assoc.role == 'member' or assoc.role == 'owner']

- 

-     @property

-     def subscribers(self):

-         return [assoc.user for assoc in self.associations

-                 if assoc.role == 'subscriber']

- 

-     @property

-     def stargazers(self):

-         return [assoc.user for assoc in self.associations

-                 if assoc.role == 'stargazer']

- 

-     def subscribe(self, user, role='subscriber'):

-         """ Subscribe a user to this hub. """

-         # TODO -- add logic here to manage not adding the user multiple

-         # times, doing different roles, etc.. publish a fedmsg message,

-         # etc...

-         session = object_session(self)

-         session.add(Association(user=user, hub=self, role=role))

-         session.commit()

- 

-     def unsubscribe(self, user, role='subscriber'):

-         """ Unsubscribe a user to this hub. """

-         # TODO -- add logic here to manage not adding the user multiple

-         # times, doing different roles, etc.. publish a fedmsg message,

-         # etc...

-         session = object_session(self)

-         association = Association.get(hub=self, user=user, role=role)

-         if not association:

-             raise KeyError("%r is not a %r of %r" % (user, role, self))

-         if role == 'owner':

-             # When stepping down from an owner, turn into a member.

-             is_member = bool(Association.query.filter_by(

-                 hub=self, user=user, role="member").count())

-             if is_member:

-                 session.delete(association)

-             else:

-                 association.role = 'member'

-         else:

-             session.delete(association)

-         session.commit()

- 

-     @classmethod

-     def by_name(cls, name):

-         return cls.query.filter_by(name=name).first()

- 

-     get = by_name

- 

-     @classmethod

-     def all_group_hubs(cls):

-         return cls.query.filter_by(user_hub=False).all()

- 

-     @classmethod

-     def all_user_hubs(cls):

-         return cls.query.filter_by(user_hub=True).all()

- 

-     @classmethod

-     def create_user_hub(cls, username, fullname):

-         session = Session()

-         hub = cls(name=username, user_hub=True)

-         session.add(hub)

-         hub_config = HubConfig(

-             hub=hub, summary=fullname, avatar=username2avatar(username))

-         session.add(hub_config)

-         session.flush()

-         hub_created.send(hub)

-         return hub

- 

-     @classmethod

-     def create_group_hub(cls, name, summary, **extra):

-         session = Session()

-         hub = cls(name=name, user_hub=False)

-         session.add(hub)

-         # TODO -- do something else, smarter for group avatars

-         hub_config = HubConfig(

-             hub=hub, summary=summary, avatar=username2avatar(name))

-         session.add(hub_config)

-         session.flush()

-         hub_created.send(hub, **extra)

-         return hub

- 

-     def on_created(self, **extra):

-         if self.user_hub:

-             hubs.defaults.add_user_widgets(self)

-             user = User.query.get(self.name)

-             self.subscribe(user, role='owner')

-         else:

-             hubs.defaults.add_group_widgets(self, **extra)

- 

-     def on_updated(self, old_config):

-         for widget_instance in self.widgets:

-             if not widget_instance.enabled:

-                 continue

-             widget = widget_instance.module

-             new_config = self.config.__json__()

-             will_reload = False

-             cached_functions = widget.get_cached_functions()

-             for fn_name, fn_class in cached_functions.items():

-                 fn = fn_class(widget_instance)

-                 if fn.should_invalidate_on_hub_config_change(old_config):

-                     flask.g.task_queue.enqueue(

-                         "widget-cache",

-                         idx=widget_instance.idx,

-                         hub=self.name,

-                         fn_name=fn_name,

-                         )

-                     will_reload = True

-             if not will_reload:

-                 # Reload the widget if it has asked for it.

-                 if widget.should_reload_on_hub_config_change(

-                         old_config, new_config):

-                     flask.g.task_queue.enqueue(

-                         "widget-update",

-                         idx=widget_instance.idx,

-                         hub=self.name,

-                         )

- 

-     def _get_auth_user_access_level(self, user):

-         # overridden to handle user hubs.

-         if self.user_hub and user.username == self.name:

-             return AccessLevel.owner

-         return super(Hub, self)._get_auth_user_access_level(user)

- 

-     def _get_auth_group(self):

-         # While we're local-only, just use the hub name:

-         return self.name

-         # When the CAIAPI is in place we will be able to use the auth_group

-         # setting:

-         # group = self.config.auth_group

-         # if group is None:

-         #     group = self.name

-         # return group

- 

-     def _get_auth_user_roles(self, user):

-         """Override until we can get groups from FAS.

- 

-         This will not return all roles, only the one for the current hub, but

-         that fine since it's the only one we're interested in when this method

-         is called.

-         """

-         roles = [

-             assoc.role for assoc in self.associations

-             if assoc.user.username == user.username

-         ]

-         return {self.name: roles}

- 

-     def _get_auth_permission_name(self, action):

-         if action == "view":

-             action = "{}.view".format(self.config.visibility)

-         return "hub.{}".format(action)

- 

-     def get_props(self):

-         """Get the hub properties for the Javascript UI"""

-         result = {

-             "name": self.name,

-             "config": self.config.__json__(),

-             "users": {role: [] for role in ROLES},

-             "mtime": self.last_refreshed,

-             "user_hub": self.user_hub,

-         }

-         for assoc in sorted(self.associations, key=lambda a: a.user.username):

-             if assoc.role not in ROLES:

-                 continue

-             result["users"][assoc.role].append(assoc.user.__json__())

-         if self.user_hub:

-             user = User.query.get(self.name)

-             if user is None:

-                 result["subscribed_to"] = []

-             else:

-                 result["subscribed_to"] = [

-                     assoc.hub.name for assoc in Association.query.filter_by(

-                         user=user, role="subscriber")

-                     ]

-         return result

- 

-     def __json__(self):

-         return {

-             'name': self.name,

-             'archived': self.archived,

-             'config': self.config.__json__(),

- 

-             'widgets': [widget.idx for widget in self.widgets],

- 

-             'owners': [u.username for u in self.owners],

-             'members': [u.username for u in self.members],

-             'subscribers': [u.username for u in self.subscribers],

-         }

- 

- 

- class HubConfig(BASE):

- 

-     __tablename__ = 'hubs_config'

- 

-     VISIBILITY = ["public", "preview", "private"]

- 

-     id = sa.Column(sa.Integer, primary_key=True)

-     hub_id = sa.Column(sa.String(50), sa.ForeignKey('hubs.name'),

-                        nullable=False)

-     summary = sa.Column(sa.String(128))

-     left_width = sa.Column(sa.Integer, nullable=False, default=8)

-     # A URL to the "avatar" for this hub.

-     avatar = sa.Column(sa.String(256), default="")

-     header_img = sa.Column(sa.String(256), default=randomheader)

-     chat_channel = sa.Column(sa.String(256), nullable=True)

-     chat_domain = sa.Column(sa.String(256), nullable=True)

-     auth_group = sa.Column(sa.String(256), nullable=True)

-     visibility = sa.Column(

-         sa.Enum(*VISIBILITY, name="hub_visibility"),

-         default="public", nullable=False)

- 

-     def __json__(self):

-         return {

-             'summary': self.summary,

-             'left_width': self.left_width,

-             'right_width': self.right_width,

-             'avatar': self.avatar,

-             'chat_channel': self.chat_channel,

-             'chat_domain': self.chat_domain,

-             'visibility': self.visibility,

-         }

- 

-     @property

-     def right_width(self):

-         return 12 - self.left_width

- 

-     @right_width.setter

-     def right_width(self, value):

-         self.left_width = 12 - value

- 

- 

- class SpecificDefaultDict(defaultdict):

-     """A more specific version of defaultdict.

- 

-     This class behaves like defaultdict, but calls the ``default_factory`` with

-     the key as first argument.

-     """

- 

-     def __missing__(self, key):

-         if self.default_factory is None:

-             return super(SpecificDefaultDict, self).__missing__(key)

-         self[key] = self.default_factory(key)

-         return self[key]

- 

- 

- class Widget(ObjectAuthzMixin, BASE):

- 

-     __tablename__ = 'widgets'

- 

-     VISIBILITY = ["public", "restricted"]

- 

-     idx = sa.Column(sa.Integer, primary_key=True)

-     plugin = sa.Column(sa.String(50), nullable=False)

-     created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

-     hub_id = sa.Column(sa.String(50), sa.ForeignKey('hubs.name'))

-     _config = sa.Column(sa.Text, default="{}")

- 

-     index = sa.Column(sa.Integer, nullable=False)

-     left = sa.Column(sa.Boolean, nullable=False, default=False)

-     visibility = sa.Column(

-         sa.Enum(*VISIBILITY, name="widget_visibility"),

-         default="public", nullable=False)

- 

-     @classmethod

-     def by_idx(cls, idx):

-         return cls.query.filter_by(idx=idx).first()

- 

-     @classmethod

-     def by_plugin(cls, plugin):

-         return cls.query.filter_by(plugin=plugin).first()

- 

-     @classmethod

-     def by_hub_id_all(cls, hub_id):

-         return cls.query.filter_by(hub_id=hub_id).all()

- 

-     get = by_idx

- 

-     @property

-     def config(self):

-         def get_default(key):

-             for param in self.module.get_parameters():

-                 if key == param.name:

-                     break

-             else:

-                 raise KeyError("No such parameter")

-             return param.default

- 

-         value = SpecificDefaultDict(get_default)

-         value.update(json.loads(self._config))

-         return value

- 

-     @config.setter

-     def config(self, config):

-         self._config = json.dumps(config)

- 

-     def on_updated(self, old_config):

-         will_reload = False

-         cached_functions = self.module.get_cached_functions()

-         for fn_name, fn_class in cached_functions.items():

-             fn = fn_class(self)

-             if fn.should_invalidate_on_widget_config_change(old_config):

-                 flask.g.task_queue.enqueue(

-                     "widget-cache",

-                     idx=self.idx,

-                     hub=self.hub.name,

-                     fn_name=fn_name,

-                     )

-                 will_reload = True

-         if not will_reload:

-             # Reload the widget nonetheless because the config

-             # change may impact rendering.

-             flask.g.task_queue.enqueue(

-                 "widget-update",

-                 idx=self.idx,

-                 hub=self.hub.name,

-                 )

- 

-     def _get_auth_access_level(self, user):

-         return self.hub._get_auth_access_level(user)

- 

-     def _get_auth_user_roles(self, user):

-         """Override until we can get groups from FAS"""

-         return self.hub._get_auth_user_roles(user)

- 

-     def _get_auth_permission_name(self, action):

-         if action != "view":

-             return self.hub._get_auth_permission_name(action)

-         hub_visibility = self.hub.config.visibility

-         if hub_visibility != "preview":

-             return "hub.{}.view".format(hub_visibility)

-         return "widget.{}.view".format(self.visibility)

- 

-     def __json__(self):

-         module = hubs.widgets.registry[self.plugin]

-         root_view = module.get_views()["root"](module)

-         data = root_view.get_context(self)

-         data.update(root_view.get_extra_context(self))

-         data.pop('widget', None)

-         data.pop('widget_instance', None)

-         return {

-             'id': self.idx,

-             # TODO -- use flask.url_for to get the url for this widget

-             'plugin': self.plugin,

-             'description': module.__doc__,

-             'hub': self.hub_id,

-             'left': self.left,

-             'index': self.index,

-             'data': data,

-             'config': self.config,

-         }

- 

-     def __repr__(self):

-         return "<Widget %s /%s/%i>" % (self.plugin, self.hub.name, self.idx)

- 

-     @property

-     def module(self):

-         return hubs.widgets.registry[self.plugin]

- 

-     def get_props(self, with_secret_config=False):

-         return self.module.get_props(self, with_secret_config)

- 

-     @property

-     def enabled(self):

-         return self.plugin in hubs.widgets.registry

- 

- 

- class User(BASE):

-     __tablename__ = 'users'

-     username = sa.Column(sa.Text, primary_key=True)

-     fullname = sa.Column(sa.Text)

-     created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

-     saved_notifications = relation('SavedNotification', backref='users',

-                                    lazy='dynamic')

- 

-     def __json__(self):

-         return {

-             'username': self.username,

-             'avatar': username2avatar(self.username),

-             'fullname': self.fullname,

-             'created_on': self.created_on,

-             # We'll need hubs subscribed to, owned, etc..

-             # 'hubs': [hub.idx for hub in self.hubx],

-         }

- 

-     @property

-     def ownerships(self):

-         return [assoc.hub for assoc in self.associations

-                 if assoc.role == 'owner']

- 

-     @property

-     def memberships(self):

-         return [assoc.hub for assoc in self.associations

-                 if assoc.role == 'member' or assoc.role == 'owner']

- 

-     @property

-     def subscriptions(self):

-         return [assoc.hub for assoc in self.associations

-                 if assoc.role == 'subscriber']

- 

-     @property

-     def starred_hubs(self):

-         return [assoc.hub for assoc in self.associations

-                 if assoc.role == 'stargazer']

- 

-     @property

-     def bookmarks(self):

-         bookmarks = {

-             "starred": [],

-             "memberships": [],

-             "subscriptions": [],

-         }

-         starred_hubs = self.starred_hubs

-         memberships = self.memberships

-         for assoc in self.associations:

-             if assoc.hub.name == self.username:

-                 continue

- 

-             if assoc.role == "stargazer":

-                 bookmarks["starred"].append(assoc.hub)

- 

-             if ((assoc.role == "member" or assoc.role == "owner")

-                     and assoc.hub not in starred_hubs):

-                 bookmarks["memberships"].append(assoc.hub)

- 

-             if (assoc.role == "subscriber"

-                     and assoc.hub not in starred_hubs

-                     and assoc.hub not in memberships):

-                 bookmarks["subscriptions"].append(assoc.hub)

- 

-         bookmarks = dict(

-             (key, sorted(list(set(values)), key=operator.attrgetter('name')))

-             for key, values in bookmarks.items()

-         )

-         return bookmarks

- 

-     @classmethod

-     def by_username(cls, username):

-         return cls.query.filter_by(username=username).first()

- 

-     get = by_username

- 

-     @classmethod

-     def all(cls):

-         return cls.query.all()

- 

-     @classmethod

-     def get_or_create(cls, username, fullname):

-         if not username:

-             raise ValueError("Must provide an username, not %r" % username)

-         self = cls.query.get(username)

-         if self is None:

-             self = cls.create(username, fullname)

-         return self

- 

-     @classmethod

-     def create(cls, username, fullname):

-         session = Session()

-         self = cls(username=username, fullname=fullname)

-         session.add(self)

-         session.flush()

-         user_created.send(self)

-         return self

- 

-     def on_created(self):

-         if Hub.query.get(self.username) is None:

-             Hub.create_user_hub(self.username, self.fullname)

- 

- 

- class VisitCounter(BASE):

-     __tablename__ = 'visit_counter'

-     count = sa.Column(sa.Integer, default=0, nullable=False)

- 

-     visited_hub = sa.Column(sa.String(50), sa.ForeignKey('hubs.name'),

-                             primary_key=True)

- 

-     username = sa.Column(sa.Text, sa.ForeignKey('users.username'),

-                          primary_key=True)

- 

-     user = relation("User", backref=backref(

-                     'visit_counters', cascade="all, delete, delete-orphan"))

-     hub = relation("Hub", backref=backref(

-                    'visit_counters', cascade="all, delete, delete-orphan"))

- 

-     @classmethod

-     def by_username(cls, username):

-         return cls.query.filter_by(username=username).all()

- 

-     @classmethod

-     def get_visits_by_username_hub(cls, username, visited_hub):

-         return cls.query.filter_by(

-             username=username, visited_hub=visited_hub).first()

- 

-     @classmethod

-     def increment_visits(cls, username, visited_hub):

-         row = cls.get_or_create(username=username,

-                                 visited_hub=visited_hub)

-         row.count += 1

- 

-     @classmethod

-     def get_or_create(cls, username, visited_hub):

-         if not username:

-             raise ValueError("Must provide an username, not %r" % username)

-         if not visited_hub:

-             raise ValueError("Must provide an hub, not %r" % visited_hub)

-         hub_exists = Hub.query.get(visited_hub) is not None

-         user_exists = User.query.get(username) is not None

-         if not hub_exists or not user_exists:

-             raise ValueError("Must provide a hub/user that exists")

- 

-         self = cls.query.filter_by(

-             username=username, visited_hub=visited_hub).first()

-         if self is None:

-             session = Session()

-             self = cls(username=username, visited_hub=visited_hub)

-             session.add(self)

-             session.flush()

-         return self

- 

- 

- class SavedNotification(BASE):

-     __tablename__ = 'savednotifications'

-     user = sa.Column(sa.Text, sa.ForeignKey('users.username'))

- 

-     created = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

-     dom_id = sa.Column(sa.Text)

-     idx = sa.Column(sa.Integer, primary_key=True)

-     link = sa.Column(sa.Text)

-     markup = sa.Column(sa.Text)

-     secondary_icon = sa.Column(sa.Text)

- 

-     def __init__(self, username=None, markup='', link='', secondary_icon='',

-                  dom_id=''):

-         self.user = username

-         self.markup = markup

-         self.link = link

-         self.secondary_icon = secondary_icon

-         self.dom_id = dom_id

- 

-     def __json__(self):

-         return {

-             'created': str(self.created),

-             'date_time': str(self.created),

-             'dom_id': self.dom_id,

-             'idx': self.idx,

-             'link': bleach.linkify(self.link),

-             'markup': bleach.linkify(self.markup),

-             'saved': True,

-             'secondary_icon': self.secondary_icon

-         }

- 

-     @classmethod

-     def by_username(cls, username):

-         return cls.query.filter_by(user=username).all()

- 

-     @classmethod

-     def all(cls):

-         return cls.query.all()

@@ -0,0 +1,30 @@ 

+ # -*- coding: utf-8 -*-

+ #

+ # Copyright © 2017  Red Hat, Inc.

+ #

+ # This copyrighted material is made available to anyone wishing to use,

+ # modify, copy, or redistribute it subject to the terms and conditions

+ # of the GNU Lesser General Public License (LGPL) version 2, or

+ # (at your option) any later version.  This program is distributed in the

+ # hope that it will be useful, but WITHOUT ANY WARRANTY expressed or

+ # implied, including the implied warranties of MERCHANTABILITY or FITNESS

+ # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

+ # more details.  You should have received a copy of the GNU Lesser General

+ # Public License along with this program; if not, write to the Free

+ # Software Foundation, Inc.,

+ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

+ #

+ # Any Red Hat trademarks that are incorporated in the source

+ # code or documentation are not subject to the GNU General Public

+ # License and may only be used or replicated with the express permission

+ # of Red Hat, Inc.

+ #

+ 

+ from __future__ import unicode_literals

+ 

+ from .association import Association  # noqa: F401

+ from .hub import Hub  # noqa: F401

+ from .hubconfig import HubConfig  # noqa: F401

+ from .widget import Widget  # noqa: F401

+ from .user import User  # noqa: F401

+ from .savednotification import SavedNotification  # noqa: F401

@@ -0,0 +1,62 @@ 

+ # -*- coding: utf-8 -*-

+ #

+ # Copyright © 2017  Red Hat, Inc.

+ #

+ # This copyrighted material is made available to anyone wishing to use,

+ # modify, copy, or redistribute it subject to the terms and conditions

+ # of the GNU Lesser General Public License (LGPL) version 2, or

+ # (at your option) any later version.  This program is distributed in the

+ # hope that it will be useful, but WITHOUT ANY WARRANTY expressed or

+ # implied, including the implied warranties of MERCHANTABILITY or FITNESS

+ # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

+ # more details.  You should have received a copy of the GNU Lesser General

+ # Public License along with this program; if not, write to the Free

+ # Software Foundation, Inc.,

+ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

+ #

+ # Any Red Hat trademarks that are incorporated in the source

+ # code or documentation are not subject to the GNU General Public

+ # License and may only be used or replicated with the express permission

+ # of Red Hat, Inc.

+ #

+ 

+ from __future__ import unicode_literals

+ 

+ import logging

+ 

+ import sqlalchemy as sa

+ from sqlalchemy.orm import relation

+ from sqlalchemy.orm import backref

+ 

+ from hubs.database import BASE

+ from .constants import ROLES

+ 

+ 

+ log = logging.getLogger(__name__)

+ 

+ 

+ class Association(BASE):

+ 

+     __tablename__ = 'association'

+ 

+     hub_id = sa.Column(sa.String(50),

+                        sa.ForeignKey('hubs.name'),

+                        primary_key=True)

+     user_id = sa.Column(sa.Text,

+                         sa.ForeignKey('users.username'),

+                         primary_key=True)

+     role = sa.Column(

+         sa.Enum(*ROLES, name="roles"), primary_key=True)

+ 

+     user = relation("User", backref=backref(

+         'associations', cascade="all, delete, delete-orphan"))

+     hub = relation("Hub", backref=backref(

+         'associations', cascade="all, delete, delete-orphan"))

+ 

+     @classmethod

+     def get(cls, hub, user, role):

+         return cls.query\

+             .filter_by(hub=hub)\

+             .filter_by(user=user)\

+             .filter_by(role=role)\

+             .first()

@@ -0,0 +1,40 @@ 

+ # -*- coding: utf-8 -*-

+ #

+ # Copyright © 2017  Red Hat, Inc.

+ #

+ # This copyrighted material is made available to anyone wishing to use,

+ # modify, copy, or redistribute it subject to the terms and conditions

+ # of the GNU Lesser General Public License (LGPL) version 2, or

+ # (at your option) any later version.  This program is distributed in the

+ # hope that it will be useful, but WITHOUT ANY WARRANTY expressed or

+ # implied, including the implied warranties of MERCHANTABILITY or FITNESS

+ # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

+ # more details.  You should have received a copy of the GNU Lesser General

+ # Public License along with this program; if not, write to the Free

+ # Software Foundation, Inc.,

+ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

+ #

+ # Any Red Hat trademarks that are incorporated in the source

+ # code or documentation are not subject to the GNU General Public

+ # License and may only be used or replicated with the express permission

+ # of Red Hat, Inc.

+ #

+ 

+ from __future__ import unicode_literals

+ 

+ 

+ ROLES = ('subscriber', 'member', 'owner', 'stargazer')

+ 

+ VISIBILITIES = ("public", "preview", "private")

+ 

+ DEV_PLATFORMS = (

+     {

+         "name": "pagure",

+         "display_name": "Pagure",

+         "url": "https://pagure.io",

+     }, {

+         "name": "github",

+         "display_name": "Github",

+         "url": "https://github.com",

+     },

+ )

file added
+290
@@ -0,0 +1,290 @@ 

+ # -*- coding: utf-8 -*-

+ #

+ # Copyright © 2017  Red Hat, Inc.

+ #

+ # This copyrighted material is made available to anyone wishing to use,

+ # modify, copy, or redistribute it subject to the terms and conditions

+ # of the GNU Lesser General Public License (LGPL) version 2, or

+ # (at your option) any later version.  This program is distributed in the

+ # hope that it will be useful, but WITHOUT ANY WARRANTY expressed or

+ # implied, including the implied warranties of MERCHANTABILITY or FITNESS

+ # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

+ # more details.  You should have received a copy of the GNU Lesser General

+ # Public License along with this program; if not, write to the Free

+ # Software Foundation, Inc.,

+ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

+ #

+ # Any Red Hat trademarks that are incorporated in the source

+ # code or documentation are not subject to the GNU General Public

+ # License and may only be used or replicated with the express permission

+ # of Red Hat, Inc.

+ #

+ 

+ from __future__ import unicode_literals

+ 

+ import datetime

+ import logging

+ 

+ import flask

+ import sqlalchemy as sa

+ from sqlalchemy.orm import relation

+ from sqlalchemy.orm.session import object_session

+ 

+ import hubs.defaults

+ from hubs.authz import ObjectAuthzMixin, AccessLevel

+ from hubs.database import BASE, Session

+ from hubs.utils import username2avatar

+ from hubs.signals import hub_created

+ from .association import Association

+ from .constants import ROLES

+ from .hubconfig import HubConfigProxy

+ from .user import User

+ 

+ 

+ log = logging.getLogger(__name__)

+ 

+ 

+ class Hub(ObjectAuthzMixin, BASE):

+ 

+     __tablename__ = 'hubs'

+ 

+     name = sa.Column(sa.String(50), primary_key=True)

+     created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

+     widgets = relation('Widget', cascade='all,delete', backref='hub',

+                        order_by="Widget.index")

+     user_hub = sa.Column(sa.Boolean, default=False)

+     # Timestamps about various kinds of "freshness"

+     last_refreshed = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

+     last_edited = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

+     config_values = relation(

+         'HubConfig', backref="hub", cascade='all,delete-orphan')

+ 

+     # fas_group = sa.Column(sa.String(32), nullable=False)

+ 

+     @property

+     def config(self):

+         return HubConfigProxy(self)

+ 

+     @config.setter

+     def config(self, config):

+         proxy = HubConfigProxy(self)

+         proxy.clear()

+         proxy.update(config)

+ 

+     @property

+     def days_idle(self):

+         return (datetime.datetime.utcnow() - self.last_refreshed).days

+ 

+     @property

+     def activity_class(self):

+         idle = self.days_idle

+         limits = [

+             (356 * 5,   '5years'),

+             (356 * 2,   '2years'),

+             (356,       'year'),

+             (31 * 3,    'quarter'),

+             (31,        'month'),

+             (7,         'week'),

+             (1,         'day'),

+             (0,         'none'),

+         ]

+         for limit, name in limits:

+             if idle > limit:

+                 return name

+ 

+     @property

+     def owners(self):

+         return [assoc.user for assoc in self.associations

+                 if assoc.role == 'owner']

+ 

+     @property

+     def members(self):

+         return [assoc.user for assoc in self.associations

+                 if assoc.role == 'member' or assoc.role == 'owner']

+ 

+     @property

+     def subscribers(self):

+         return [assoc.user for assoc in self.associations

+                 if assoc.role == 'subscriber']

+ 

+     @property

+     def stargazers(self):

+         return [assoc.user for assoc in self.associations

+                 if assoc.role == 'stargazer']

+ 

+     def subscribe(self, user, role='subscriber'):

+         """ Subscribe a user to this hub. """

+         # TODO -- add logic here to manage not adding the user multiple

+         # times, doing different roles, etc.. publish a fedmsg message,

+         # etc...

+         session = object_session(self)

+         session.add(Association(user=user, hub=self, role=role))

+         session.commit()

+ 

+     def unsubscribe(self, user, role='subscriber'):

+         """ Unsubscribe a user to this hub. """

+         # TODO -- add logic here to manage not adding the user multiple

+         # times, doing different roles, etc.. publish a fedmsg message,

+         # etc...

+         session = object_session(self)

+         association = Association.get(hub=self, user=user, role=role)

+         if not association:

+             raise KeyError("%r is not a %r of %r" % (user, role, self))

+         if role == 'owner':

+             # When stepping down from an owner, turn into a member.

+             is_member = bool(Association.query.filter_by(

+                 hub=self, user=user, role="member").count())

+             if is_member:

+                 session.delete(association)

+             else:

+                 association.role = 'member'

+         else:

+             session.delete(association)

+         session.commit()

+ 

+     @classmethod

+     def by_name(cls, name):

+         return cls.query.filter_by(name=name).first()

+ 

+     get = by_name

+ 

+     @classmethod

+     def all_group_hubs(cls):

+         return cls.query.filter_by(user_hub=False).all()

+ 

+     @classmethod

+     def all_user_hubs(cls):

+         return cls.query.filter_by(user_hub=True).all()

+ 

+     @classmethod

+     def create_user_hub(cls, username, fullname):

+         session = Session()

+         hub = cls(name=username, user_hub=True)

+         session.add(hub)

+         hub.config["summary"] = fullname

+         hub.config["avatar"] = username2avatar(username)

+         session.flush()

+         hub_created.send(hub)

+         return hub

+ 

+     @classmethod

+     def create_group_hub(cls, name, summary, **extra):

+         session = Session()

+         hub = cls(name=name, user_hub=False)

+         session.add(hub)

+         hub.config["summary"] = summary

+         if extra.get("irc_channel") and extra.get("irc_network"):

+             hub.config["chat_domain"] = extra["irc_network"]

+             hub.config["chat_channel"] = extra["irc_channel"]

+         if extra.get("mailing_list"):

+             hub.config["mailing_list"] = extra["mailing_list"]

+         session.flush()

+         hub_created.send(hub, **extra)

+         return hub

+ 

+     def on_created(self, **extra):

+         if self.user_hub:

+             hubs.defaults.add_user_widgets(self)

+             user = User.query.get(self.name)

+             self.subscribe(user, role='owner')

+         else:

+             hubs.defaults.add_group_widgets(self, **extra)

+ 

+     def on_updated(self, old_config):

+         for widget_instance in self.widgets:

+             if not widget_instance.enabled:

+                 continue

+             widget = widget_instance.module

+             new_config = self.config.to_dict()

+             will_reload = False

+             cached_functions = widget.get_cached_functions()

+             for fn_name, fn_class in cached_functions.items():

+                 fn = fn_class(widget_instance)

+                 if fn.should_invalidate_on_hub_config_change(old_config):

+                     flask.g.task_queue.enqueue(

+                         "widget-cache",

+                         idx=widget_instance.idx,

+                         hub=self.name,

+                         fn_name=fn_name,

+                         )

+                     will_reload = True

+             if not will_reload:

+                 # Reload the widget if it has asked for it.

+                 if widget.should_reload_on_hub_config_change(

+                         old_config, new_config):

+                     flask.g.task_queue.enqueue(

+                         "widget-update",

+                         idx=widget_instance.idx,

+                         hub=self.name,

+                         )

+ 

+     def _get_auth_user_access_level(self, user):

+         # overridden to handle user hubs.

+         if self.user_hub and user.username == self.name:

+             return AccessLevel.owner

+         return super(Hub, self)._get_auth_user_access_level(user)

+ 

+     def _get_auth_group(self):

+         # While we're local-only, just use the hub name:

+         return self.name

+         # When the CAIAPI is in place we will be able to use the auth_group

+         # setting:

+         # group = self.config["auth_group"]

+         # if group is None:

+         #     group = self.name

+         # return group

+ 

+     def _get_auth_user_roles(self, user):

+         """Override until we can get groups from FAS.

+ 

+         This will not return all roles, only the one for the current hub, but

+         that fine since it's the only one we're interested in when this method

+         is called.

+         """

+         roles = [

+             assoc.role for assoc in self.associations

+             if assoc.user.username == user.username

+         ]

+         return {self.name: roles}

+ 

+     def _get_auth_permission_name(self, action):

+         if action == "view":

+             action = "{}.view".format(self.config["visibility"])

+         return "hub.{}".format(action)

+ 

+     def get_props(self):

+         """Get the hub properties for the Javascript UI"""

+         result = {

+             "name": self.name,

+             "config": self.config.to_dict(),

+             "users": {role: [] for role in ROLES},

+             "mtime": self.last_refreshed,

+             "user_hub": self.user_hub,

+         }

+         for assoc in sorted(self.associations, key=lambda a: a.user.username):

+             if assoc.role not in ROLES:

+                 continue

+             result["users"][assoc.role].append(assoc.user.__json__())

+         if self.user_hub:

+             user = User.query.get(self.name)

+             if user is None:

+                 result["subscribed_to"] = []

+             else:

+                 result["subscribed_to"] = [

+                     assoc.hub.name for assoc in Association.query.filter_by(

+                         user=user, role="subscriber")

+                     ]

+         return result

+ 

+     def __json__(self):

+         return {

+             'name': self.name,

+             'archived': self.archived,

+             'config': self.config.to_dict(),

+ 

+             'widgets': [widget.idx for widget in self.widgets],

+ 

+             'owners': [u.username for u in self.owners],

+             'members': [u.username for u in self.members],

+             'subscribers': [u.username for u in self.subscribers],

+         }

@@ -0,0 +1,247 @@ 

+ # -*- coding: utf-8 -*-

+ #

+ # Copyright © 2017  Red Hat, Inc.

+ #

+ # This copyrighted material is made available to anyone wishing to use,

+ # modify, copy, or redistribute it subject to the terms and conditions

+ # of the GNU Lesser General Public License (LGPL) version 2, or

+ # (at your option) any later version.  This program is distributed in the

+ # hope that it will be useful, but WITHOUT ANY WARRANTY expressed or

+ # implied, including the implied warranties of MERCHANTABILITY or FITNESS

+ # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

+ # more details.  You should have received a copy of the GNU Lesser General

+ # Public License along with this program; if not, write to the Free

+ # Software Foundation, Inc.,

+ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

+ #

+ # Any Red Hat trademarks that are incorporated in the source

+ # code or documentation are not subject to the GNU General Public

+ # License and may only be used or replicated with the express permission

+ # of Red Hat, Inc.

+ #

+ 

+ from __future__ import unicode_literals

+ 

+ import logging

+ try:

+     from collections.abc import MutableMapping

+ except ImportError:

+     # Python 2

+     from collections import MutableMapping

+ 

+ import six

+ import sqlalchemy as sa

+ from sqlalchemy.orm.session import object_session

+ 

+ from hubs.database import BASE

+ from hubs.utils import validators

+ from .constants import DEV_PLATFORMS, VISIBILITIES

+ 

+ 

+ log = logging.getLogger(__name__)

+ 

+ 

+ class Converter(object):

+     """

+     Converts a value between its Python value and its

+     database-serialized value.

+     """

+ 

+     def __init__(self, func=None):

+         if func is None:

+             self.func = lambda v: v

+         else:

+             self.func = func

+ 

+     def from_db(self, value):

+         return self.func(value)

+ 

+     def to_db(self, value):

+         return six.text_type(value)

+ 

+ 

+ class BooleanConverter(Converter):

+ 

+     def __init__(self):

+         self.func = bool

+ 

+     def to_db(self, value):

+         if value:

+             return "True"

+         else:

+             return ""

+ 

+ 

+ class EnumConverter(Converter):

+ 

+     def __init__(self, allowed_values):

+         self.func = lambda v: v

+         self.allowed_values = allowed_values

+ 

+     def to_db(self, value):

+         if value not in self.allowed_values:

+             raise ValueError("{} is not in {}".format(

+                 value, repr(self.allowed_values)))

+         return super(EnumConverter, self).to_db(value)

+ 

+ 

+ class HubConfigProxy(MutableMapping):

+ 

+     KEYS = (

+         "archived", "summary", "left_width", "avatar", "visibility",

+         "chat_domain", "chat_channel", "mailing_list", "calendar",

+     ) + tuple(p["name"] for p in DEV_PLATFORMS)

+     CONVERTERS = {

+         "archived": BooleanConverter(),

+         "left_width": Converter(int),

+         "visibility": EnumConverter(VISIBILITIES),

+     }

+     # Default is None if not specified here:

+     DEFAULTS = {

+         "archived": False,

+         "summary": "",

+         "visibility": "public",

+         "left_width": 8,

+         "avatar": "",

+     }

+     LISTS = ("pagure", "github")

+     VALIDATORS = {

+         "github": validators.GithubOrgAndRepo,

+         "pagure": validators.PagureRepo,

+         "chat_domain": validators.ChatDomain,

+         "mailing_list": validators.Email,

+     }

+ 

+     def __init__(self, hub):

+         self.hub = hub

+         self.db = object_session(hub)

+ 

+     def __getitem__(self, key):

+         if key not in self.KEYS:

+             raise KeyError

+         converter = self.CONVERTERS.get(key, Converter())

+         query = self.db.query(HubConfig.value).filter_by(hub=self.hub, key=key)

+         if key in self.LISTS:

+             return [

+                 converter.from_db(r[0])

+                 for r in query.order_by(HubConfig.value)

+             ]

+         else:

+             try:

+                 return converter.from_db(query.one()[0])

+             except sa.orm.exc.NoResultFound:

+                 return self.DEFAULTS.get(key)

+             # Raise an exception on MultipleResultsFound, this should not

+             # happen if the key is not in LISTS.

+ 

+     def __setitem__(self, key, value):

+         converter = self.CONVERTERS.get(key, Converter())

+         if key in self.LISTS:

+             self.db.query(HubConfig).filter_by(hub=self.hub, key=key).delete()

+             for item in value:

+                 self.db.add(HubConfig(

+                     hub=self.hub, key=key, value=converter.to_db(item)))

+         else:

+             value = converter.to_db(value)

+             try:

+                 config = self.db.query(HubConfig).filter_by(

+                     hub=self.hub, key=key).one()

+             except sa.orm.exc.NoResultFound:

+                 config = self.db.add(HubConfig(

+                     hub=self.hub, key=key, value=value))

+             else:

+                 config.value = value

+         self.db.flush()

+ 

+     def __delitem__(self, key):

+         self.db.query(HubConfig).filter_by(hub=self.hub, key=key).delete()

+ 

+     def __iter__(self):

+         return self.KEYS.__iter__()

+ 

+     def __len__(self):

+         return len(self.KEYS)

+ 

+     def to_dict(self):

+         result = {}

+         for conf in self.db.query(HubConfig).filter_by(hub=self.hub):

+             converter = self.CONVERTERS.get(conf.key, Converter())

+             if conf.key in self.LISTS:

+                 if conf.key not in result:

+                     result[conf.key] = []

+                 result[conf.key].append(converter.from_db(conf.value))

+             else:

+                 result[conf.key] = converter.from_db(conf.value)

+         # Add defaults

+         for key in self.KEYS:

+             if key not in result:

+                 if key in self.LISTS:

+                     result[key] = []

+                 else:

+                     result[key] = self.DEFAULTS.get(key)

+         return result

+ 

+     def validate(self, config):

+         # Raise ValueError if the new config does not validate.

+         current_config = self.to_dict()

+         validated = {}

+         errors = {}

+         for key, value in config.items():

+             if key not in self.KEYS:

+                 raise ValueError("Invalid config key: {}".format(key))

+             if key not in self.VALIDATORS:

+                 validated[key] = value

+                 continue

+             try:

+                 if key in self.LISTS:

+                     validated[key] = []

+                     for v in value:

+                         if v in current_config[key]:

+                             # optimization: don't validate if it's already in

+                             # the config.

+                             validated[key].append(v)

+                             continue

+                         validated[key] = self.VALIDATORS[key](v)

+                 else:

+                     if current_config[key] == value:

+                         # optimization: don't validate if it's already in

+                         # the config.

+                         validated[key] = value

+                         continue

+                     validated[key] = self.VALIDATORS[key](value)

+             except ValueError as e:

+                 if key in ("pagure", "github"):

+                     # This is not very pretty. Sorry.

+                     key = "devplatform_project"

+                 errors[key] = e.args[0]

+         if errors:

+             raise ValueError(errors)

+         return validated

+ 

+     # Methods below are not necessary but are optimizations

+ 

+     def items(self):

+         # Avoid making multiple DB queries.

+         return self.to_dict().items()

+ 

+     def values(self):

+         # Avoid making multiple DB queries.

+         return self.to_dict().values()

+ 

+     def clear(self):

+         self.db.query(HubConfig).filter_by(hub=self.hub).delete()

+ 

+     def __contains__(self, key):

+         # Avoid calling __getitem__

+         return key in self.KEYS

+ 

+ 

+ class HubConfig(BASE):

+ 

+     __tablename__ = 'hubs_config'

+ 

+     id = sa.Column(sa.Integer, primary_key=True)

+     hub_id = sa.Column(

+         sa.String(50), sa.ForeignKey('hubs.name'), index=True, nullable=False)

+     key = sa.Column(sa.String(256), index=True, nullable=False)

+     value = sa.Column(sa.Text, index=True, nullable=False)

@@ -0,0 +1,75 @@ 

+ # -*- coding: utf-8 -*-

+ #

+ # Copyright © 2017  Red Hat, Inc.

+ #

+ # This copyrighted material is made available to anyone wishing to use,

+ # modify, copy, or redistribute it subject to the terms and conditions

+ # of the GNU Lesser General Public License (LGPL) version 2, or

+ # (at your option) any later version.  This program is distributed in the

+ # hope that it will be useful, but WITHOUT ANY WARRANTY expressed or

+ # implied, including the implied warranties of MERCHANTABILITY or FITNESS

+ # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

+ # more details.  You should have received a copy of the GNU Lesser General

+ # Public License along with this program; if not, write to the Free

+ # Software Foundation, Inc.,

+ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

+ #

+ # Any Red Hat trademarks that are incorporated in the source

+ # code or documentation are not subject to the GNU General Public

+ # License and may only be used or replicated with the express permission

+ # of Red Hat, Inc.

+ #

+ 

+ from __future__ import unicode_literals

+ 

+ import datetime

+ import logging

+ 

+ import bleach

+ import sqlalchemy as sa

+ 

+ from hubs.database import BASE

+ 

+ 

+ log = logging.getLogger(__name__)

+ 

+ 

+ class SavedNotification(BASE):

+ 

+     __tablename__ = 'savednotifications'

+ 

+     user = sa.Column(sa.Text, sa.ForeignKey('users.username'))

+     created = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

+     dom_id = sa.Column(sa.Text)

+     idx = sa.Column(sa.Integer, primary_key=True)

+     link = sa.Column(sa.Text)

+     markup = sa.Column(sa.Text)

+     secondary_icon = sa.Column(sa.Text)

+ 

+     def __init__(self, username=None, markup='', link='', secondary_icon='',

+                  dom_id=''):

+         self.user = username

+         self.markup = markup

+         self.link = link

+         self.secondary_icon = secondary_icon

+         self.dom_id = dom_id

+ 

+     def __json__(self):

+         return {

+             'created': str(self.created),

+             'date_time': str(self.created),

+             'dom_id': self.dom_id,

+             'idx': self.idx,

+             'link': bleach.linkify(self.link),

+             'markup': bleach.linkify(self.markup),

+             'saved': True,

+             'secondary_icon': self.secondary_icon

+         }

+ 

+     @classmethod

+     def by_username(cls, username):

+         return cls.query.filter_by(user=username).all()

+ 

+     @classmethod

+     def all(cls):

+         return cls.query.all()

file added
+139
@@ -0,0 +1,139 @@ 

+ # -*- coding: utf-8 -*-

+ #

+ # Copyright © 2017  Red Hat, Inc.

+ #

+ # This copyrighted material is made available to anyone wishing to use,

+ # modify, copy, or redistribute it subject to the terms and conditions

+ # of the GNU Lesser General Public License (LGPL) version 2, or

+ # (at your option) any later version.  This program is distributed in the

+ # hope that it will be useful, but WITHOUT ANY WARRANTY expressed or

+ # implied, including the implied warranties of MERCHANTABILITY or FITNESS

+ # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

+ # more details.  You should have received a copy of the GNU Lesser General

+ # Public License along with this program; if not, write to the Free

+ # Software Foundation, Inc.,

+ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

+ #

+ # Any Red Hat trademarks that are incorporated in the source

+ # code or documentation are not subject to the GNU General Public

+ # License and may only be used or replicated with the express permission

+ # of Red Hat, Inc.

+ #

+ 

+ from __future__ import unicode_literals

+ 

+ import datetime

+ import logging

+ import operator

+ 

+ import sqlalchemy as sa

+ from sqlalchemy.orm import relation

+ 

+ from hubs.database import BASE, Session

+ from hubs.utils import username2avatar

+ from hubs.signals import user_created

+ 

+ log = logging.getLogger(__name__)

+ 

+ 

+ class User(BASE):

+     __tablename__ = 'users'

+     username = sa.Column(sa.Text, primary_key=True)

+     fullname = sa.Column(sa.Text)

+     created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

+     saved_notifications = relation('SavedNotification', backref='users',

+                                    lazy='dynamic')

+ 

+     def __json__(self):

+         return {

+             'username': self.username,

+             'avatar': username2avatar(self.username),

+             'fullname': self.fullname,

+             'created_on': self.created_on,

+             # We'll need hubs subscribed to, owned, etc..

+             # 'hubs': [hub.idx for hub in self.hubx],

+         }

+ 

+     @property

+     def ownerships(self):

+         return [assoc.hub for assoc in self.associations

+                 if assoc.role == 'owner']

+ 

+     @property

+     def memberships(self):

+         return [assoc.hub for assoc in self.associations

+                 if assoc.role == 'member' or assoc.role == 'owner']

+ 

+     @property

+     def subscriptions(self):

+         return [assoc.hub for assoc in self.associations

+                 if assoc.role == 'subscriber']

+ 

+     @property

+     def starred_hubs(self):

+         return [assoc.hub for assoc in self.associations

+                 if assoc.role == 'stargazer']

+ 

+     @property

+     def bookmarks(self):

+         bookmarks = {

+             "starred": [],

+             "memberships": [],

+             "subscriptions": [],

+         }

+         starred_hubs = self.starred_hubs

+         memberships = self.memberships

+         for assoc in self.associations:

+             if assoc.hub.name == self.username:

+                 continue

+ 

+             if assoc.role == "stargazer":

+                 bookmarks["starred"].append(assoc.hub)

+ 

+             if ((assoc.role == "member" or assoc.role == "owner")

+                     and assoc.hub not in starred_hubs):

+                 bookmarks["memberships"].append(assoc.hub)

+ 

+             if (assoc.role == "subscriber"

+                     and assoc.hub not in starred_hubs

+                     and assoc.hub not in memberships):

+                 bookmarks["subscriptions"].append(assoc.hub)

+ 

+         bookmarks = dict(

+             (key, sorted(list(set(values)), key=operator.attrgetter('name')))

+             for key, values in bookmarks.items()

+         )

+         return bookmarks

+ 

+     @classmethod

+     def by_username(cls, username):

+         return cls.query.filter_by(username=username).first()

+ 

+     get = by_username

+ 

+     @classmethod

+     def all(cls):

+         return cls.query.all()

+ 

+     @classmethod

+     def get_or_create(cls, username, fullname):

+         if not username:

+             raise ValueError("Must provide an username, not %r" % username)

+         self = cls.query.get(username)

+         if self is None:

+             self = cls.create(username, fullname)

+         return self

+ 

+     @classmethod

+     def create(cls, username, fullname):

+         session = Session()

+         self = cls(username=username, fullname=fullname)

+         session.add(self)

+         session.flush()

+         user_created.send(self)

+         return self

+ 

+     def on_created(self):

+         from .hub import Hub

+         if Hub.query.get(self.username) is None:

+             Hub.create_user_hub(self.username, self.fullname)

file added
+173
@@ -0,0 +1,173 @@ 

+ # -*- coding: utf-8 -*-

+ #

+ # Copyright © 2017  Red Hat, Inc.

+ #

+ # This copyrighted material is made available to anyone wishing to use,

+ # modify, copy, or redistribute it subject to the terms and conditions

+ # of the GNU Lesser General Public License (LGPL) version 2, or

+ # (at your option) any later version.  This program is distributed in the

+ # hope that it will be useful, but WITHOUT ANY WARRANTY expressed or

+ # implied, including the implied warranties of MERCHANTABILITY or FITNESS

+ # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

+ # more details.  You should have received a copy of the GNU Lesser General

+ # Public License along with this program; if not, write to the Free

+ # Software Foundation, Inc.,

+ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

+ #

+ # Any Red Hat trademarks that are incorporated in the source

+ # code or documentation are not subject to the GNU General Public

+ # License and may only be used or replicated with the express permission

+ # of Red Hat, Inc.

+ #

+ 

+ from __future__ import unicode_literals

+ 

+ import datetime

+ import json

+ import logging

+ from collections import defaultdict

+ 

+ import flask

+ import sqlalchemy as sa

+ 

+ import hubs.widgets

+ from hubs.authz import ObjectAuthzMixin

+ from hubs.database import BASE

+ 

+ 

+ log = logging.getLogger(__name__)

+ 

+ 

+ class SpecificDefaultDict(defaultdict):

+     """A more specific version of defaultdict.

+ 

+     This class behaves like defaultdict, but calls the ``default_factory`` with

+     the key as first argument.

+     """

+ 

+     def __missing__(self, key):

+         if self.default_factory is None:

+             return super(SpecificDefaultDict, self).__missing__(key)

+         self[key] = self.default_factory(key)

+         return self[key]

+ 

+ 

+ class Widget(ObjectAuthzMixin, BASE):

+ 

+     __tablename__ = 'widgets'

+ 

+     VISIBILITY = ["public", "restricted"]

+ 

+     idx = sa.Column(sa.Integer, primary_key=True)

+     plugin = sa.Column(sa.String(50), nullable=False)

+     created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

+     hub_id = sa.Column(sa.String(50), sa.ForeignKey('hubs.name'))

+     _config = sa.Column(sa.Text, default="{}")

+ 

+     index = sa.Column(sa.Integer, nullable=False)

+     left = sa.Column(sa.Boolean, nullable=False, default=False)

+     visibility = sa.Column(

+         sa.Enum(*VISIBILITY, name="widget_visibility"),

+         default="public", nullable=False)

+ 

+     @classmethod

+     def by_idx(cls, idx):

+         return cls.query.filter_by(idx=idx).first()

+ 

+     @classmethod

+     def by_plugin(cls, plugin):

+         return cls.query.filter_by(plugin=plugin).first()

+ 

+     @classmethod

+     def by_hub_id_all(cls, hub_id):

+         return cls.query.filter_by(hub_id=hub_id).all()

+ 

+     get = by_idx

+ 

+     @property

+     def config(self):

+         def get_default(key):

+             for param in self.module.get_parameters():

+                 if key == param.name:

+                     break

+             else:

+                 raise KeyError("No such parameter")

+             return param.default

+ 

+         value = SpecificDefaultDict(get_default)

+         value.update(json.loads(self._config))

+         return value

+ 

+     @config.setter

+     def config(self, config):

+         self._config = json.dumps(config)

+ 

+     def on_updated(self, old_config):

+         will_reload = False

+         cached_functions = self.module.get_cached_functions()

+         for fn_name, fn_class in cached_functions.items():

+             fn = fn_class(self)

+             if fn.should_invalidate_on_widget_config_change(old_config):

+                 flask.g.task_queue.enqueue(

+                     "widget-cache",

+                     idx=self.idx,

+                     hub=self.hub.name,

+                     fn_name=fn_name,

+                     )

+                 will_reload = True

+         if not will_reload:

+             # Reload the widget nonetheless because the config

+             # change may impact rendering.

+             flask.g.task_queue.enqueue(

+                 "widget-update",

+                 idx=self.idx,

+                 hub=self.hub.name,

+                 )

+ 

+     def _get_auth_access_level(self, user):

+         return self.hub._get_auth_access_level(user)

+ 

+     def _get_auth_user_roles(self, user):

+         """Override until we can get groups from FAS"""

+         return self.hub._get_auth_user_roles(user)

+ 

+     def _get_auth_permission_name(self, action):

+         if action != "view":

+             return self.hub._get_auth_permission_name(action)

+         hub_visibility = self.hub.config["visibility"]

+         if hub_visibility != "preview":

+             return "hub.{}.view".format(hub_visibility)

+         return "widget.{}.view".format(self.visibility)

+ 

+     def __json__(self):

+         module = hubs.widgets.registry[self.plugin]

+         root_view = module.get_views()["root"](module)

+         data = root_view.get_context(self)

+         data.update(root_view.get_extra_context(self))

+         data.pop('widget', None)

+         data.pop('widget_instance', None)

+         return {

+             'id': self.idx,

+             # TODO -- use flask.url_for to get the url for this widget

+             'plugin': self.plugin,

+             'description': module.__doc__,

+             'hub': self.hub_id,

+             'left': self.left,

+             'index': self.index,

+             'data': data,

+             'config': self.config,

+         }

+ 

+     def __repr__(self):

+         return "<Widget %s /%s/%i>" % (self.plugin, self.hub.name, self.idx)

+ 

+     @property

+     def module(self):

+         return hubs.widgets.registry[self.plugin]

+ 

+     def get_props(self, with_secret_config=False):

+         return self.module.get_props(self, with_secret_config)

+ 

+     @property

+     def enabled(self):

+         return self.plugin in hubs.widgets.registry

@@ -11,7 +11,7 @@ 

      const old_limit = Date.now() - (1000 * 86400 * 31);  // 31 days

      let icon = null, msg = null;

  

-     if (this.props.hub.archived) {

+     if (this.props.hub.config.archived) {

          icon = ArchivedIcon;

          msg = "This hub has been archived and locked.";

      } else if (this.props.hub.mtime < old_limit) {

@@ -44,3 +44,6 @@ 

    }

  }

  

+ .HubConfigDialog .modal-body table.table td {

+     vertical-align: middle;

+ }

@@ -6,10 +6,14 @@ 

  import PropTypes from 'prop-types';

  import GeneralPanel from './HubConfigPanelGeneral';

  import UserPanel from './HubConfigPanelUser';

+ import MailingListPanel from './HubConfigPanelMailingList';

  import ChatPanel from './HubConfigPanelChat';

+ import CalendarPanel from './HubConfigPanelCalendar';

+ import DevPlatformPanel from './HubConfigPanelDevPlatform';

  import NotImplementedPanel from './HubConfigPanelNotImpl';

  import Modal from '../../components/Modal';

  import TabSet from '../../components/TabSet';

+ import Spinner from "../Spinner";

  

  

  const messages = defineMessages({
@@ -29,10 +33,22 @@ 

      id: "hubs.core.config.members",

      defaultMessage: "Members",

    },

+   mailinglist: {

+     id: "hubs.core.config.mailinglist",

+     defaultMessage: "Mailing-list",

+   },

    chat: {

      id: "hubs.core.config.chat",

      defaultMessage: "Chat",

    },

+   calendar: {

+     id: "hubs.core.config.calendar",

+     defaultMessage: "Calendar",

+   },

+   devplatform: {

+     id: "hubs.core.config.devplatform",

+     defaultMessage: "Development Platform",

+   },

    other: {

      id: "hubs.core.config.other",

      defaultMessage: "Other",
@@ -56,7 +72,15 @@ 

        <FormattedMessage {...messages.title} />

      );

      const footer = (

-       <div className="d-flex justify-content-end w-100">

+       <div className="d-flex justify-content-end align-items-center w-100">

+         { this.props.isLoading &&

+           <Spinner circle={true} />

+         }

+         { this.props.hub.error &&

+           <div className="alert alert-warning py-1 mb-0">

+             {this.props.hub.error.message}

+           </div>

+         }

          <button

            type="button"

            className="btn btn-secondary ml-3"
@@ -68,6 +92,7 @@ 

            type="button"

            className="btn btn-primary ml-3"

            onClick={this.props.onSaveClicked}

+           disabled={this.props.isLoading}

            >

            <FormattedMessage {...messages.save_changes} />

          </button>
@@ -118,11 +143,39 @@ 

                />

            }

            {!this.props.hub.user_hub &&

+             <MailingListPanel

+               hubConfig={this.props.hubConfig}

+               globalConfig={this.props.globalConfig}

+               tabTitle={<FormattedMessage {...messages.mailinglist} />}

+               handleChange={this.props.onConfigChange}

+               error={this.props.hub.error}

+               />

+           }

+           {!this.props.hub.user_hub &&

              <ChatPanel

                hubConfig={this.props.hubConfig}

                globalConfig={this.props.globalConfig}

                tabTitle={<FormattedMessage {...messages.chat} />}

                handleChange={this.props.onConfigChange}

+               error={this.props.hub.error}

+               />

+           }

+           {!this.props.hub.user_hub &&

+             <CalendarPanel

+               hubConfig={this.props.hubConfig}

+               globalConfig={this.props.globalConfig}

+               tabTitle={<FormattedMessage {...messages.calendar} />}

+               handleChange={this.props.onConfigChange}

+               error={this.props.hub.error}

+               />

+           }

+           {!this.props.hub.user_hub &&

+             <DevPlatformPanel

+               hubConfig={this.props.hubConfig}

+               globalConfig={this.props.globalConfig}

+               tabTitle={<FormattedMessage {...messages.devplatform} />}

+               handleChange={this.props.onConfigListChange}

+               error={this.props.hub.error}

                />

            }

            {/*<NotImplementedPanel

@@ -0,0 +1,83 @@ 

+ import React from 'react';

+ import {

+   defineMessages,

+   FormattedMessage,

+   } from 'react-intl';

+ 

+ 

+ const messages = defineMessages({

+   title: {

+     id: "hubs.core.config.calendar.title",

+     defaultMessage: "Calendar Settings",

+   },

+   intro_1: {

+     id: "hubs.core.config.calendar.intro1",

+     defaultMessage: (

+       "If your team or project has a calendar associated with it, you "

+      +"can connect this hub to it."

+      ),

+   },

+   intro_2: {

+     id: "hubs.core.config.calendar.intro2",

+     defaultMessage: "Please indicate your team's calendar below:",

+   },

+   address: {

+     id: "hubs.core.config.calendar.calendar",

+     defaultMessage: "Calendar name",

+   },

+   example: {

+     id: "hubs.core.config.calendar.example",

+     defaultMessage: "Example:",

+   },

+ });

+ 

+ 

+ export default class CalendarPanel extends React.Component {

+ 

+   constructor(props) {

+     super(props);

+     this.handleChange = this.handleChange.bind(this);

+   }

+ 

+   handleChange(e) {

+     const name = e.target.name, value = e.target.value;

+     this.props.handleChange(name, value);

+   }

+ 

+   render() {

+     const stillLoading = (

+           typeof this.props.hubConfig.calendar === "undefined"

+         );

+     const invalid = this.props.error ? this.props.error.fields.calendar : null;

+     return (

+       <form onSubmit={(e) => {e.preventDefault();}}>

+         <FormattedMessage {...messages.title} tagName="h3" />

+         <FormattedMessage {...messages.intro_1} tagName="p" />

+         <FormattedMessage {...messages.intro_2} tagName="p" />

+         <div className="form-group row">

+           <label htmlFor="hub-settings-calendar">

+             <FormattedMessage {...messages.address} />

+           </label>

+           <input

+             type="text"

+             className={"form-control" + (invalid ? " is-invalid" : "")}

+             name="calendar"

+             id="hub-settings-calendar"

+             disabled={stillLoading}

+             onChange={this.handleChange}

+             value={this.props.hubConfig.calendar || ""}

+             />

+           { invalid &&

+             <div className="invalid-feedback">

+               {invalid}

+             </div>

+           }

+           <p className="form-text text-muted">

+             <FormattedMessage {...messages.example} tagName="strong" />

+             <span className="ml-2">team</span>

+           </p>

+         </div>

+       </form>

+     );

+   }

+ }

@@ -38,6 +38,16 @@ 

  

  export default class ChatPanel extends React.Component {

  

+   constructor(props) {

+     super(props);

+     this.handleChange = this.handleChange.bind(this);

+   }

+ 

+   handleChange(e) {

+     const name = e.target.name, value = e.target.value;

+     this.props.handleChange(name, value);

+   }

+ 

    render() {

      var networks = [],

          channel = "#",
@@ -61,6 +71,8 @@ 

        );

      }

  

+     const invalid = this.props.error ? this.props.error.fields.chat_channel : null;

+ 

      return (

        <form onSubmit={(e) => {e.preventDefault();}}>

          <FormattedMessage {...messages.title} tagName="h3" />
@@ -71,12 +83,19 @@ 

              <FormattedMessage {...messages.channel_name} />

            </label>

            <input

-             type="text" className="form-control" name="chat_channel"

+             type="text"

+             className={"form-control" + (invalid ? " is-invalid" : "")}

+             name="chat_channel"

              id="hub-settings-chat-channel"

              disabled={stillLoading}

-             onChange={this.props.handleChange}

+             onChange={this.handleChange}

              value={channel}

              />

+           { invalid &&

+             <div className="invalid-feedback">

+               {invalid}

+             </div>

+           }

            <p className="form-text text-muted">

              <FormattedMessage {...messages.example} tagName="strong" />

              <span className="ml-2">#fedora-devel</span>
@@ -90,7 +109,7 @@ 

              name="chat_domain" className="form-control"

              id="hub-settings-chat-domain"

              disabled={stillLoading}

-             onChange={this.props.handleChange}

+             onChange={this.handleChange}

              value={this.props.hubConfig.chat_domain || ""}

              >

              {networks}

@@ -0,0 +1,242 @@ 

+ import React from 'react';

+ import {

+   defineMessages,

+   FormattedMessage,

+   } from 'react-intl';

+ 

+ 

+ const messages = defineMessages({

+   title: {

+     id: "hubs.core.config.devplatform.title",

+     defaultMessage: "Development Platform",

+   },

+   intro_1: {

+     id: "hubs.core.config.devplatform.intro1",

+     defaultMessage: (

+       "If your team or project uses a development platform, you "

+      +"can connect this hub to it."

+      ),

+   },

+   intro_2: {

+     id: "hubs.core.config.devplatform.intro2",

+     defaultMessage: (

+       "Add your team's development platform using the form below:"

+       ),

+   },

+   currently: {

+     id: "hubs.core.config.devplatform.no_current",

+     defaultMessage: "Your currently connected platforms are:"

+   },

+   devplatform_name: {

+     id: "hubs.core.config.devplatform.devplatform_name",

+     defaultMessage: "Platform name",

+   },

+   example: {

+     id: "hubs.core.config.devplatform.example",

+     defaultMessage: "Example:",

+   },

+   project_name: {

+     id: "hubs.core.config.devplatform.project",

+     defaultMessage: "Project name",

+   },

+   add: {

+     id: "hubs.core.config.devplatform.add",

+     defaultMessage: "Add",

+   },

+   already_connected: {

+     id: "hubs.core.config.devplatform.already_connected",

+     defaultMessage: "This project is already connected.",

+   },

+ });

+ 

+ 

+ export default class DevPlatformPanel extends React.Component {

+ 

+   constructor(props) {

+     super(props);

+     this.state = {

+       devplatform_name: this.props.globalConfig.dev_platforms[0].name,

+       devplatform_project: "",

+       error: null,

+     };

+     this.handleChange = this.handleChange.bind(this);

+     this.addPlatform = this.addPlatform.bind(this);

+     this.delPlatform = this.delPlatform.bind(this);

+   }

+ 

+   handleChange(e) {

+     const name = e.target.name, value = e.target.value;

+     this.setState({

+       [name]: value,

+       error: null,  // Remove the "already connected" message.

+       });

+   }

+ 

+   addPlatform() {

+     if (!this.state.devplatform_project || !this.state.devplatform_name) {

+       return;

+     }

+     const current = this.props.hubConfig[this.state.devplatform_name] || [];

+     if (current.indexOf(this.state.devplatform_project) !== -1) {

+       this.setState({

+         error: <FormattedMessage {...messages.already_connected} />

+       });

+       return;

+     }

+     // add to the list

+     this.props.handleChange(

+       "append",

+       this.state.devplatform_name,

+       this.state.devplatform_project

+     );

+     this.setState({devplatform_project: "", error: null});

+   }

+ 

+   delPlatform(platform_name, platform_project) {

+     this.props.handleChange(

+       "remove", platform_name, platform_project

+     );

+     this.setState({error: null});

+   }

+ 

+   render() {

+     let availablePlatforms = [],

+         platformsByName = {},

+         stillLoading = (

+           typeof this.props.hubConfig.summary === "undefined"

+         );

+ 

+     if (this.props.globalConfig.dev_platforms) {

+       this.props.globalConfig.dev_platforms.forEach((platform) => {

+         availablePlatforms.push(

+           <option value={platform.name} key={platform.name}>

+             {platform.display_name}

+           </option>

+         );

+         platformsByName[platform.name] = platform;

+       });

+     }

+     let currentPlatforms = [];

+     this.props.globalConfig.dev_platforms.forEach((platform) => {

+       if (this.props.hubConfig[platform.name]) {

+         this.props.hubConfig[platform.name].forEach((project) => {

+           currentPlatforms.push({name: platform.name, project: project});

+         });

+       }

+     });

+ 

+     return (

+       <form onSubmit={(e) => {e.preventDefault();}}>

+         <FormattedMessage {...messages.title} tagName="h3" />

+         <FormattedMessage {...messages.intro_1} tagName="p" />

+         { currentPlatforms.length !== 0 &&

+           <div>

+             <FormattedMessage {...messages.currently} tagName="p" />

+             <table className="table table-sm table-striped mb-0">

+               <thead>

+                 <tr>

+                   <th>

+                     <FormattedMessage {...messages.devplatform_name} />

+                   </th>

+                   <th>

+                     <FormattedMessage {...messages.project_name} />

+                   </th>

+                   <th>

+                   </th>

+                 </tr>

+               </thead>

+               <tbody>

+                 { currentPlatforms.map((platform) => (

+                     <tr key={`${platform.name}/${platform.project}`}>

+                       <td>

+                         { platformsByName[platform.name]["display_name"] }

+                       </td>

+                       <td>

+                         <a href={`${platformsByName[platform.name]["url"]}/${platform.project}`} target="_blank">

+                           { platform.project }

+                         </a>

+                       </td>

+                       <td>

+                         <button

+                           className="btn btn-danger btn-sm ml-2"

+                           title="Disconnect"

+                           onClick={(e) => (this.delPlatform(platform.name, platform.project))}

+                           >

+                           <i className="fa fa-times"></i>

+                         </button>

+                       </td>

+                     </tr>

+                   ))

+                 }

+               </tbody>

+             </table>

+             { this.props.error && this.props.error.fields.devplatform_project &&

+               <div className="text-danger">

+                 {this.props.error.fields.devplatform_project}

+               </div>

+             }

+           </div>

+         }

+         <p className="mt-3">

+           <FormattedMessage {...messages.intro_2} />

+         </p>

+         <div className="form-group row">

+           <label

+             htmlFor="hub-settings-devplatform-name"

+             className="col-sm-4 col-form-label"

+             >

+             <FormattedMessage {...messages.devplatform_name} />

+           </label>

+           <div className="col-sm-8">

+             <select

+               name="devplatform_name" className="form-control"

+               id="hub-settings-devplatform-name"

+               disabled={stillLoading}

+               onChange={this.handleChange}

+               value={this.state.devplatform_name}

+               >

+               {availablePlatforms}

+             </select>

+           </div>

+         </div>

+         <div className="form-group row">

+           <label

+             htmlFor="hub-settings-devplatform-project"

+             className="col-sm-4 col-form-label"

+             >

+             <FormattedMessage {...messages.project_name} />

+           </label>

+           <div className="col-sm-8">

+             <input

+               type="text" className="form-control" name="devplatform_project"

+               id="hub-settings-devplatform-project"

+               disabled={stillLoading}

+               onChange={this.handleChange}

+               value={this.state.devplatform_project}

+               />

+             <p className="form-text text-muted">

+               <FormattedMessage {...messages.example} tagName="strong" />

+               <span className="ml-2">team/repo</span>

+             </p>

+           </div>

+         </div>

+         <div className="form-group row align-items-center">

+           <div className="col-sm-8 ml-auto">

+             <button

+               className="btn btn-primary"

+               onClick={this.addPlatform}

+               disabled={this.state.devplatform_project.length === 0}

+               >

+               <FormattedMessage {...messages.add} />

+             </button>

+             { this.state.error &&

+               <span className="ml-3 text-danger">

+                 {this.state.error}

+               </span>

+             }

+           </div>

+         </div>

+       </form>

+     );

+   }

+ }

@@ -59,9 +59,26 @@ 

  

  export default class GeneralPanel extends React.Component {

  

+   constructor(props) {

+     super(props);

+     this.handleChange = this.handleChange.bind(this);

+   }

+ 

+   handleChange(e) {

+     const name = e.target.name, value = e.target.value;

+     this.props.handleChange(name, value);

+   }

+ 

    render() {

-     var stillLoading = (typeof this.props.hubConfig.summary === "undefined");

-     var visibilities = this.props.globalConfig.hub_visibility || [];

+     const stillLoading = (typeof this.props.hubConfig.summary === "undefined");

+     const visibilities = this.props.globalConfig.hub_visibility || [];

+     const right_width = this.props.hubConfig ? 12 - this.props.hubConfig.left_width : 4;

+     let invalid = {};

+     if (this.props.error) {

+       ["summary", "left_width", "visibility", "avatar"].forEach((key) => {

+         invalid[key] = this.props.error.fields[key];

+       });

+     }

      return (

        <form onSubmit={(e) => {e.preventDefault();}}>

          <FormattedMessage {...messages.title} tagName="h3" />
@@ -71,12 +88,19 @@ 

              <FormattedMessage {...messages.summary} />

            </label>

            <input

-             type="text" className="form-control" name="summary"

+             type="text"

+             name="summary"

+             className={"form-control" + (invalid.summary ? " is-invalid" : "")}

              id="hub-settings-general-summary"

              disabled={stillLoading}

-             onChange={this.props.handleChange}

+             onChange={this.handleChange}

              value={this.props.hubConfig.summary || ""}

              />

+           { invalid.summary &&

+             <div className="invalid-feedback">

+               {invalid.summary}

+             </div>

+           }

            <p className="form-text text-muted">

              <FormattedMessage {...messages.summary_help} />

            </p>
@@ -86,17 +110,24 @@ 

              <FormattedMessage {...messages.left_width} />

            </label>

            <input

-             type="number" className="form-control" name="left_width"

+             type="number"

+             name="left_width"

+             className={"form-control" + (invalid.left_width ? " is-invalid" : "")}

              id="hub-settings-general-leftwidth"

              disabled={stillLoading}

              min="1" max="11"

-             onChange={this.props.handleChange}

+             onChange={this.handleChange}

              value={this.props.hubConfig.left_width || ""}

              />

+           { invalid.left_width &&

+             <div className="invalid-feedback">

+               {invalid.left_width}

+             </div>

+           }

            <p className="form-text text-muted">

              <FormattedMessage

                {...messages.left_width_help}

-               values={{right_width: 12 - (this.props.hubConfig.left_width || 8)}}

+               values={{right_width}}

                />

            </p>

          </div>
@@ -106,9 +137,10 @@ 

              <FormattedMessage {...messages.visibility} />

            </label>

            <select

-             className="form-control" name="visibility"

+             name="visibility"

+             className={"form-control" + (invalid.visibility ? " is-invalid" : "")}

              value={this.props.hubConfig.visibility || ""}

-             onChange={this.props.handleChange}

+             onChange={this.handleChange}

              disabled={stillLoading}

              >

              {visibilities.map(function(value) {
@@ -117,6 +149,11 @@ 

                  );

              })}

            </select>

+           { invalid.visibility &&

+             <div className="invalid-feedback">

+               {invalid.visibility}

+             </div>

+           }

            <p className="form-text text-muted">

              <FormattedHTMLMessage {...messages.visibility_help} />

            </p>
@@ -128,12 +165,19 @@ 

              <FormattedMessage {...messages.avatar} />

            </label>

            <input

-             type="text" className="form-control" name="avatar"

+             type="text"

+             name="avatar"

+             className={"form-control" + (invalid.avatar ? " is-invalid" : "")}

              id="hub-settings-general-avatar"

              disabled={stillLoading}

-             onChange={this.props.handleChange}

+             onChange={this.handleChange}

              value={this.props.hubConfig.avatar || ""}

              />

+           { invalid.avatar &&

+             <div className="invalid-feedback">

+               {invalid.avatar}

+             </div>

+           }

            <p className="form-text text-muted">

              <FormattedMessage {...messages.avatar_help} />

            </p>
@@ -142,5 +186,4 @@ 

        </form>

      );

    }

- 

  }

@@ -0,0 +1,85 @@ 

+ import React from 'react';

+ import {

+   defineMessages,

+   FormattedMessage,

+   } from 'react-intl';

+ 

+ 

+ const messages = defineMessages({

+   title: {

+     id: "hubs.core.config.mailinglist.title",

+     defaultMessage: "Mailing-list Settings",

+   },

+   intro_1: {

+     id: "hubs.core.config.mailinglist.intro1",

+     defaultMessage: (

+       "If your team or project has a mailing-list associated with it, you "

+      +"can connect this hub to it."

+      ),

+   },

+   intro_2: {

+     id: "hubs.core.config.mailinglist.intro2",

+     defaultMessage: "Please indicate your team's mailing-list below:",

+   },

+   address: {

+     id: "hubs.core.config.mailinglist.address",

+     defaultMessage: "Mailing-list address",

+   },

+   example: {

+     id: "hubs.core.config.mailinglist.example",

+     defaultMessage: "Example:",

+   },

+ });

+ 

+ 

+ export default class MailingListPanel extends React.Component {

+ 

+   constructor(props) {

+     super(props);

+     this.handleChange = this.handleChange.bind(this);

+   }

+ 

+   handleChange(e) {

+     const name = e.target.name, value = e.target.value;

+     this.props.handleChange(name, value);

+   }

+ 

+   render() {

+     const stillLoading = (

+           typeof this.props.hubConfig.mailing_list === "undefined"

+         );

+     const invalid = this.props.error ? this.props.error.fields.mailing_list : null;

+ 

+     return (

+       <form onSubmit={(e) => {e.preventDefault();}}>

+         <FormattedMessage {...messages.title} tagName="h3" />

+         <FormattedMessage {...messages.intro_1} tagName="p" />

+         <FormattedMessage {...messages.intro_2} tagName="p" />

+         <div className="form-group row">

+           <label htmlFor="hub-settings-mailing-list">

+             <FormattedMessage {...messages.address} />

+           </label>

+           <input

+             type="text"

+             name="mailing_list"

+             className={"form-control" + (invalid ? " is-invalid" : "")}

+             id="hub-settings-mailing-list"

+             disabled={stillLoading}

+             onChange={this.handleChange}

+             placeholder="team@lists.fedoraproject.org"

+             value={this.props.hubConfig.mailing_list || ""}

+             />

+           { invalid &&

+             <div className="invalid-feedback w-100">

+               {invalid}

+             </div>

+           }

+           <p className="form-text text-muted">

+             <FormattedMessage {...messages.example} tagName="strong" />

+             <span className="ml-2">team@lists.fedoraproject.org</span>

+           </p>

+         </div>

+       </form>

+     );

+   }

+ }

@@ -5,7 +5,11 @@ 

    defineMessages,

    FormattedMessage,

    } from 'react-intl';

- import { saveHub } from '../../core/actions/hub';

+ import {

+   saveHub,

+   openConfigDialog,

+   closeConfigDialog,

+   } from '../../core/actions/hub';

  import HubConfigDialog from './HubConfigDialog';

  import "./HubConfig.css";

  
@@ -25,9 +29,9 @@ 

      this.state = {

        config: null,

        users: null,

-       isDialogOpen: false,

      };

-     this.handleConfigChange = this.handleConfigChange.bind(this);

+     this.doConfigChange = this.doConfigChange.bind(this);

+     this.doConfigListChange = this.doConfigListChange.bind(this);

      this.doUserChange = this.doUserChange.bind(this);

      this.handleOpenClicked = this.handleOpenClicked.bind(this);

      this.handleCloseClicked = this.handleCloseClicked.bind(this);
@@ -37,22 +41,45 @@ 

    handleOpenClicked(e) {

      e.preventDefault();

      this.setState({

-       isDialogOpen: true,

        config: this.props.hub.config,

        users: this.props.hub.users,

-     });

+     },

+     () => this.props.dispatch(openConfigDialog())

+     );

    }

  

    handleCloseClicked(e) {

      e.preventDefault();

-     this.setState({isDialogOpen: false});

+     this.props.dispatch(closeConfigDialog())

+   }

+ 

+   doConfigChange(key, value) {

+     this.setState((prevState, props) => {

+       let newConfig = prevState.config;

+       newConfig[key] = value;

+       return {config: newConfig};

+     });

    }

  

-   handleConfigChange(e) {

-     const name = e.target.name, value = e.target.value;

+   doConfigListChange(action, key, value) {

+     if (!key) { return; }

      this.setState((prevState, props) => {

-       prevState.config[name] = value;

-       return prevState;

+       let newConfig = Object.assign({}, prevState.config);

+       if (!newConfig[key]) {

+         newConfig[key] = [];

+       }

+       if (action === "append") {

+         newConfig[key] = [

+           ...newConfig[key], value,

+         ];

+       } else if (action === "remove") {

+         newConfig[key] = newConfig[key].filter(

+           (item) => (item !== value)

+         );

+       } else {

+         throw new Error("Unsupported action: " + action);

+       }

+       return {config: newConfig};

      });

    }

  
@@ -74,7 +101,6 @@ 

    handleSaveClicked(e) {

      e.preventDefault();

      this.props.dispatch(saveHub(this.state.config, this.state.users));

-     this.setState({isDialogOpen: false});

    }

  

    render() {
@@ -90,18 +116,20 @@ 

      return (

        <div className="HubConfig">

          {openButton}

-         {this.state.isDialogOpen &&

+         {this.props.isDialogOpen &&

            <HubConfigDialog

              onCloseClicked={this.handleCloseClicked}

              onSaveClicked={this.handleSaveClicked}

              hubConfig={this.state.config}

              users={this.state.users}

              globalConfig={this.props.globalConfig}

-             onConfigChange={this.handleConfigChange}

+             onConfigChange={this.doConfigChange}

+             onConfigListChange={this.doConfigListChange}

              onUserChange={this.doUserChange}

              urls={this.props.urls}

              currentUser={this.props.currentUser}

              hub={this.props.hub}

+             isLoading={this.props.isLoading}

              />

          }

        </div>
@@ -117,6 +145,8 @@ 

      globalConfig: state.globalConfig,

      urls: state.urls,

      currentUser: state.currentUser,

+     isDialogOpen: state.ui.hubConfigDialogOpen,

+     isLoading: state.ui.hubConfigDialogLoading,

    }

  };

  

@@ -13,6 +13,7 @@ 

  

  class HubHeader extends React.Component {

    render() {

+     const right_width = this.props.hub.config ? 12 - this.props.hub.config.left_width : 4;

      return (

        <div className="HubHeader">

          { this.props.isLoading &&
@@ -45,7 +46,7 @@ 

                    <div className="clearfix"></div>

                  </div>

              </div>

-             <div className={`col-md-${this.props.hub.config.right_width} text-center align-self-center`}>

+             <div className={`col-md-${right_width} text-center align-self-center`}>

                <HubMembership />

                { this.props.hub.perms.config &&

                  <div>

@@ -24,7 +24,7 @@ 

    constructor(props) {

      super(props);

      this.handleEditButtonClicked = this.handleEditButtonClicked.bind(this)

-     this.handleWidgetDeleted = this.handleWidgetDeleted.bind(this);    

+     this.handleWidgetDeleted = this.handleWidgetDeleted.bind(this);

    }

  

    handleEditButtonClicked(e) {
@@ -102,7 +102,6 @@ 

    return {

      onEdit: (widgetId) => { dispatch(openConfigDialog(widgetId)); },

      onDelete: (widgetId) => { dispatch(deleteWidget(widgetId)); }

-     

    }

  }

  

@@ -21,6 +21,7 @@ 

      if (!this.props.hub.name) {

        return null;

      }

+     const right_width = this.props.hub.config ? 12 - this.props.hub.config.left_width : 4;

      return (

        <div className="WidgetsArea">

          { this.props.widgets.isLoading &&
@@ -48,7 +49,7 @@ 

                />

            </div>

  

-           <div className={`col-md-${this.props.hub.config.right_width}`}>

+           <div className={`col-md-${right_width}`}>

              <WidgetsColumn

                position="right"

                widgets={

@@ -47,6 +47,7 @@ 

  /* PUT */

  

  export const HUB_PUT_REQUEST = 'HUB_PUT_REQUEST';

+ export const HUB_PUT_SUCCESS = 'HUB_PUT_SUCCESS';

  export const HUB_PUT_FAILURE = 'HUB_PUT_FAILURE';

  

  function putHub(config, users) {
@@ -56,9 +57,17 @@ 

    }

  }

  

- function putHubFailure() {

+ function putHubSuccess() {

+   return {

+     type: HUB_PUT_SUCCESS,

+   }

+ }

+ 

+ function putHubFailure(error) {

    return {

      type: HUB_PUT_FAILURE,

+     message: error.message,

+     fields: error.fields,

    }

  }

  
@@ -70,12 +79,12 @@ 

      const body = JSON.stringify({config, users});

      return apiCall(url, {method: "PUT", body}).then(

        result => {

+         dispatch(putHubSuccess());

          dispatch(addFlashMessage("Configuration updated", "success"));

          return dispatch(fetchHub());

        },

        error => {

-         dispatch(addFlashMessage(error.message, "error"));

-         return dispatch(putHubFailure());

+         return dispatch(putHubFailure(error));

        })

    }

  }
@@ -141,3 +150,22 @@ 

      );

    }

  }

+ 

+ 

+ 

+ /* UI */

+ 

+ export const HUB_OPEN_CONFIG = 'HUB_OPEN_CONFIG';

+ export const HUB_CLOSE_CONFIG = 'HUB_CLOSE_CONFIG';

+ 

+ export function openConfigDialog() {

+   return {

+     type: HUB_OPEN_CONFIG,

+   }

+ }

+ 

+ export function closeConfigDialog() {

+   return {

+     type: HUB_CLOSE_CONFIG,

+   }

+ }

@@ -4,9 +4,12 @@ 

    HUB_FETCH_FAILURE,

    HUB_PUT_REQUEST,

    HUB_PUT_FAILURE,

+   HUB_PUT_SUCCESS,

    HUB_ASSOC_REQUEST,

    HUB_ASSOC_SUCCESS,

    HUB_ASSOC_FAILURE,

+   HUB_OPEN_CONFIG,

+   HUB_CLOSE_CONFIG,

    } from '../actions/hub';

  

  
@@ -14,6 +17,7 @@ 

    state={

      name: null,

      isLoading: false,

+     error: null,

      old: {},

    },

    action
@@ -46,6 +50,7 @@ 

          config: action.config,

          users: action.users,

          old: {config: state.config, users: state.users},

+         error: null,

        };

      case HUB_PUT_FAILURE:

        return {
@@ -54,6 +59,10 @@ 

          // Optimism failed... ;-(

          config: state.old.config,

          users: state.old.users,

+         error: {

+           message: action.message,

+           fields: action.fields,

+         },

        };

      case HUB_ASSOC_SUCCESS:

        return {
@@ -61,8 +70,39 @@ 

          users: action.users,

          perms: action.perms,

          isLoading: false,

+         error: null,

        };

      default:

        return state

    }

  }

+ 

+ 

+ 

+ /* UI */

+ 

+ 

+ export function hubConfigDialogOpen(state=false, action) {

+   switch (action.type) {

+     case HUB_OPEN_CONFIG:

+       return true;

+     case HUB_CLOSE_CONFIG:

+     case HUB_PUT_SUCCESS:

+       return false;

+     default:

+       return state

+   }

+ }

+ 

+ 

+ export function hubConfigDialogLoading(state=false, action) {

+   switch (action.type) {

+     case HUB_PUT_REQUEST:

+       return true;

+     case HUB_PUT_SUCCESS:

+     case HUB_PUT_FAILURE:

+       return false;

+     default:

+       return state

+   }

+ }

@@ -3,7 +3,9 @@ 

  import sseReducer from "./sse";

  import {

    hubReducer,

-   hubEditMode

+   hubEditMode,

+   hubConfigDialogOpen,

+   hubConfigDialogLoading,

    } from "./hub";

  import {

    widgetsReducer,
@@ -31,6 +33,8 @@ 

    flashMessages,

    widgetsEditMode,

    widgetConfigDialogOpen,

+   hubConfigDialogOpen,

+   hubConfigDialogLoading,

  });

  

  

@@ -43,12 +43,9 @@ 

            return result;

          }

        } else {

-         throw new Error(result.message);

+         throw result;

        }

      },

-     error => {

-       throw new Error(error.message);

-     }

    );

  }

  

file modified
+1 -2
@@ -82,8 +82,7 @@ 

                  hub.last_updated = datetime.utcnow() - timedelta(days=200)

  

              self.session.add(hub)

-             self.session.add(

-                 hubs.models.HubConfig(hub=hub, summary="the %s team" % team))

+             hub.config["summary"] = "the %s team" % team

              widget = hubs.models.Widget(plugin='meetings', index=11,

                                          _config=json.dumps({'calendar': team}))

              hub.widgets.append(widget)

file modified
+5 -65
@@ -90,66 +90,6 @@ 

          hub = hubs.models.Hub.get(username)

          self.assertIsNone(hub)

  

-     def test_visit_counter(self):

-         username = 'ralph'

-         hub = 'decause'

-         # Make sure the table is empty of data

-         vc = hubs.models.VisitCounter.get_visits_by_username_hub(

-             username=username, visited_hub=hub)

-         self.assertIsNone(vc)

- 

-         # Insert a new counter row

-         vc = hubs.models.VisitCounter.get_or_create(

-             username=username, visited_hub=hub)

-         # Make sure its init to 0

-         self.assertEqual(vc.count, 0)

- 

-         # Increment counter and make sure its 1

-         hubs.models.VisitCounter.increment_visits(

-             username=username, visited_hub=hub)

-         self.assertEqual(vc.count, 1)

- 

-         # Delete the counter make sure the hub/user is still arround

-         vc = hubs.models.VisitCounter.get_or_create(

-             username=username, visited_hub=hub)

-         self.session.delete(vc)

-         hub_obj = hubs.models.Hub.get(username)

-         self.assertIsNotNone(hub_obj)

-         user_obj = hubs.models.User.get(username=username)

-         self.assertIsNotNone(user_obj)

- 

-         # Delete hub and make sure the visit counter is 0

-         vc = hubs.models.VisitCounter.get_visits_by_username_hub(

-             username=username, visited_hub=hub)

-         self.session.delete(hub_obj)

-         self.assertIsNone(vc)

-         user_obj = hubs.models.User.get(username=username)

-         self.assertIsNotNone(user_obj)

- 

-     def test_visit_counter_does_not_exist(self):

-         username = 'ralph'

-         hub = 'does-not-exist'

-         self.assertRaises(ValueError,

-                           hubs.models.VisitCounter.get_or_create,

-                           username=username,

-                           visited_hub=hub)

- 

-         username = 'does-not-exist'

-         hub = 'ralph'

-         # Make sure the table is empty of data

-         self.assertRaises(ValueError,

-                           hubs.models.VisitCounter.get_or_create,

-                           username=username,

-                           visited_hub=hub)

- 

-         username = 'does-not-exist'

-         hub = 'does-not-exist'

-         # Make sure the table is empty of data

-         self.assertRaises(ValueError,

-                           hubs.models.VisitCounter.get_or_create,

-                           username=username,

-                           visited_hub=hub)

- 

      def test_auth_hub_widget_access_level(self):

          username = 'ralph'

          ralph = hubs.models.User.get(username)
@@ -177,7 +117,7 @@ 

          hub = hubs.models.Hub.get(username)

          self.assertEqual(hub._get_auth_group(), username)

          # Uncomment this when CAIAPI is active.

-         # hub.config.auth_group = "testing"

+         # hub.config["auth_group"] = "testing"

          # self.assertEqual(hub._get_auth_group(), "testing")

  

      def test_auth_hub_permission_name(self):
@@ -188,10 +128,10 @@ 

              hub._get_auth_permission_name("users.manage"), "hub.users.manage")

          self.assertEqual(

              hub._get_auth_permission_name("config"), "hub.config")

-         hub.config.visibility = "preview"

+         hub.config["visibility"] = "preview"

          self.assertEqual(

              hub._get_auth_permission_name("view"), "hub.preview.view")

-         hub.config.visibility = "private"

+         hub.config["visibility"] = "private"

          self.assertEqual(

              hub._get_auth_permission_name("view"), "hub.private.view")

  
@@ -216,11 +156,11 @@ 

              hub=hub, plugin="contact").one()

          self.assertEqual(

              widget._get_auth_permission_name("view"), "hub.public.view")

-         hub.config.visibility = "private"

+         hub.config["visibility"] = "private"

          self.session.commit()

          self.assertEqual(

              widget._get_auth_permission_name("view"), "hub.private.view")

-         hub.config.visibility = "preview"

+         hub.config["visibility"] = "preview"

          assert widget.visibility == "public"

          self.assertEqual(

              widget._get_auth_permission_name("view"), "widget.public.view")

hubs/tests/utils/test_validators.py hubs/tests/test_widget_validators.py
file renamed
+1 -1
@@ -2,7 +2,7 @@ 

  

  import unittest

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  

  from hubs.tests import APPTest

  

@@ -0,0 +1,84 @@ 

+ interactions:

+ - request:

+     body: null

+     headers:

+       Accept: ['*/*']

+       Accept-Encoding: ['gzip, deflate']

+       Connection: [keep-alive]

+       User-Agent: [python-requests/2.18.4]

+     method: GET

+     uri: https://api.github.com/users/fedora-infra

+   response:

+     body:

+       string: !!binary |

+         H4sIAAAAAAAAA52TzW6cMBSFXyXyehjjMJ02lqruKnXVzayyGRnjgRsZ2/IP0RTl3XsNJJogVRWz

+         Aiyf7x6OfUaibQuGcHJRjfWiAHPxguwINIRXFTseq687IgYRhT8nr3FjF6MLnNJ5MbB9C7FLdQrK

+         S2uiMnEvbU8TXeQ/hu8HBLZ+oWQywYUVzcECmtVIC3TlqYu9XpmYZ0+S1eaL1dq+ImVt+3+D6IcS

+         Tc7vYNo7KagcqY2dwvTwl95yEBDidlOTaqT5cYYmcwIeiVfNZmOLDm29GnQ0Uq+cnYCpDtKDi2DN

+         doOf1EizvhUG/oj7aKgOCMnWtluZVKhWA17G7fJZNlLnYRDymqPxSioYMOw7kSs9EuPVKezB75uU

+         8hFAVGfR9LmRF6GD2hEj+rzx51TPh1+5niH6JGPyChV4850wV8JN0npHaqzzbUedC/u5GM7bFyXj

+         HpOlqNNWTkfzLlS9AGz3TOnAK1FrnLtQwf7LwsNJiR55LtUa5HmOnrOSfSxNN5fw8r1MWMmbLyzI

+         9CVxYsSARcRJjyWripIV7OnEnvjhGz98ecYZyTWf9hyLsirY4fRY8orxsnomb38BYQwDb9AEAAA=

+     headers:

+       Access-Control-Allow-Origin: ['*']

+       Access-Control-Expose-Headers: ['ETag, Link, Retry-After, X-GitHub-OTP, X-RateLimit-Limit,

+           X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes,

+           X-Poll-Interval']

+       Cache-Control: ['public, max-age=60, s-maxage=60']

+       Content-Encoding: [gzip]

+       Content-Security-Policy: [default-src 'none']

+       Content-Type: [application/json; charset=utf-8]

+       Date: ['Wed, 13 Dec 2017 16:49:09 GMT']

+       ETag: [W/"26ff4247338ae3b104803156575c2cff"]

+       Last-Modified: ['Mon, 14 Mar 2016 20:31:03 GMT']

+       Server: [GitHub.com]

+       Status: [200 OK]

+       Strict-Transport-Security: [max-age=31536000; includeSubdomains; preload]

+       Vary: [Accept]

+       X-Content-Type-Options: [nosniff]

+       X-Frame-Options: [deny]

+       X-GitHub-Media-Type: [github.v3; format=json]

+       X-GitHub-Request-Id: ['10CA:23D57:16924CB:2D234EA:5A315A04']

+       X-RateLimit-Limit: ['60']

+       X-RateLimit-Remaining: ['59']

+       X-RateLimit-Reset: ['1513187349']

+       X-Runtime-rack: ['0.043492']

+       X-XSS-Protection: [1; mode=block]

+     status: {code: 200, message: OK}

+ - request:

+     body: null

+     headers:

+       Accept: ['*/*']

+       Accept-Encoding: ['gzip, deflate']

+       Connection: [keep-alive]

+       User-Agent: [python-requests/2.18.4]

+     method: GET

+     uri: https://api.github.com/users/something-that-does-not-exist

+   response:

+     body:

+       string: !!binary |

+         H4sIAAAAAAAAAxXJMQ7CMAwF0Ksgs5J6YOsBGHsFFJqvNFISV7HdBfXuhfW9LzWoxgyaaRG7vcR7

+         ogclWb2hW7Qi/e2j/n4z23VmTjhQZceYcrHNP9MqjY8nu2Io3zMsxKCl54rwNzovaRNpS2YAAAA=

+     headers:

+       Access-Control-Allow-Origin: ['*']

+       Access-Control-Expose-Headers: ['ETag, Link, Retry-After, X-GitHub-OTP, X-RateLimit-Limit,

+           X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes,

+           X-Poll-Interval']

+       Content-Encoding: [gzip]

+       Content-Security-Policy: [default-src 'none']

+       Content-Type: [application/json; charset=utf-8]

+       Date: ['Wed, 13 Dec 2017 16:49:09 GMT']

+       Server: [GitHub.com]

+       Status: [404 Not Found]

+       Strict-Transport-Security: [max-age=31536000; includeSubdomains; preload]

+       X-Content-Type-Options: [nosniff]

+       X-Frame-Options: [deny]

+       X-GitHub-Media-Type: [github.v3; format=json]

+       X-GitHub-Request-Id: ['4B2B:23D57:169254C:2D235D0:5A315A05']

+       X-RateLimit-Limit: ['60']

+       X-RateLimit-Remaining: ['58']

+       X-RateLimit-Reset: ['1513187349']

+       X-Runtime-rack: ['0.017613']

+       X-XSS-Protection: [1; mode=block]

+     status: {code: 404, message: Not Found}

+ version: 1

@@ -0,0 +1,98 @@ 

+ interactions:

+ - request:

+     body: null

+     headers:

+       Accept: ['*/*']

+       Accept-Encoding: ['gzip, deflate']

+       Connection: [keep-alive]

+       User-Agent: [python-requests/2.18.4]

+     method: GET

+     uri: https://api.github.com/repos/fedora-infra/fedmsg

+   response:

+     body:

+       string: !!binary |

+         H4sIAAAAAAAAA+1YTW/jNhD9K4audUzbSnYTAYvtqblk2xTYXvZi0BItsaFEgaTsJkL+ex9FfdmA

+         P0JfAwSOTfA9Pg45w5mpA54EURguH8L7L9OgoDkLomDDklynwTTYVEKshkGp6A0vNoqSfobcFUwF

+         UR0ImfLCYftpYHD0iy9fwq/TgG6poWpVKYGJmTGljghxg3oxS7nJqnWlmYplYVhhZrHMSUXCsIF/

+         3367BWGqWhbLHGDggK3kLZFDg01btWNNmcnFgQi3dgM5mLyRQsgdWA5ln1uI9EhryIaFF6knC5A1

+         kSZjsB629G4NwbX5uKgGVRP7b8UTy6NxJIolHxbW4iDLXoH3mihWyoawWutY8dJwWXxc4B4abFKl

+         tOBv1I8NaA0SK+3jUhoU0GyLy/hxuIPVpFR8S+NXaxrFYsa3MLYn5QEejOa1tB7718hK9gi4YSua

+         5NYjN1Ro9j4NGhkGk5uBKfzvUi8YvD1h/cli0T9YwhQok8kPpjWF/6eTHZx48osp+eNv6NhI9dIv

+         eNJRG1vv+d6wqmU5Y/6jcHghwJDywl69OSy2JvhsXSaGN9M1QoqR5+LCcWF7JDUZ/7RXxTCaewtu

+         wCDJpPS3XAMGCde6Yhfd2OObbTg06VyiqPK1i2KXOMJxWoeGRqo1TwvGvC3WE9SkC7BrRYs486fs

+         8DVx35pTpam3RIsFxVrItTcH3jnSENREZ9Q9I2Z1jSrLaPF7hIptrpJo8T2hUVecayPPEvR0eLcM

+         jthbX4cndWtBQYu0oqk/Y0+A07WvakrfzuYbx31iYACdzaQUX1fXBaqBwyp0zzv819+EA8VA2OQL

+         p7OQE5se5RzNtvOcn3uyj7O18L0rfSWlvYeHtPb3+czitEyLr8kQT12wbpl9rdlG607fmL9Nzb2P

+         vsOT+reSmsxGICxTUsV8xbZwUq8p8pzZbFZnjDaZbc7UFV7p0KChKs6QtPnqqzs8MpGcmiZR3lh5

+         CRJnIWnibcueAGTuyHw1OvT4nEtUft7CGvCYLeeCaSML/xg5MIx5C2n4hseXFAfH3WiPpP6ueRGz

+         KRViiltpeMxxT5HZ2hND0sf8reLQkI8i2xUDguHKeltZMYeviSvkElYK+XpVRBlRWMdUzGb3K2qQ

+         7C/ni+XNHH+Ln8tFdDePFve/MKcqk705X28Wi5sl5iyj5V00v7NzykpnIxpMAc3Dz/l9FM6j2wc7

+         BeGxvbv4hqbAkXq8rQlshQ+Q1tkA+n2AROMSfh8SC1zCAy+5bK3t4dt0GgZ5mcxZibxg1OtwbZMZ

+         7Jqglk9krGdcErsV/oZ54Xx+u5cCxLIqYPzFbTgNdtQgG8WjOx7sUges8fxqMlnYdaleOYcOIqMq

+         W+phpFTyXxYbPR4bAsho4o6/8KFItEib2/QjrhhrNdieUc6Vkm1Tp4Dj9wET/Zm21JQlK1pJnfoQ

+         SMFjVmjsu7bVGbYg0lLcLGcLbKJtOj3++c/kCcUlU5NHhjYDFZPnag3g5MmBJ1s3X5fJf6jOQPL0

+         +PzUkpysONvVNelXRZHsqsXI7mskGieDge4A2vNI2IZWwqxcco+FE1QjQpYQP+5ZfPbGmoLqsLbv

+         umqw1mdvrG2Enu0ofvbG0CA+0tdFVrXXW8PFurw3VjCzQ5+oC07W/cdlThvtwsX7/x1CM+swFwAA

+     headers:

+       Access-Control-Allow-Origin: ['*']

+       Access-Control-Expose-Headers: ['ETag, Link, Retry-After, X-GitHub-OTP, X-RateLimit-Limit,

+           X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes,

+           X-Poll-Interval']

+       Cache-Control: ['public, max-age=60, s-maxage=60']

+       Content-Encoding: [gzip]

+       Content-Security-Policy: [default-src 'none']

+       Content-Type: [application/json; charset=utf-8]

+       Date: ['Wed, 13 Dec 2017 16:49:10 GMT']

+       ETag: [W/"64dc91ced4a01dec785310d5b3f6bcb3"]

+       Last-Modified: ['Tue, 21 Nov 2017 22:25:05 GMT']

+       Server: [GitHub.com]

+       Status: [200 OK]

+       Strict-Transport-Security: [max-age=31536000; includeSubdomains; preload]

+       Vary: [Accept]

+       X-Content-Type-Options: [nosniff]

+       X-Frame-Options: [deny]

+       X-GitHub-Media-Type: [github.v3; format=json]

+       X-GitHub-Request-Id: ['6329:23D58:142B6B1:31DDAF1:5A315A06']

+       X-RateLimit-Limit: ['60']

+       X-RateLimit-Remaining: ['57']

+       X-RateLimit-Reset: ['1513187349']

+       X-Runtime-rack: ['0.054102']

+       X-XSS-Protection: [1; mode=block]

+     status: {code: 200, message: OK}

+ - request:

+     body: null

+     headers:

+       Accept: ['*/*']

+       Accept-Encoding: ['gzip, deflate']

+       Connection: [keep-alive]

+       User-Agent: [python-requests/2.18.4]

+     method: GET

+     uri: https://api.github.com/repos/fedora-infra/something-that-does-not-exist

+   response:

+     body:

+       string: !!binary |

+         H4sIAAAAAAAAA6tWyk0tLk5MT1WyUvLLL1Fwyy/NS1HSUUrJTy7NTc0rSSzJzM+LLy3KAcpnlJQU

+         FFvp66eklqXm5BekFumlZ5ZklCbpJefn6pcZK9UCAP6TTUJNAAAA

+     headers:

+       Access-Control-Allow-Origin: ['*']

+       Access-Control-Expose-Headers: ['ETag, Link, Retry-After, X-GitHub-OTP, X-RateLimit-Limit,

+           X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes,

+           X-Poll-Interval']

+       Content-Encoding: [gzip]

+       Content-Security-Policy: [default-src 'none']

+       Content-Type: [application/json; charset=utf-8]

+       Date: ['Wed, 13 Dec 2017 16:49:11 GMT']

+       Server: [GitHub.com]

+       Status: [404 Not Found]

+       Strict-Transport-Security: [max-age=31536000; includeSubdomains; preload]

+       X-Content-Type-Options: [nosniff]

+       X-Frame-Options: [deny]

+       X-GitHub-Media-Type: [github.v3; format=json]

+       X-GitHub-Request-Id: ['358A:23D58:142B715:31DDBE1:5A315A06']

+       X-RateLimit-Limit: ['60']

+       X-RateLimit-Remaining: ['56']

+       X-RateLimit-Reset: ['1513187349']

+       X-Runtime-rack: ['0.018425']

+       X-XSS-Protection: [1; mode=block]

+     status: {code: 404, message: Not Found}

+ version: 1

@@ -0,0 +1,385 @@ 

+ interactions:

+ - request:

+     body: null

+     headers:

+       Accept: ['*/*']

+       Accept-Encoding: ['gzip, deflate']

+       Connection: [keep-alive]

+       User-Agent: [python-requests/2.18.4]

+     method: GET

+     uri: https://pagure.io/fedora-hubs

+   response:

+     body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\

+         Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Overview\

+         \ - fedora-hubs - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"\

+         image/vnd.microsoft.icon\"\n        href=\"/static/favicon.ico\"/>\n    <link\

+         \ href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\

+         \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\

+         \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\

+         \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\

+         \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\

+         \n        type=\"text/css\" rel=\"stylesheet\" />\n    <meta name=\"go-import\"\

+         \ content=\"pagure.io/fedora-hubs git https://pagure.io/fedora-hubs.git\"\

+         >\n  </head>\n  <body id=\"home\">\n  <!-- start masthead -->\n    <div class=\"\

+         masthead\">\n      <div class=\"container\">\n        <div class=\"row\">\n\

+         \          <div class=\"col-sm-3\">\n            <a href=\"/\">\n        \

+         \      <img height=40px src=\"/static/pagure-logo.png\"\n                alt=\"\

+         pagure Logo\" id=\"pagureLogo\"/>\n            </a>\n          </div>\n  \

+         \        <div class=\"col-sm-9\">\n            <div class=\"row\">\n\n  <nav\

+         \ class=\"navbar navbar-light p-t-0 p-b-0\">\n      <div class=\"container\"\

+         >\n        <ul class=\"nav navbar-nav nav-underline pull-xs-right\">\n   \

+         \           <li class=\"nav-item p-l-1\">\n                <a class=\"nav-link\

+         \ btn btn-primary\" href=\"/login/?next=https://pagure.io/fedora-hubs\">Log\

+         \ In</a>\n              </li>\n        </ul>\n      </div>\n    </nav>\n\n\

+         \            </div>\n          </div>\n        </div>\n      </div>\n    </div><!--\

+         \ close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\n<div class=\"\

+         repo-header p-t-1\">\n  <div class=\"container\">\n    <header>\n      <h2\

+         \ class=\"repo-name m-b-0\">\n <a href=\"/fedora-hubs\">fedora-hubs</a>\n\

+         \        </h2>\n        <div class=\"projectinfo m-t-1 m-b-1\">\nFedora Hubs\

+         \ &nbsp;| &nbsp;<a class=\"inline\" href=\"http://pagure.io/fedora-hubs\"\

+         >http://pagure.io/fedora-hubs</a>        </div>\n\n    </header>\n    <ul\

+         \ class=\"nav nav-tabs nav-small\">\n      <li class=\"nav-item\">\n     \

+         \   <a class=\"nav-link active\" href=\"/fedora-hubs\">\n            <span\

+         \ class=\"oi hidden-md-up\" data-glyph=\"home\"></span>\n            <span\

+         \ class=\"hidden-sm-down\">Overview</span>\n        </a>\n      </li>\n  \

+         \      <li class=\"nav-item\" >\n          <a  class=\"nav-link\" href=\"\

+         /docs/fedora-hubs/\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\

+         book\"></span>\n              <span class=\"hidden-sm-down\">Docs</span>\n\

+         \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \

+         \    <a class=\"nav-link\" href=\"/fedora-hubs/commits/develop\">\n      \

+         \        <span class=\"oi hidden-md-up\" data-glyph=\"spreadsheet\">\n   \

+         \           </span><span class=\"hidden-sm-down\">Commits</span>\n       \

+         \     </a>\n        </li>\n\n        <li class=\"nav-item\">\n          <a\

+         \ class=\"nav-link\" href=\"/fedora-hubs/tree/develop\">\n              <span\

+         \ class=\"oi hidden-md-up\" data-glyph=\"file\"></span>\n              <span\

+         \ class=\"hidden-sm-down\">Files</span>\n          </a>\n        </li>\n\n\

+         \        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\

+         /fedora-hubs/releases\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\

+         box\"></span>\n              <span class=\"hidden-sm-down\">Releases</span>\n\

+         \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \

+         \      <a class=\"nav-link\" href=\"/fedora-hubs/issues\">\n             \

+         \   <span class=\"oi hidden-md-up\" data-glyph=\"warning\"></span>\n     \

+         \           <span class=\"hidden-sm-down\">Issues&nbsp;</span>\n         \

+         \       <span class=\"label label-default label-pill hidden-sm-down\">\n \

+         \                 132\n                </span>\n            </a>\n       \

+         \ </li>\n\n        <li class=\"nav-item\">\n          <a class=\"nav-link\"\

+         \ href=\"/fedora-hubs/pull-requests\">\n              <span class=\"oi hidden-md-up\"\

+         \ data-glyph=\"task\"></span>\n              <span class=\"hidden-sm-down\"\

+         >Pull Requests&nbsp;</span>\n              <span class=\"label label-default\

+         \ label-pill hidden-sm-down\">\n                10\n              </span>\n\

+         \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \

+         \    <a class=\"nav-link\" href=\"/fedora-hubs/stats\">\n              <span\

+         \ class=\"oi hidden-md-up\" data-glyph=\"task\"></span>\n              <span\

+         \ class=\"hidden-sm-down\">Stats&nbsp;</span>\n          </a>\n        </li>\n\

+         \n\n    </ul>\n  </div>\n</div>\n\n<div class=\"container p-t-3\">\n  <div\

+         \ class=\"row\">\n        <div class=\"col-md-8\">\n            <section class=\"\

+         readme\">\n              <div class=\"document\">\n<h1>Fedora Hubs</h1>\n\

+         <p>Fedora Hubs will provide a communication and collaboration center for Fedora\n\

+         contributors of all types. The idea is that contributors will be able to visit\n\

+         Hubs to check on their involvements across Fedora, discover new places that\

+         \ they\ncan contribute, and more.</p>\n<p>Hubs is currently under development.\

+         \ (We had a development instance at\n<a href=\"https://hubs-dev.fedorainfracloud.org/\"\

+         >https://hubs-dev.fedorainfracloud.org/</a>)</p>\n<div class=\"section\">\n\

+         <h1>Get Involved</h1>\n<p>Visit our <a href=\"https://lists.fedoraproject.org/archives/list/hubs-devel@lists.fedoraproject.org\"\

+         >mailing list</a>\nand join us in the <code><span class=\"pre\">#fedora-hubs</span></code>\

+         \ IRC channel on irc.freenode.net.</p>\n<p>For a more detailed overview of\

+         \ what Fedora Hubs is, see the\n<a href=\"https://docs.pagure.org/fedora-hubs/overview.html\"\

+         >documentation</a>.</p>\n<p>To set up a development environment and start\

+         \ contributing, check out\nthe <a href=\"https://docs.pagure.org/fedora-hubs/dev-guide.html\"\

+         >development guide</a>.</p>\n</div>\n<div class=\"section\">\n<h1>Meetings</h1>\n\

+         <p>Meetings are held weekly in <code><span class=\"pre\">#fedora-hubs</span></code>\

+         \ at 14:00UTC and the minutes for\nevery meeting are <a href=\"https://meetbot.fedoraproject.org/sresults/?group_id=hubs-devel&amp;type=team\"\

+         >archived</a>.\nIn the meetings we review our statuses from the preceding\

+         \ week and do ticket triage, too.</p>\n<div class=\"section\">\n<h2>Steps\

+         \ to run a meeting</h2>\n<ul>\n<li>#startmeeting hubs-devel</li>\n<li>#topic\

+         \ Roll Call (Wait for 2 minutes for the Roll Call)</li>\n<li>#chair [nick\

+         \ 1, nick 2,...., nick N](All the people present for the meeting)</li>\n<li>#topic\

+         \ Action items from last meeting (Find the last meeting log from <a href=\"\

+         https://meetbot-raw.fedoraproject.org/teams/hubs-devel\">https://meetbot-raw.fedoraproject.org/teams/hubs-devel</a>)</li>\n\

+         <li>#topic Status Updates (Cycle through all the nicks)</li>\n<li>#topic Ticket\

+         \ &lt;subject link_to_the_ticket&gt;</li>\n<li>#topic Open Floor</li>\n<li>#endmeeting</li>\n\

+         </ul>\n<p>Send the minutes of the meeting to <a href=\"mailto:hubs-devel@lists.fedoraproject.org\"\

+         >hubs-devel@lists.fedoraproject.org</a></p>\n</div>\n</div>\n</div>\n\n  \

+         \          </section>\n        </div>\n      <div class=\"col-md-4\">\n  \

+         \      <div class=\"card\">\n          <div class=\"card-block\">\n      \

+         \      <h5><strong>Contributors</strong></h5>\n            <div class=\"m-b-2\"\

+         >\n              <div>\n                <a href=\"/user/decause\">\n     \

+         \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/251361d9808433399060ac45609dae227c64b944f6fd697c5e4b32c1506afe26?s=20&d=retro\"\

+         />\n                  Remy DeCausemaker (decause)\n                </a>\n\

+         \                -  main admin\n              </div>\n                  <div>\n\

+         \                    <a href=\"/user/duffy\">\n                      <img\

+         \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/e4e83ac110d58771f0c0125651a1ab585e9f72715dc76bd4af54e0102e96f171?s=20&d=retro\"\

+         />\n                      M\xE1ir\xEDn Duffy (duffy)\n                   \

+         \ </a>\n                    - admin\n                  </div>\n          \

+         \        <div>\n                    <a href=\"/user/lmacken\">\n         \

+         \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/d433d26a9f50eb87d94b0473472202aa7cf7952700ae82fc5f9750adc0aad37e?s=20&d=retro\"\

+         />\n                      Luke Macken (lmacken)\n                    </a>\n\

+         \                    - admin\n                  </div>\n                 \

+         \ <div>\n                    <a href=\"/user/nyazdani\">\n               \

+         \       <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/40c9ac361c6cf560171f6bfb99eef9c6849bf8156e2faa15b067e4c4d092246b?s=20&d=retro\"\

+         />\n                      Nathaniel Yazdani (nyazdani)\n                 \

+         \   </a>\n                    - admin\n                  </div>\n        \

+         \          <div>\n                    <a href=\"/user/bee2502\">\n       \

+         \               <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/0b4ce1166336c24dae92f4ece34d4b6a2c7218dd85088967e2f12c6e7b09d5ce?s=20&d=retro\"\

+         />\n                      Bhagyashree Padalkar (bee2502)\n               \

+         \     </a>\n                    - admin\n                  </div>\n      \

+         \            <div>\n                    <a href=\"/user/devyani7\">\n    \

+         \                  <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/cbfbb8a5d78cfc3a595d65dd26074e5b5799bb2b585311dcc0dd18230e2f5649?s=20&d=retro\"\

+         />\n                      Devyani Kota (devyani7)\n                    </a>\n\

+         \                    - admin\n                  </div>\n                 \

+         \ <div>\n                    <a href=\"/user/dhrish20\">\n               \

+         \       <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/156c08ff565412423d5bdb7a1d46e9ea0a3f0cf8f9b51b4f6cd0c6da56a72da9?s=20&d=retro\"\

+         />\n                      Dhriti Shikhar (dhrish20)\n                    </a>\n\

+         \                    - admin\n                  </div>\n                 \

+         \ <div>\n                    <a href=\"/user/jflory7\">\n                \

+         \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/bc8fd31501030af99c3f31ca8a1f4ee205a1d0035b487d09a497a194b8777c70?s=20&d=retro\"\

+         />\n                      Justin W. Flory (jflory7)\n                    </a>\n\

+         \                    - admin\n                  </div>\n                 \

+         \ <div>\n                    <a href=\"/user/pingou\">\n                 \

+         \     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\

+         />\n                      Pierre-YvesChibon (pingou)\n                   \

+         \ </a>\n                    - admin\n                  </div>\n          \

+         \        <div>\n                    <a href=\"/user/ryanlerch\">\n       \

+         \               <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/de5bf8d06663adb3bb1b8d49ccab259828fad7dddeb233b073d0c447d79b4c14?s=20&d=retro\"\

+         />\n                      Ryan Lerch (ryanlerch)\n                    </a>\n\

+         \                    - admin\n                  </div>\n                 \

+         \ <div>\n                    <a href=\"/user/skrzepto\">\n               \

+         \       <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/55ffbcbe7ef5ef02b58c2082b79aa1dc23794da806e19333d4dbb994c4093b3d?s=20&d=retro\"\

+         />\n                      Szymon Mucha (skrzepto)\n                    </a>\n\

+         \                    - admin\n                  </div>\n                 \

+         \ <div>\n                    <a href=\"/user/sayanchowdhury\">\n         \

+         \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/c5ac4605cc268bbce6edd94b6cc52c34dec3b2abb22f92079aa7186db6983bae?s=20&d=retro\"\

+         />\n                      Sayan Chowdhury (sayanchowdhury)\n             \

+         \       </a>\n                    - admin\n                  </div>\n    \

+         \              <div>\n                    <a href=\"/user/bkorren\">\n   \

+         \                   <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/632177fa944babf9d34ebdc5fb82a4b2ea3ca7c1da7ef9657ebd6f3f48e3c094?s=20&d=retro\"\

+         />\n                      bkorren (bkorren)\n                    </a>\n  \

+         \                  - admin\n                  </div>\n                  <div>\n\

+         \                    <a href=\"/user/atelic\">\n                      <img\

+         \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/9ac566a1a33e8fe203da5925f39f52e8c0bad623f7147bc3420faa40d82acbb3?s=20&d=retro\"\

+         />\n                      Eric Barbour (atelic)\n                    </a>\n\

+         \                    - admin\n                  </div>\n                 \

+         \ <div>\n                    <a href=\"/user/wispfox\">\n                \

+         \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b5f1b3792dc945fba3b616b489aa688b91577f186283427b4f08dd9dcbe53db7?s=20&d=retro\"\

+         />\n                      Suzanne Hillman (wispfox)\n                    </a>\n\

+         \                    - admin\n                  </div>\n                 \

+         \ <div>\n                    <a href=\"/user/jcline\">\n                 \

+         \     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/1a108f46a960aa35efcabd0b779cd59b8c3aba2927213fe63373921e1aa3fac4?s=20&d=retro\"\

+         />\n                      Jeremy Cline (jcline)\n                    </a>\n\

+         \                    - admin\n                  </div>\n                 \

+         \ <div>\n                    <a href=\"/user/abompard\">\n               \

+         \       <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/224e9ad3084cb9f442f423b7910701377be7a018408a4f8b22a09d94c89af46f?s=20&d=retro\"\

+         />\n                      Aurelien Bompard (abompard)\n                  \

+         \  </a>\n                    - admin\n                  </div>\n         \

+         \         <div>\n                    <a href=\"/user/etsang\">\n         \

+         \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/e5982727b1830fc56fe85934100f3651dfc984e3d074023129f28b7d3d5512f7?s=20&d=retro\"\

+         />\n                      Eric Tsang (etsang)\n                    </a>\n\

+         \                    - commit\n                  </div>\n            </div>\n\

+         \            <h5><strong>Branches</strong></h5>\n            <div class=\"\

+         m-b-2\">\n              <div id=\"branch-develop\" class=\"repoinfo-branchlistitem\

+         \ row\">\n                <div class=\"branch_name col-md-8\">\n         \

+         \         <span class=\"oi text-muted\" data-glyph=\"random\"\n          \

+         \          title=\"Default branch\"></span>\n                  <strong title=\"\

+         Currently viewing branch develop\" data-toggle=\"tooltip\">develop</strong>\n\

+         \                </div>\n                <div class=\"branch_del col-md-4\"\

+         >\n                  <span class=\"oi text-muted\" data-glyph=\"star\"\n \

+         \                   title=\"Default branch\"></span>\n                </div>\n\

+         \              </div>\n\n              <div id=\"branch-feature__bugzilla-links\"\

+         \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\

+         \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\

+         random\"></span>\n                  <a class=\"\" href=\"/fedora-hubs/branch/feature/bugzilla-links\"\

+         \n                    title=\"feature/bugzilla-links\" data-toggle=\"tooltip\"\

+         >feature/bugzilla-links\n                  </a>\n\n                </div>\n\

+         \                <div class=\"branch_del col-md-4\">\n                </div>\n\

+         \              </div>\n              <div id=\"branch-fix_fedmsgstats\" class=\"\

+         repoinfo-branchlistitem row\">\n                <div class=\"branch_name col-md-8\"\

+         >\n                  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n\

+         \                  <a class=\"\" href=\"/fedora-hubs/branch/fix_fedmsgstats\"\

+         \n                    title=\"fix_fedmsgstats\" data-toggle=\"tooltip\">fix_fedmsgstats\n\

+         \                  </a>\n\n                </div>\n                <div class=\"\

+         branch_del col-md-4\">\n                </div>\n              </div>\n   \

+         \           <div id=\"branch-jenkins\" class=\"repoinfo-branchlistitem row\"\

+         >\n                <div class=\"branch_name col-md-8\">\n                \

+         \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \

+         \         <a class=\"\" href=\"/fedora-hubs/branch/jenkins\"\n           \

+         \         title=\"jenkins\" data-toggle=\"tooltip\">jenkins\n            \

+         \      </a>\n\n                </div>\n                <div class=\"branch_del\

+         \ col-md-4\">\n                </div>\n              </div>\n            \

+         \  <div id=\"branch-master\" class=\"repoinfo-branchlistitem row\">\n    \

+         \            <div class=\"branch_name col-md-8\">\n                  <span\

+         \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \

+         \  <a class=\"\" href=\"/fedora-hubs/branch/master\"\n                   \

+         \ title=\"master\" data-toggle=\"tooltip\">master\n                  </a>\n\

+         \n                </div>\n                <div class=\"branch_del col-md-4\"\

+         >\n                </div>\n              </div>\n              <div id=\"\

+         branch-unittest\" class=\"repoinfo-branchlistitem row\">\n               \

+         \ <div class=\"branch_name col-md-8\">\n                  <span class=\"oi\

+         \ text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\

+         \" href=\"/fedora-hubs/branch/unittest\"\n                    title=\"unittest\"\

+         \ data-toggle=\"tooltip\">unittest\n                  </a>\n\n           \

+         \     </div>\n                <div class=\"branch_del col-md-4\">\n      \

+         \          </div>\n              </div>\n            </div>\n            <h5><strong>Source\

+         \ GIT URLs</strong>                  <span class=\"pull-xs-right\"><a data-toggle=\"\

+         collapse\" href=\"#moregiturls\"\n                  aria-expanded=\"false\"\

+         \ aria-controls=\"moregiturls\"\n                  id=\"more_gits\">more</a></span></h5>\n\

+         \            <div>\n              <div class=\"form-group\">\n           \

+         \     <div class=\"input-group input-group-sm\">\n                  <div class=\"\

+         input-group-addon\">GIT</div>\n                  <input class=\"form-control\"\

+         \ type=\"text\" value=\"https://pagure.io/fedora-hubs.git\" readonly>\n  \

+         \              </div>\n              </div>\n              <div class=\"collapse\"\

+         \ id=\"moregiturls\">\n                  <h5><strong>Docs GIT URLs</strong></h5>\n\

+         \                  <div class=\"form-group\">\n                    <div class=\"\

+         input-group input-group-sm\">\n                      <div class=\"input-group-addon\"\

+         >GIT</div>\n                      <input class=\"form-control\" type=\"text\"\

+         \ value=\"https://pagure.io/docs/fedora-hubs.git\" readonly>\n           \

+         \         </div>\n                  </div>\n              </div>\n       \

+         \     </div>\n          </div>\n          <div class=\"repo_date\" title=\"\

+         2015-06-04 17:27:48.074785\">\n              created 2 years ago\n       \

+         \   </div>\n        </div>\n\n          <div class=\"card\">\n           \

+         \ <div class=\"card-header\">\n              <strong>Recent Commits in\n \

+         \               <span class=\"pr-toplabel\">\n                  <span class=\"\

+         oi\" data-glyph=\"random\"></span>\n                  develop\n          \

+         \      </span>\n              </strong>\n            </div>\n            <div\

+         \ class=\"card-block p-a-0\">\n              <div class=\"list-group list-group-flush\"\

+         >\n                  <a href=\"/fedora-hubs/c/37918df50b7c947be85a37827210325aecaeb8c7\"\

+         \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\

+         \ title=\"Dec 08 2017 10:56:26\">\n                      <small>\n       \

+         \                 <strong>\n                          <img class=\"avatar\

+         \ circle\" src=\"https://seccdn.libravatar.org/avatar/224e9ad3084cb9f442f423b7910701377be7a018408a4f8b22a09d94c89af46f?s=20&d=retro\"\

+         />\n                          Aur\xE9lien Bompard\n                      \

+         \  </strong>\n                        commited 5 days ago\n              \

+         \        </small>\n                    </div>\n                    <div><small>\n\

+         \                      Fix alembic migrations\n                    </small></div>\n\

+         \                  </a>\n                  <a href=\"/fedora-hubs/c/754ac2acaee5127f629ba8ad7240dab3f1276a49\"\

+         \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\

+         \ title=\"Dec 08 2017 10:54:26\">\n                      <small>\n       \

+         \                 <strong>\n                          <img class=\"avatar\

+         \ circle\" src=\"https://seccdn.libravatar.org/avatar/224e9ad3084cb9f442f423b7910701377be7a018408a4f8b22a09d94c89af46f?s=20&d=retro\"\

+         />\n                          Aur\xE9lien Bompard\n                      \

+         \  </strong>\n                        commited 5 days ago\n              \

+         \        </small>\n                    </div>\n                    <div><small>\n\

+         \                      Fix alembic migrations\n                    </small></div>\n\

+         \                  </a>\n                  <a href=\"/fedora-hubs/c/2d5673e441e8be7e80b4c707f7854e244b826241\"\

+         \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\

+         \ title=\"Dec 08 2017 10:24:32\">\n                      <small>\n       \

+         \                 <strong>\n                          <img class=\"avatar\

+         \ circle\" src=\"https://seccdn.libravatar.org/avatar/224e9ad3084cb9f442f423b7910701377be7a018408a4f8b22a09d94c89af46f?s=20&d=retro\"\

+         />\n                          Aur\xE9lien Bompard\n                      \

+         \  </strong>\n                        commited 5 days ago\n              \

+         \        </small>\n                    </div>\n                    <div><small>\n\

+         \                      Test the search function\n                    </small></div>\n\

+         \                  </a>\n              </div>\n              </div>\n    \

+         \        </div>\n          </div>\n      </div>\n    </div>\n\n\n</div>\n\

+         \    </div>\n\n    <div class=\"footer p-t-1 p-b-1\">\n        <div class=\"\

+         container\">\n            <p class=\"text-muted credit\">\n         Copyright\

+         \ &copy; 2014-2017 Red Hat\n          <a href=\"https://pagure.io/pagure\"\

+         >pagure</a> &mdash;\n          3.11.2 &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\"\

+         >Documentation</a>\n            </p>\n            <p><a href=\"/ssh_info\"\

+         >SSH Hostkey/Fingerprint</a></p>\n        </div>\n    </div>\n\n    <script\

+         \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery/jquery.min.js\"\

+         >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\

+         >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\

+         >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\

+         tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n<script type=\"\

+         text/javascript\">\n$(document).ready(function() {\n        var currentWatchStatusButton\

+         \ = $('#unwatch_button');\n    currentWatchStatusButton.prepend('<span class=\"\

+         oi\" data-glyph=\"circle-check\" style=\"padding-right:0.5em\"></span>');\n\

+         \    $('.watch-menu a').not(currentWatchStatusButton).css('padding-left',\

+         \ '2.85em');\n\n    $(\".watch-menu a\").click(function(){\n        var selectedValue\

+         \ = $(this).attr('id');\n        var watchProjectForm = $(\"#watch_project\"\

+         );\n        var action = watchProjectForm.attr('action');\n\n        if (selectedValue\

+         \ === \"watch_issues_button\") {\n            action = action.replace('/settings/0',\

+         \ '/settings/1');\n        } else if (selectedValue === \"watch_commits_button\"\

+         ) {\n            action = action.replace('/settings/0', '/settings/2');\n\

+         \        } else if (selectedValue === \"watch_issues_commits_button\") {\n\

+         \            action = action.replace('/settings/0', '/settings/3');\n    \

+         \    } else if (selectedValue === \"reset_button\") {\n            action\

+         \ = action.replace('/settings/0', '/settings/-1');\n        }\n\n        watchProjectForm.attr('action',\

+         \ action);\n        watchProjectForm.submit();\n    });\n});\n</script>\n\n\

+         <script type=\"text/javascript\">\n$(document).ready(function() {\n    $(\"\

+         .extra_gits\").hide();\n});\n\n$(function() {\n  $( \"#more_gits\" ).click(function()\

+         \ {\n      if ($( \"#more_gits\" ).html() == 'more') {\n        $( \"#more_gits\"\

+         \ ).html('less');\n      } else {\n        $( \"#more_gits\" ).html('more');\n\

+         \      }\n  });\n\n});\n</script>\n\n\n</body>\n</html>"}

+     headers:

+       Connection: [Keep-Alive]

+       Content-Length: ['23129']

+       Content-Type: [text/html; charset=utf-8]

+       Date: ['Wed, 13 Dec 2017 16:49:12 GMT']

+       Keep-Alive: ['timeout=5, max=100']

+       Referrer-Policy: [same-origin]

+       Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.2k-fips mod_wsgi/3.4

+           Python/2.7.5]

+       Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlLCJjc3JmIjp7IiBiIjoiT1RSak5EQmtZemhrTXpNek5URTVZbVEzTXpCa016ZGpZMlkwTUdGbFptTmtaVEExTWpVMll3PT0ifX0.DRLriA.5_Nu2PevTT_Sld7a7iQbD61SApE;

+           Expires=Sat, 13-Jan-2018 16:49:12 GMT; Secure; HttpOnly; Path=/']

+       Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]

+       X-Content-Type-Options: [nosniff]

+       X-Frame-Options: ['ALLOW FROM https://pagure.io/']

+       X-Xss-Protection: [1; mode=block]

+     status: {code: 200, message: OK}

+ - request:

+     body: null

+     headers:

+       Accept: ['*/*']

+       Accept-Encoding: ['gzip, deflate']

+       Connection: [keep-alive]

+       User-Agent: [python-requests/2.18.4]

+     method: GET

+     uri: https://pagure.io/something-that-does-not-exist

+   response:

+     body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\

+         Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Page not\

+         \ found :'( - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"image/vnd.microsoft.icon\"\

+         \n        href=\"/static/favicon.ico\"/>\n    <link href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\

+         \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\

+         \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\

+         \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\

+         \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\

+         \n        type=\"text/css\" rel=\"stylesheet\" />\n  </head>\n  <body id=\"\

+         error\">\n  <!-- start masthead -->\n    <div class=\"masthead\">\n      <div\

+         \ class=\"container\">\n        <div class=\"row\">\n          <div class=\"\

+         col-sm-3\">\n            <a href=\"/\">\n              <img height=40px src=\"\

+         /static/pagure-logo.png\"\n                alt=\"pagure Logo\" id=\"pagureLogo\"\

+         />\n            </a>\n          </div>\n          <div class=\"col-sm-9\"\

+         >\n            <div class=\"row\">\n\n  <nav class=\"navbar navbar-light p-t-0\

+         \ p-b-0\">\n      <div class=\"container\">\n        <ul class=\"nav navbar-nav\

+         \ nav-underline pull-xs-right\">\n              <li class=\"nav-item p-l-1\"\

+         >\n                <a class=\"nav-link btn btn-primary\" href=\"/login/?next=https://pagure.io/something-that-does-not-exist\"\

+         >Log In</a>\n              </li>\n        </ul>\n      </div>\n    </nav>\n\

+         \n            </div>\n          </div>\n        </div>\n      </div>\n   \

+         \ </div><!-- close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\

+         \n<div class=\"container p-t-3\">\n  <div class=\"row\">\n    <div class=\"\

+         col-md-8\">\n      <h2>Page not found (404)</h2>\n      <p>With the message:</p>\n\

+         \      <div class=\"card-block\">\n      <p>Project not found</p>\n      </div>\n\

+         \      <p>You have either entered a bad URL or the page has moved, removed,\

+         \ or otherwise rendered unavailable.<br/>\n      Please use the main navigation\

+         \ menu to get (re)started.</p>\n    </div>\n  </div>\n</div>\n    </div>\n\

+         \n    <div class=\"footer p-t-1 p-b-1\">\n        <div class=\"container\"\

+         >\n            <p class=\"text-muted credit\">\n         Copyright &copy;\

+         \ 2014-2017 Red Hat\n          <a href=\"https://pagure.io/pagure\">pagure</a>\

+         \ &mdash;\n          3.11.2 &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\"\

+         >Documentation</a>\n            </p>\n            <p><a href=\"/ssh_info\"\

+         >SSH Hostkey/Fingerprint</a></p>\n        </div>\n    </div>\n\n    <script\

+         \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery/jquery.min.js\"\

+         >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\

+         >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\

+         >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\

+         tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n\n</body>\n\

+         </html>"}

+     headers:

+       Connection: [Keep-Alive]

+       Content-Length: ['3060']

+       Content-Type: [text/html; charset=utf-8]

+       Date: ['Wed, 13 Dec 2017 16:49:13 GMT']

+       Keep-Alive: ['timeout=5, max=100']

+       Referrer-Policy: [same-origin]

+       Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.2k-fips mod_wsgi/3.4

+           Python/2.7.5]

+       Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlfQ.DRLriQ.jQ4kD-EQkD9NU61IU3IuxXWA6cU;

+           Expires=Sat, 13-Jan-2018 16:49:13 GMT; Secure; HttpOnly; Path=/']

+       Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]

+       X-Content-Type-Options: [nosniff]

+       X-Frame-Options: ['ALLOW FROM https://pagure.io/']

+       X-Xss-Protection: [1; mode=block]

+     status: {code: 404, message: NOT FOUND}

+ version: 1

@@ -32,11 +32,15 @@ 

              "subscribed_to": [],

              'perms': {'config': True},

              "config": {

+                 "archived": False,

                  "avatar": avatar_url,

                  'chat_channel': None,

                  'chat_domain': None,

+                 "calendar": None,

+                 "mailing_list": None,

+                 "github": [],

+                 "pagure": [],

                  "left_width": 8,

-                 "right_width": 4,

                  "summary": "Ralph",

                  "visibility": "public",

              },
@@ -60,7 +64,7 @@ 

  

      def test_private_get(self):

          hub = Hub.by_name('ralph')

-         hub.config.visibility = "private"

+         hub.config["visibility"] = "private"

          self.session.commit()

          # Private hubs are not accessible to anonymous users.

          response = self.check_url("/api/hubs/ralph/config", code=403)
@@ -87,7 +91,6 @@ 

                  data=json.dumps({

                      "config": {

                          "summary": "changed value",

-                         "chat_domain": "",

                      }

                  })

              )
@@ -95,10 +98,7 @@ 

          result_data = json.loads(result.get_data(as_text=True))

          self.assertEqual(result_data["status"], "OK")

          hub_config = Hub.query.get("ralph").config

-         self.assertEqual(hub_config.summary, "changed value")

-         self.assertEqual(

-             hub_config.chat_domain,

-             app.config["CHAT_NETWORKS"][0]["domain"])

+         self.assertEqual(hub_config["summary"], "changed value")

  

      def test_put_unknown_data(self):

          # Unknown PUT data is silently ignored
@@ -111,12 +111,8 @@ 

          self.assertEqual(result.status_code, 200)

          result_data = json.loads(result.get_data(as_text=True))

          self.assertEqual(result_data["status"], "ERROR")

-         self.assertEqual(result_data["message"], "Invalid value(s)")

-         self.assertIn("non_existant", result_data["fields"])

          self.assertEqual(

-             result_data["fields"]["non_existant"],

-             "Unexpected parameter."

-             )

+             result_data["message"], "Invalid config key: non_existant")

  

      def test_put_invalid_chat_domain(self):

          user = FakeAuthorization('ralph')
@@ -144,7 +140,7 @@ 

                  data=json.dumps({"config": {"summary": "Defaced!"}}))

          self.assertEqual(result.status_code, 403)

          hub_config = Hub.query.get("decause").config

-         self.assertEqual(hub_config.summary, "Decause")

+         self.assertEqual(hub_config["summary"], "Decause")

  

      def test_put_users(self):

          hub = Hub.by_name('ralph')

@@ -22,7 +22,7 @@ 

  

      def test_get_widgets_private(self):

          hub = Hub.by_name('ralph')

-         hub.config.visibility = "private"

+         hub.config["visibility"] = "private"

          self.session.commit()

          # Private hubs are not accessible to anonymous users.

          response = self.check_url("/api/hubs/ralph/widgets/", code=403)
@@ -42,7 +42,7 @@ 

  

      def test_get_widgets_preview(self):

          hub = Hub.by_name('ralph')

-         hub.config.visibility = "preview"

+         hub.config["visibility"] = "preview"

          meetings = Widget.query.filter_by(

              hub=hub, plugin="meetings").first()

          # set the widget to "restricted"
@@ -238,7 +238,7 @@ 

  

      def test_get_logged_out(self):

          hub = Hub.by_name('ralph')

-         hub.config.visibility = "private"

+         hub.config["visibility"] = "private"

          self.session.commit()

          response = self.check_url("/api/hubs/ralph/widgets/31/", code=403)

          response_data = json.loads(response.get_data(as_text=True))

@@ -27,7 +27,7 @@ 

  

      def test_hub_private(self):

          hub = Hub.by_name('ralph')

-         hub.config.visibility = "private"

+         hub.config["visibility"] = "private"

          self.session.commit()

          # Private hubs are not accessible to anonymous users.

          self.check_url("/ralph/", code=403)

@@ -1,7 +1,6 @@ 

  from __future__ import unicode_literals

  

  import json

- import unittest

  

  from mock import Mock, patch

  
@@ -143,50 +142,3 @@ 

                  '/stream/saved/{}/'.format(self.user.nickname, idx)

              )

          self.assertEqual(resp.status_code, 404)

- 

- 

- class TestHubVisits(hubs.tests.APPTest):

- 

-     def test_hub_visit_counter_logged_in(self):

-         user = hubs.tests.FakeAuthorization('ralph')

-         with hubs.tests.auth_set(app, user):

-             url = '/visit/decause'

-             result = self.app.get(url)

-             self.assertEqual(

-                 json.loads(result.get_data(as_text=True)),

-                 {"count": 0})

- 

-             result = self.app.post(url)

-             self.assertEqual(

-                 json.loads(result.get_data(as_text=True)),

-                 {"count": 1})

- 

-             # accessing my hub shouldn't increment the count

-             url = 'visit/ralph'

-             result = self.app.post(url)

-             self.assertEqual(result.status_code, 403)

- 

-             # visiting no hub while logged should throw a 405

-             url = 'visit/'

-             result = self.app.post(url)

-             self.assertEqual(result.status_code, 405)

- 

-             # visiting a hub that doesn't exist should 404

-             url = 'visit/hub-does-not-exist'

-             result = self.app.post(url)

-             self.assertEqual(result.status_code, 404)

- 

-     @unittest.skip("Ajax calls don't seem to work in unittests ")

-     def test_hub_vist_counter_logged_in_2(self):

-         user = hubs.tests.FakeAuthorization('ralph')

-         with hubs.tests.auth_set(app, user):

-             url = '/visit/decause'

-             result = self.app.get(url)

-             self.assertEqual(result.get_data(as_text=True), '0')

- 

-             url = '/decause'

-             result = self.app.get(url, follow_redirects=True)

- 

-             url = '/visit/decause'

-             result = self.app.get(url)

-             self.assertEqual(result.get_data(as_text=True), '1')

@@ -50,13 +50,13 @@ 

          # Public

          self.check_url(url, None, 200)

          # Preview

-         hub.config.visibility = "preview"

+         hub.config["visibility"] = "preview"

          widget.visibility = "restricted"

          self.session.commit()

          self.check_url(url, None, 403)  # anonymous

          self.check_url(url, FakeAuthorization('decause'), 200)  # logged-in

          # Private

-         widget.hub.config.visibility = "private"

+         widget.hub.config["visibility"] = "private"

          self.session.commit()

          self.check_url(url, None, 403)  # anonymous

          self.check_url(url, FakeAuthorization('decause'), 403)  # logged-in

@@ -111,13 +111,13 @@ 

          config["per_page"] = 3

          self.widget.config = config

          infra_hub = Hub.query.get("infra")

-         infra_hub.config.chat_channel = "#fedora-meeting"

+         infra_hub.config["chat_channel"] = "#fedora-meeting"

          devyani7 = Hub.query.get("devyani7")

-         devyani7.config.chat_channel = "#fedora-meeting-2"

+         devyani7.config["chat_channel"] = "#fedora-meeting-2"

          decause = Hub.query.get("decause")

-         decause.config.chat_channel = "#ansible-meeting"

+         decause.config["chat_channel"] = "#ansible-meeting"

          dhrish = Hub.query.get("dhrish")

-         dhrish.config.chat_channel = "#foss2serve"

+         dhrish.config["chat_channel"] = "#foss2serve"

          self.session.commit()

          self.session.refresh(self.widget)

  
@@ -371,7 +371,7 @@ 

          self.widget.config = config

          # The infra hub works in the fedora-infra channel.

          infra_hub = Hub.query.filter_by(name="infra").one()

-         infra_hub.config.chat_channel = "#fedora-infra"

+         infra_hub.config["chat_channel"] = "#fedora-infra"

          self.session.commit()

  

      def test_execute(self):
@@ -387,7 +387,7 @@ 

      def test_should_invalidate_wrong_hub(self):

          # The decause hub works in the fedora-commops channel.

          decause_hub = Hub.query.filter_by(name="decause").one()

-         decause_hub.config.chat_channel = "#fedora-commops"

+         decause_hub.config["chat_channel"] = "#fedora-commops"

          self.session.commit()

          msg = {'topic': 'org.fedoraproject.prod.meetbot.meeting.item.help',

                 'msg': {'channel': '#fedora-commops'},

@@ -10,6 +10,7 @@ 

      def test_data_simple(self):

          team = 'i18n'

          widget = widget_instance(team, self.plugin)

+         widget.hub.config["calendar"] = team

          user = FakeAuthorization('ralph')

          response = self.check_url(

              '/%s/w/%s/%i/' % (team, self.plugin, widget.idx), user)
@@ -18,6 +19,7 @@ 

      def test_render_simple(self):

          team = 'i18n'

          widget = widget_instance(team, self.plugin)

+         widget.hub.config["calendar"] = team

          user = FakeAuthorization('ralph')

          url = '/%s/w/%s/%i/' % (team, self.plugin, widget.idx)

          response = self.check_url(url, user)

@@ -20,8 +20,7 @@ 

  

          self.session.add(hubs.models.Hub(name="designteam"))

          hub = hubs.models.Hub.by_name('designteam')

-         self.session.add(

-             hubs.models.HubConfig(hub=hub, summary="the designteam team"))

+         hub.config["summary"] = "the designteam team"

          hub.subscribe(hubs.models.User.by_username('ralph'), 'member')

  

      def test_view_authz(self):

file modified
+2 -2
@@ -40,8 +40,8 @@ 

  

  

  def hub2groupavatar(hub):

-     if hub.config.avatar != "":

-         return '<img src="%s" width="60px" />' % (hub.config.avatar)

+     if hub.config["avatar"] != "":

+         return '<img src="%s" width="60px" />' % (hub.config["avatar"])

      return ("<div class='monogram-avatar bg-fedora-%s text-fedora-%s-dark'>"

              "%s</div>") % (hubname2monogramcolour(hub.name),

                             hubname2monogramcolour(hub.name),

file modified
+3 -3
@@ -13,7 +13,7 @@ 

      log.info("Finding github organization for {}".format(username))

      tmpl = "https://api.github.com/users/{username}"

      url = tmpl.format(username=username)

-     result = requests.get(url)

+     result = requests.get(url, timeout=5)

      return result.ok

  

  
@@ -21,7 +21,7 @@ 

      log.info("Finding github repo for {} and {} ".format(repo, username))

      tmpl = "https://api.github.com/repos/{username}/{repo}"

      url = tmpl.format(username=username, repo=repo)

-     result = requests.get(url)

+     result = requests.get(url, timeout=5)

      return result.ok

  

  
@@ -54,7 +54,7 @@ 

  def _github_results(url, auth):

      link = dict(next=url)

      while 'next' in link:

-         response = requests.get(link['next'], params=auth)

+         response = requests.get(link['next'], params=auth, timeout=5)

  

          # And.. if we didn't get good results, just bail.

          if not bool(response):

hubs/utils/validators.py hubs/widgets/validators.py
file renamed
+41 -10
@@ -1,23 +1,23 @@ 

  """

- Validate and convert the value of widget parameters.

+ Validate and convert the value of widget or hub configuration.

  

  Validators are used to validate and convert

- :py:class:`~hubs.widgets.base.WidgetParameter` values. They will raise a

- ``ValueError`` exception if the value is invalid.

+ :py:class:`~hubs.widgets.base.WidgetParameter` values or Hub configuration

+ values. They will raise a ``ValueError`` exception if the value is invalid.

  

  A validator is a function that will receive the value as unique argument, and

  will return the validated value.

  """

  

  from __future__ import unicode_literals

- from hubs.utils.github import github_org_is_valid, github_repo_is_valid

  

  import flask

- import hubs.models

  import kitchen.text.converters

  import requests

  import six

  

+ from hubs.utils.github import github_org_is_valid, github_repo_is_valid

+ 

  

  def Noop(value):

      """Does no validation, just return the value."""
@@ -61,13 +61,25 @@ 

      This validator does not return the User instance because it is not

      JSON-serializable, it returns the username unchanged.

      """

+     from hubs.models import User

      if value is None and flask.g.auth.logged_in:

          return flask.g.user.username

-     if hubs.models.User.by_username(value) is not None:

+     if User.by_username(value) is not None:

          return value

      raise ValueError('Invalid username')

  

  

+ def GithubOrgAndRepo(value):

+     """Fails if the Github organization or repository name don't exist."""

+     try:

+         username, repo = value.split('/', 1)

+     except ValueError:

+         raise ValueError("The repo must contain a '/'")

+     if not github_repo_is_valid(username, repo):

+         raise ValueError('Invalid Github org or repository: {}'.format(value))

+     return "/".join([username, repo])

+ 

+ 

  def GithubOrganization(value):

      """Fails if the Github organization name does not exist."""

      if not github_org_is_valid(value):
@@ -77,9 +89,12 @@ 

  

  def GithubRepo(value):

      """Fails if the Github repository name does not exist."""

-     username, repo = value.split('/')

+     try:

+         username, repo = value.split('/', 1)

+     except ValueError:

+         raise ValueError("The repo must contain a '/'")

      if not github_repo_is_valid(username, repo):

-         raise ValueError('Github repository does not exist')

+         raise ValueError('Invalid Github repository: {}'.format(value))

      return repo

  

  
@@ -94,9 +109,9 @@ 

  def PagureRepo(value):

      """Fails if the Pagure repository name does not exist."""

      response = requests.get("https://pagure.io/%s" % value, timeout=5)

-     if response.status_code == 200:

+     if response.ok:

          return value

-     raise ValueError('Invalid pagure repo')

+     raise ValueError('Invalid Pagure repo: {}'.format(value))

  

  

  def CommaSeparatedList(value):
@@ -116,3 +131,19 @@ 

      if not isinstance(value, list):

          raise ValueError("Expected a list")

      return value

+ 

+ 

+ def ChatDomain(value):

+     valid_chat_domains = [

+         network["domain"] for network in

+         flask.current_app.config["CHAT_NETWORKS"]

+         ]

+     if value not in valid_chat_domains:

+         raise ValueError("Unsupported chat domain.")

+     return value

+ 

+ 

+ def Email(value):

+     if value and "@" not in value:

+         raise ValueError("The address must contain \"@\".")

+     return value

file modified
+12 -9
@@ -10,8 +10,7 @@ 

  

  import flask

  from six.moves.urllib import parse as urlparse

- from sqlalchemy import or_

- from sqlalchemy.orm import joinedload

+ from sqlalchemy import or_, and_

  from sqlalchemy.orm.exc import NoResultFound

  

  from hubs.models import Hub, HubConfig, Widget
@@ -20,11 +19,9 @@ 

  log = logging.getLogger(__name__)

  

  

- def get_hub(name, load_config=False):

+ def get_hub(name):

      """ Utility shorthand to get a hub and 404 if not found. """

      query = Hub.query.filter(Hub.name == name)

-     if load_config:

-         query = query.options(joinedload(Hub.config))

      try:

          return query.one()

      except NoResultFound:
@@ -32,9 +29,15 @@ 

  

  

  def query_hubs(querystring):

-     query = Hub.query.join(HubConfig)

-     query = query.filter(or_(HubConfig.summary.ilike('%%%s%%' % querystring),

-                              Hub.name.ilike('%%%s%%' % querystring)))

+     query = Hub.query.join(HubConfig).filter(

+         or_(

+             Hub.name.ilike('%{}%'.format(querystring)),

+             and_(

+                 HubConfig.key == "summary",

+                 HubConfig.value.ilike('%{}%'.format(querystring)),

+             )

+         )

+     )

      return query.all()

  

  
@@ -272,7 +275,7 @@ 

          @functools.wraps(function)

          def wrapper(*args, **kwargs):

              hub_name = kwargs[url_param]

-             hub = get_hub(hub_name, load_config=True)

+             hub = get_hub(hub_name)

              check_hub_access(hub, action, json)

              return function(*args, **kwargs)

          return wrapper

file modified
+27 -30
@@ -8,8 +8,7 @@ 

  from hubs.app import app

  from hubs.signals import hub_updated

  from hubs.utils.views import (

-     get_hub, get_user_permissions, check_hub_access, RequestValidator,

-     require_hub_access,

+     get_hub, get_user_permissions, check_hub_access, require_hub_access,

      )

  

  log = logging.getLogger(__name__)
@@ -25,7 +24,7 @@ 

      hub = get_hub(name)

      if flask.request.method == 'PUT':

          check_hub_access(hub, "config", json=True)

-         old_config = hub.config.__json__()

+         old_config = hub.config.to_dict()

          request_data = flask.request.get_json()

          if request_data is None:

              return flask.jsonify({
@@ -41,11 +40,12 @@ 

              result = e.args[0]

          else:

              result = {"status": "OK"}

-         try:

-             flask.g.db.commit()

-         except Exception as err:

-             result = {"status": "ERROR", "message": str(err)}

-         hub_updated.send(hub, old_config=old_config)

+             try:

+                 flask.g.db.commit()

+             except Exception as err:

+                 result = {"status": "ERROR", "message": str(err)}

+             else:

+                 hub_updated.send(hub, old_config=old_config)

          return flask.jsonify(result)

      data = hub.get_props()

      data["perms"] = get_user_permissions(hub)
@@ -54,32 +54,29 @@ 

  

  

  def hub_config_put_config(hub, config):

+     config = {

+         key: value

+         for key, value in config.items()

+         if value is not None

+     }

      # Validate values

-     def _validate_chat_domain(value):

-         valid_chat_domains = [

-             network["domain"] for network in app.config["CHAT_NETWORKS"]

-             ]

-         if not value and len(valid_chat_domains) > 0:

-             value = valid_chat_domains[0]

-         if value not in valid_chat_domains:

-             raise ValueError("Unsupported chat domain.")

-         return value

- 

-     validator = RequestValidator(dict(

-         # Only allow the parameters listed in __json__().

-         (key, None) for key in hub.config.__json__().keys()

-     ))

-     validator.converters["chat_domain"] = _validate_chat_domain

      try:

-         values = validator(config)

+         values = hub.config.validate(config)

      except ValueError as e:

-         result = {"status": "ERROR", "message": "Invalid value(s)"}

-         result["fields"] = e.args[0]

+         result = {"status": "ERROR"}

+         error = e.args[0]

+         if isinstance(error, dict):

+             result["message"] = "Invalid value(s)"

+             result["fields"] = error

+         else:

+             result["message"] = error

          raise ConfigChangeError(result)

- 

-     # Now set the configuration values.

-     for key, value in values.items():

-         setattr(hub.config, key, value)

+     # Set the new configuration values.

+     hub.config.update(values)

+     # Clear the config values that aren't in the request.

+     for key in hub.config.keys():

+         if config.get(key) is None:

+             del hub.config[key]

  

  

  def hub_config_put_users(hub, user_roles):

file modified
+3 -2
@@ -13,11 +13,12 @@ 

  @require_hub_access("view")

  @login_required

  def hub(name):

-     hub = get_hub(name, load_config=True)

+     hub = get_hub(name)

      global_config = {

          "chat_networks": app.config["CHAT_NETWORKS"],

-         "hub_visibility": hubs.models.HubConfig.VISIBILITY,

+         "hub_visibility": hubs.models.constants.VISIBILITIES,

          "roles": ["owner", "member"],

+         "dev_platforms": hubs.models.constants.DEV_PLATFORMS,

      }

      urls = {

          "widgets": flask.url_for("api_hub_widgets", hub=hub.name),

file modified
-26
@@ -95,29 +95,3 @@ 

      flask.g.db.delete(notification)

      flask.g.db.commit()

      return flask.jsonify(dict(status="OK"))

- 

- 

- @app.route('/visit/<visited_hub>/', methods=['GET', 'POST'])

- @app.route('/visit/<visited_hub>', methods=['GET', 'POST'])

- @login_required

- def increment_counter(visited_hub):

-     nickname = flask.g.auth.nickname

- 

-     if str(visited_hub) != str(nickname):

-         try:

-             vc = hubs.models.VisitCounter.get_or_create(

-                 username=nickname, visited_hub=visited_hub)

-         except ValueError:  # this should never trip

-             # flask will 405 if visited_hub is blank

-             # @login_required forces flask.g.auth to be sets

-             flask.abort(404)

-         if flask.request.method == 'POST':

-             vc.increment_visits(username=nickname,

-                                 visited_hub=visited_hub)

- 

-             return flask.jsonify({'count': vc.count})

- 

-         elif flask.request.method == 'GET':

-             return flask.jsonify({'count': vc.count})

-     else:

-         return flask.abort(403)

@@ -1,6 +1,6 @@ 

  from __future__ import unicode_literals

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  

@@ -3,7 +3,7 @@ 

  import operator

  import requests

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  from hubs.widgets.caching import CachedFunction

@@ -4,8 +4,8 @@ 

  

  import pkgwat.api

  

+ from hubs.utils import validators

  from hubs.utils.packages import get_user_packages

- from hubs.widgets import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  from hubs.widgets.caching import CachedFunction

@@ -1,6 +1,6 @@ 

  from __future__ import unicode_literals

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  

@@ -6,7 +6,7 @@ 

  import flask

  

  from hubs.feed import format_msgs

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import WidgetView

  

@@ -2,9 +2,8 @@ 

  

  import logging

  

- from hubs.utils import get_fedmsg_config

+ from hubs.utils import get_fedmsg_config, validators

  from hubs.utils.github import github_repos, github_pulls

- from hubs.widgets import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  from hubs.widgets.caching import CachedFunction
@@ -14,6 +13,10 @@ 

  fedmsg_config = get_fedmsg_config()

  

  

+ # TODO: use a checkbox set to select which repos to use from the hub's

+ # configuration.

+ 

+ 

  class GitHubPRs(Widget):

  

      name = "github_pr"

@@ -2,12 +2,16 @@ 

  

  import requests

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  from hubs.widgets.caching import CachedFunction

  

  

+ # TODO: use a checkbox set to select which repos to use from the hub's

+ # configuration.

+ 

+ 

  class GitHubIssues(Widget):

  

      name = "githubissues"

@@ -4,8 +4,8 @@ 

  import arrow

  import flask

  

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

- from hubs.widgets import validators

  from .views import hubs_suggest_view

  from .utils import listofhubs_validator

  

file modified
+3 -2
@@ -17,9 +17,10 @@ 

      return [

          h[0] for h in

          Hub.query.join(HubConfig).filter(

-             HubConfig.chat_channel == msg["channel"]

+             HubConfig.key == "chat_channel",

+             HubConfig.value == msg["channel"]

          ).values(Hub.name)

-         ]

+     ]

  

  

  def listofhubs_validator(value):

file modified
+1 -1
@@ -1,6 +1,6 @@ 

  from __future__ import unicode_literals, absolute_import

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  

  

@@ -2,7 +2,7 @@ 

  

  import flask

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  

  

@@ -1,6 +1,6 @@ 

  from __future__ import unicode_literals

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  

@@ -5,8 +5,8 @@ 

  import datetime

  import requests

  

+ from hubs.utils import validators

  from hubs.utils.text import markup

- from hubs.widgets import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  from hubs.widgets.caching import CachedFunction
@@ -18,12 +18,6 @@ 

      position = "both"

      parameters = [

          dict(

-             name="calendar",

-             label="Calendar",

-             default=None,

-             validator=validators.Required,

-             help="A fedocal calendar.",

-         ), dict(

              name="n_meetings",

              label="Number of meetings",

              default=4,
@@ -49,7 +43,7 @@ 

              if meeting['start_dt'] > now

              }

          return dict(

-             calendar=instance.config["calendar"],

+             calendar=instance.hub.config.get("calendar"),

              meetings=meetings,

              )

  
@@ -57,9 +51,12 @@ 

  class GetMeetings(CachedFunction):

  

      TOPIC = ".fedocal.calendar."

+     invalidate_on_hub_config_change = True

  

      def execute(self):

-         calendar = self.instance.config["calendar"]

+         calendar = self.instance.hub.config.get("calendar")

+         if calendar is None:

+             return {}

          n_meetings = self.instance.config.get("n_meetings", 4)

          base = ('https://apps.fedoraproject.org/calendar/api/meetings/'

                  '?calendar=%s')
@@ -91,7 +88,11 @@ 

              calendar = message["msg"]["calendar"]["calendar_name"]

          except KeyError:

              return False

-         return (calendar == self.instance.config.get("calendar"))

+         return (calendar == self.instance.hub.config.get("calendar"))

+ 

+     def should_invalidate_on_hub_config_change(self, old_config):

+         new_config = self.instance.hub.config

+         return old_config.get("calendar") != new_config.get("calendar")

  

  

  def next_meeting(meetings):

@@ -1,53 +1,59 @@ 

- {% for title, next in meetings.items() %}

- <div class="meeting">

-   <div class="row no-gutters">

-     <div class="col-md-12">

-       <h5>The {{ next.meeting_name }} is {{ next.start_dt | humanize }}:</h5>

-     </div>

-     <div class="col-md-3 meeting-date">

-       {% if next.display_duration %}

-       <strong>{{ next.start_date }} - {{ next.stop_date }}</strong>

-       {% else %}

-       <strong>{{next.start_date}}</strong>

-       {% endif %}

-     </div>

-     <div class="col-md-4 col-lg-5 meeting-time">

-       {% if next.display_time %}

-       @{{ next.start_time }}{% endif %}<br>

+ {% if not calendar %}

+   <p class="text-warning mb-0">

+     <em>You must configure a calendar in the hub configuration in order to use this widget.</em>

+   </p>

+ {% else %}

+   {% for title, next in meetings.items() %}

+   <div class="meeting">

+     <div class="row no-gutters">

+       <div class="col-md-12">

+         <h5>The {{ next.meeting_name }} is {{ next.start_dt | humanize }}:</h5>

+       </div>

+       <div class="col-md-3 meeting-date">

+         {% if next.display_duration %}

+         <strong>{{ next.start_date }} - {{ next.stop_date }}</strong>

+         {% else %}

+         <strong>{{next.start_date}}</strong>

+         {% endif %}

+       </div>

+       <div class="col-md-4 col-lg-5 meeting-time">

+         {% if next.display_time %}

+         @{{ next.start_time }}{% endif %}<br>

  

-       <a href="#">{{ next.location }}</a>

+         <a href="#">{{ next.location }}</a>

+       </div>

+       <div class="col-md-5 col-lg-4 text-right">

+         <a href="https://apps.fedoraproject.org/calendar/ical/calendar/meeting/{{ next.meeting_id }}/">

+           <button class="btn btn-secondary btn-sm">

+             <small><strong>Add to calendar</strong></small>

+           </button>

+         </a>

+       </div>

      </div>

-     <div class="col-md-5 col-lg-4 text-right">

-       <a href="https://apps.fedoraproject.org/calendar/ical/calendar/meeting/{{ next.meeting_id }}/">

-         <button class="btn btn-secondary btn-sm">

-           <small><strong>Add to calendar</strong></small>

-         </button>

-       </a>

+     {% if next.get('meeting_information_html')  %}

+     <div class="row">

+         <div class="col-md-12 meeting-info">

+           {{ next.meeting_information_html[:150] }}

+           {%- if next.meeting_information_html | length > 150 %}...

+           <p>

+             <a href="https://apps.fedoraproject.org/calendar/meeting/{{ next.meeting_id }}/">Full description</a>

+           </p>

+           {% endif %}

+         </div>

      </div>

+     {% endif  %}

    </div>

-   {% if next.get('meeting_information_html')  %}

-   <div class="row">

-       <div class="col-md-12 meeting-info">

-         {{ next.meeting_information_html[:150] }}

-         {%- if next.meeting_information_html | length > 150 %}...

-         <p>

-           <a href="https://apps.fedoraproject.org/calendar/meeting/{{ next.meeting_id }}/">Full description</a>

-         </p>

-         {% endif %}

-       </div>

-   </div>

-   {% endif  %}

- </div>

- <!-- {% if meetings | length > 3 %} <hr> {% endif %} -->

- {% else %}

- <div class="meeting">

-   <div class="row">

-     No coming meetings in {{ calendar }}.

+   <!-- {% if meetings | length > 3 %} <hr> {% endif %} -->

+   {% else %}

+   <div class="meeting">

+     <div class="row">

+       No coming meetings in {{ calendar }}.

+     </div>

    </div>

- </div>

- <p class="text-center mt-3">

-   <button class="btn btn-secondary">

-     <strong>Request A New Meeting</strong>

-   </button>

- </p>

- {% endfor %}

+   <p class="text-center mt-3">

+     <button class="btn btn-secondary">

+       <strong>Request A New Meeting</strong>

+     </button>

+   </p>

+   {% endfor %}

+ {% endif %}

@@ -1,6 +1,6 @@ 

  from __future__ import unicode_literals

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  from hubs.widgets.caching import CachedFunction
@@ -9,6 +9,9 @@ 

  

  pagure_url = "https://pagure.io/api/0"

  

+ # TODO: use a checkbox set to select which repos to use from the hub's

+ # configuration.

+ 

  

  class PagurePRs(Widget):

  

@@ -2,13 +2,16 @@ 

  

  import requests

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  from hubs.widgets.caching import CachedFunction

  

  pagure_url = "https://pagure.io/api/0"

  

+ # TODO: use a checkbox set to select which repos to use from the hub's

+ # configuration.

+ 

  

  class PagureIssues(Widget):

  

file modified
+1 -1
@@ -1,6 +1,6 @@ 

  from __future__ import unicode_literals, absolute_import

  

- from .validators import Noop

+ from hubs.utils.validators import Noop

  

  

  class WidgetParameter(object):

file modified
+14 -10
@@ -2,8 +2,7 @@ 

  

  from collections import OrderedDict as ordereddict

  

- from hubs.utils import username2avatar

- from hubs.widgets import validators

+ from hubs.utils import username2avatar, validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  
@@ -49,7 +48,9 @@ 

  class BaseView(RootWidgetView):

  

      def get_context(self, instance, *args, **kwargs):

-         owners = instance.hub.owners

+         hub = instance.hub

+         hub_config = hub.config

+         owners = hub.owners

          oldest_owners = sorted(

              owners, key=lambda o: o.created_on)[:ELLIPSIS_LIMIT]

          oldest_owners = [{
@@ -60,14 +61,17 @@ 

          owners = ordereddict([

              (o.username, username2avatar(o.username)) for o in owners

          ])

-         mailing_list = "{}@lists.fedoraproject.org".format(instance.hub.name)

-         mailing_list_url = (

-             'https://lists.fedoraproject.org/archives/list/{}@'

-             'lists.fedoraproject.org/').format(instance.hub.name)

+         mailing_list = hub_config["mailing_list"]

+         if mailing_list is not None:

+             mailing_list_url = (

+                 'https://lists.fedoraproject.org/archives/list/{}/'.format(

+                     mailing_list))

+         else:

+             mailing_list_url = None

          irc_channel = irc_network = None

-         if instance.hub.config.chat_channel:

-             irc_channel = instance.hub.config.chat_channel

-             irc_network = instance.hub.config.chat_domain

+         if hub_config["chat_channel"]:

+             irc_channel = hub_config["chat_channel"]

+             irc_network = hub_config["chat_domain"]

          return dict(

              oldest_owners=oldest_owners,

              owners=owners,

@@ -1,6 +1,7 @@ 

  from __future__ import unicode_literals

  

- from hubs.widgets import clean_input, validators

+ from hubs.utils import validators

+ from hubs.widgets import clean_input

  from hubs.widgets.base import Widget

  from hubs.widgets.caching import CachedFunction

  from hubs.widgets.view import RootWidgetView

@@ -2,7 +2,7 @@ 

  

  import requests

  

- from hubs.widgets import validators

+ from hubs.utils import validators

  from hubs.widgets.base import Widget

  from hubs.widgets.view import RootWidgetView

  from hubs.widgets.caching import CachedFunction

file modified
+10 -13
@@ -39,10 +39,13 @@ 

  db.commit()

  

  # ############# Internationalizationteam

- hub = hubs.models.Hub(name='i18n', archived=True)

+ hub = hubs.models.Hub(name='i18n')

  db.add(hub)

- db.add(hubs.models.HubConfig(

-     hub=hub, summary='The Internationalization Team', avatar=placekitten))

+ hub.config.update(dict(

+     summary='The Internationalization Team',

+     avatar=placekitten,

+     archived=True,

+ ))

  

  widget = hubs.models.Widget(

      plugin='rules', index=1, _config=json.dumps({
@@ -94,8 +97,7 @@ 

  # ############# CommOps

  hub = hubs.models.Hub(name='commops')

  db.add(hub)

- db.add(hubs.models.HubConfig(

-     hub=hub, summary='The Fedora Community Operations Team'))

+ hub.config["summary"] = 'The Fedora Community Operations Team'

  

  widget = hubs.models.Widget(

      plugin='rules', index=1, _config=json.dumps({
@@ -144,8 +146,7 @@ 

  # ############# Marketing team

  hub = hubs.models.Hub(name='marketing')

  db.add(hub)

- db.add(hubs.models.HubConfig(

-     hub=hub, summary='The Fedora Marketing Team'))

+ hub.config["summary"] = 'The Fedora Marketing Team'

  

  widget = hubs.models.Widget(

      plugin='rules', index=1, _config=json.dumps({
@@ -201,8 +202,7 @@ 

  # ############# Design team

  hub = hubs.models.Hub(name='designteam')

  db.add(hub)

- db.add(hubs.models.HubConfig(

-     hub=hub, summary='The Fedora Design Team'))

+ hub.config["summary"] = 'The Fedora Design Team'

  

  widget = hubs.models.Widget(

      plugin='rules', index=1, _config=json.dumps({
@@ -255,8 +255,7 @@ 

  # ############# Infra team

  hub = hubs.models.Hub(name='infrastructure')

  db.add(hub)

- db.add(hubs.models.HubConfig(

-     hub=hub, summary='The Fedora Infra Team'))

+ hub.config["summary"] = 'The Fedora Infra Team'

  

  widget = hubs.models.Widget(

      plugin='rules', index=1, _config=json.dumps({
@@ -295,8 +294,6 @@ 

  widget = hubs.models.Widget(plugin='feed', index=3, left=True)

  hub.widgets.append(widget)

  

- vc = hubs.models.VisitCounter.get_or_create('ralph', 'mrichard')

- vc.increment_visits('ralph', 'mrichard')

  hub.subscribe(hubs.models.User.by_username('ralph'), 'owner')

  hub.subscribe(hubs.models.User.by_username('abompard'), 'owner')

  hub.subscribe(hubs.models.User.by_username('lmacken'), 'owner')

This is a big PR that reorganizes the way the hub configuration is stored in the database.
Feel free to read the commits in order, they should be roughly topical.
There is no support for migrating the configuration, so you'll have to remove your DB and run populate.py again.
When this PR is accepted, I'll split the models.py file in multiple sub-modules, because it's getting complicated and I think it would be clearer with a table per file, but I did not want to generate noise for this review.

10 new commits added

  • Fix tests
  • Show config validation errors in the dialog
  • Update widgets to use the new config
  • Add the pagure and github configs
  • Validate some of the config keys
  • Update the config dialog to work with the new schema
  • Add new config keys
  • Redesign the hub config DB schema
  • Only configure the SQLAlchemy metadata if not done before
  • Use Flask signals
6 years ago

rebased onto 6ad0176c2392124581d4157edfc4ad6af06d7d86

6 years ago

I forgot to mention: this PR depends on the "signals" PR, so the first commit actually comes from that branch.

rebased onto 1992284

6 years ago

Pull-Request has been merged by abompard

6 years ago
Metadata
Changes Summary 73
+1 -1
file changed
docs/api/auth.rst
+5 -0
file changed
docs/api/utils.rst
+0 -5
file changed
docs/api/widgets.rst
+2 -1
file changed
hubs/database.py
+2 -2
file changed
hubs/feed.py
+83
file added
hubs/migrations/versions/20b23e867aeb_refactored_hubs_config_table.py
-686
file removed
hubs/models.py
+30
file added
hubs/models/__init__.py
+62
file added
hubs/models/association.py
+40
file added
hubs/models/constants.py
+290
file added
hubs/models/hub.py
+247
file added
hubs/models/hubconfig.py
+75
file added
hubs/models/savednotification.py
+139
file added
hubs/models/user.py
+173
file added
hubs/models/widget.py
+1 -1
file changed
hubs/static/client/app/components/CobWeb/index.js
+3 -0
file changed
hubs/static/client/app/components/HubConfig/HubConfig.css
+54 -1
file changed
hubs/static/client/app/components/HubConfig/HubConfigDialog.js
+83
file added
hubs/static/client/app/components/HubConfig/HubConfigPanelCalendar.js
+22 -3
file changed
hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js
+242
file added
hubs/static/client/app/components/HubConfig/HubConfigPanelDevPlatform.js
+55 -12
file changed
hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js
+85
file added
hubs/static/client/app/components/HubConfig/HubConfigPanelMailingList.js
+43 -13
file changed
hubs/static/client/app/components/HubConfig/index.js
+2 -1
file changed
hubs/static/client/app/components/HubHeader.js
+1 -2
file changed
hubs/static/client/app/components/WidgetChrome.js
+2 -1
file changed
hubs/static/client/app/components/WidgetsArea.js
+31 -3
file changed
hubs/static/client/app/core/actions/hub.js
+40 -0
file changed
hubs/static/client/app/core/reducers/hub.js
+5 -1
file changed
hubs/static/client/app/core/reducers/index.js
+1 -4
file changed
hubs/static/client/app/core/utils.js
+1 -2
file changed
hubs/tests/__init__.py
+5 -65
file changed
hubs/tests/test_models.py
+1 -1
file renamed
hubs/tests/test_widget_validators.py
hubs/tests/utils/test_validators.py
+84
file added
hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_github_organization
+98
file added
hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_github_repo
+385
file added
hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_pagure_repo
+9 -13
file changed
hubs/tests/views/test_api_hub_config.py
+3 -3
file changed
hubs/tests/views/test_api_hub_widget.py
+1 -1
file changed
hubs/tests/views/test_hub_view.py
+0 -48
file changed
hubs/tests/views/test_user.py
+2 -2
file changed
hubs/tests/widgets/__init__.py
+6 -6
file changed
hubs/tests/widgets/test_halp.py
+2 -0
file changed
hubs/tests/widgets/test_meetings.py
+1 -2
file changed
hubs/tests/widgets/test_my_hubs.py
+2 -2
file changed
hubs/utils/__init__.py
+3 -3
file changed
hubs/utils/github.py
+41 -10
file renamed
hubs/widgets/validators.py
hubs/utils/validators.py
+12 -9
file changed
hubs/utils/views.py
+27 -30
file changed
hubs/views/api/hub_config.py
+3 -2
file changed
hubs/views/hub.py
+0 -26
file changed
hubs/views/user.py
+1 -1
file changed
hubs/widgets/about/__init__.py
+1 -1
file changed
hubs/widgets/badges/__init__.py
+1 -1
file changed
hubs/widgets/bugzilla/__init__.py
+1 -1
file changed
hubs/widgets/dummy/__init__.py
+1 -1
file changed
hubs/widgets/feed/__init__.py
+5 -2
file changed
hubs/widgets/github_pr/__init__.py
+5 -1
file changed
hubs/widgets/githubissues/__init__.py
+1 -1
file changed
hubs/widgets/halp/__init__.py
+3 -2
file changed
hubs/widgets/halp/utils.py
+1 -1
file changed
hubs/widgets/irc/__init__.py
+1 -1
file changed
hubs/widgets/library/__init__.py
+1 -1
file changed
hubs/widgets/linechart/__init__.py
+11 -10
file changed
hubs/widgets/meetings/__init__.py
+54 -48
file changed
hubs/widgets/meetings/templates/root.html
+4 -1
file changed
hubs/widgets/pagure_pr/__init__.py
+4 -1
file changed
hubs/widgets/pagureissues/__init__.py
+1 -1
file changed
hubs/widgets/parameters.py
+14 -10
file changed
hubs/widgets/rules/__init__.py
+2 -1
file changed
hubs/widgets/sticky/__init__.py
+1 -1
file changed
hubs/widgets/workflow/updates2stable.py
+10 -13
file changed
populate.py