From 19922844b12448c312e4db4211add3c6f70cd521 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 13:58:21 +0000 Subject: [PATCH 1/11] Only configure the SQLAlchemy metadata if not done before --- diff --git a/hubs/database.py b/hubs/database.py index cad0400..91458d2 100644 --- a/hubs/database.py +++ b/hubs/database.py @@ -75,4 +75,5 @@ def init(db_url, debug=False, create=False): # 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']) From 118fcb9fed7a6d0320d387edab8262a0a862ed04 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 13:58:21 +0000 Subject: [PATCH 2/11] Redesign the hub config DB schema Fixes #487 --- diff --git a/docs/api/auth.rst b/docs/api/auth.rst index c5e8382..2d1b25b 100644 --- a/docs/api/auth.rst +++ b/docs/api/auth.rst @@ -12,7 +12,7 @@ authorization system is in the :py:mod:`hubs.authz` module. 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 diff --git a/hubs/feed.py b/hubs/feed.py index 4c92454..939f503 100644 --- a/hubs/feed.py +++ b/hubs/feed.py @@ -10,8 +10,8 @@ import flask 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 @@ class Feed(object): "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"), diff --git a/hubs/migrations/versions/20b23e867aeb_refactored_hubs_config_table.py b/hubs/migrations/versions/20b23e867aeb_refactored_hubs_config_table.py new file mode 100644 index 0000000..c3fc606 --- /dev/null +++ b/hubs/migrations/versions/20b23e867aeb_refactored_hubs_config_table.py @@ -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 . +""" +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') + ) diff --git a/hubs/models.py b/hubs/models.py index 2b30812..6c44dc1 100644 --- a/hubs/models.py +++ b/hubs/models.py @@ -26,12 +26,12 @@ import datetime import json import logging import operator -import os -import random from collections import defaultdict +from collections.abc import MutableMapping import bleach import flask +import six import sqlalchemy as sa from sqlalchemy.orm import relation from sqlalchemy.orm import backref @@ -47,14 +47,8 @@ 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'] +ROLES = ('subscriber', 'member', 'owner', 'stargazer') +VISIBILITIES = ("public", "preview", "private") class Association(BASE): @@ -89,17 +83,26 @@ class Hub(ObjectAuthzMixin, BASE): 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) + 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 @@ -189,9 +192,8 @@ class Hub(ObjectAuthzMixin, BASE): 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) + hub.config["summary"] = fullname + hub.config["avatar"] = username2avatar(username) session.flush() hub_created.send(hub) return hub @@ -201,10 +203,10 @@ class Hub(ObjectAuthzMixin, BASE): 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) + 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"] session.flush() hub_created.send(hub, **extra) return hub @@ -222,7 +224,7 @@ class Hub(ObjectAuthzMixin, BASE): if not widget_instance.enabled: continue widget = widget_instance.module - new_config = self.config.__json__() + new_config = self.config.to_dict() will_reload = False cached_functions = widget.get_cached_functions() for fn_name, fn_class in cached_functions.items(): @@ -256,7 +258,7 @@ class Hub(ObjectAuthzMixin, BASE): return self.name # When the CAIAPI is in place we will be able to use the auth_group # setting: - # group = self.config.auth_group + # group = self.config["auth_group"] # if group is None: # group = self.name # return group @@ -276,14 +278,14 @@ class Hub(ObjectAuthzMixin, BASE): def _get_auth_permission_name(self, action): if action == "view": - action = "{}.view".format(self.config.visibility) + 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__(), + "config": self.config.to_dict(), "users": {role: [] for role in ROLES}, "mtime": self.last_refreshed, "user_hub": self.user_hub, @@ -307,7 +309,7 @@ class Hub(ObjectAuthzMixin, BASE): return { 'name': self.name, 'archived': self.archived, - 'config': self.config.__json__(), + 'config': self.config.to_dict(), 'widgets': [widget.idx for widget in self.widgets], @@ -317,45 +319,162 @@ class Hub(ObjectAuthzMixin, BASE): } -class HubConfig(BASE): +class Converter(object): - __tablename__ = 'hubs_config' + def __init__(self, func=None): + if func is None: + self.func = lambda v: v + else: + self.func = func - VISIBILITY = ["public", "preview", "private"] + def from_db(self, value): + return self.func(value) - 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 to_db(self, value): + return six.text_type(value) - 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 +class BooleanConverter(Converter): + + def __init__(self): + self.func = bool - @right_width.setter - def right_width(self, value): - self.left_width = 12 - value + 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", + ) + 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 = [] + + 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): + if conf.key in self.LISTS: + if conf.key not in result: + result[conf.key] = [] + result[conf.key].append(conf.value) + else: + result[conf.key] = 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 + + # 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) class SpecificDefaultDict(defaultdict): @@ -454,7 +573,7 @@ class Widget(ObjectAuthzMixin, BASE): def _get_auth_permission_name(self, action): if action != "view": return self.hub._get_auth_permission_name(action) - hub_visibility = self.hub.config.visibility + hub_visibility = self.hub.config["visibility"] if hub_visibility != "preview": return "hub.{}.view".format(hub_visibility) return "widget.{}.view".format(self.visibility) diff --git a/hubs/static/client/app/components/CobWeb/index.js b/hubs/static/client/app/components/CobWeb/index.js index 582b494..4333f20 100644 --- a/hubs/static/client/app/components/CobWeb/index.js +++ b/hubs/static/client/app/components/CobWeb/index.js @@ -11,7 +11,7 @@ export default class CobWeb extends React.Component { 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) { diff --git a/hubs/static/client/app/components/HubHeader.js b/hubs/static/client/app/components/HubHeader.js index fe1e2ee..e4a6fd7 100644 --- a/hubs/static/client/app/components/HubHeader.js +++ b/hubs/static/client/app/components/HubHeader.js @@ -13,6 +13,7 @@ import "./HubHeader.css"; class HubHeader extends React.Component { render() { + const right_width = 12 - (this.props.hub.config.left_width || 8); return (
{ this.props.isLoading && @@ -45,7 +46,7 @@ class HubHeader extends React.Component {
-
+
{ this.props.hub.perms.config &&
diff --git a/hubs/static/client/app/components/WidgetsArea.js b/hubs/static/client/app/components/WidgetsArea.js index 5dcbc18..ec712b7 100644 --- a/hubs/static/client/app/components/WidgetsArea.js +++ b/hubs/static/client/app/components/WidgetsArea.js @@ -21,6 +21,7 @@ class WidgetsArea extends React.PureComponent { if (!this.props.hub.name) { return null; } + const right_width = 12 - (this.props.hub.config.left_width || 8); return (
{ this.props.widgets.isLoading && @@ -48,7 +49,7 @@ class WidgetsArea extends React.PureComponent { />
-
+
' % (hub.config.avatar) + if hub.config["avatar"] != "": + return '' % (hub.config["avatar"]) return ("
" "%s
") % (hubname2monogramcolour(hub.name), hubname2monogramcolour(hub.name), diff --git a/hubs/utils/views.py b/hubs/utils/views.py index ea6038e..2e305e7 100644 --- a/hubs/utils/views.py +++ b/hubs/utils/views.py @@ -10,8 +10,7 @@ import logging 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 @@ from hubs.models import Hub, HubConfig, Widget 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 get_hub(name, load_config=False): 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 @@ def require_hub_access(action, url_param="name", json=False): @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 diff --git a/hubs/views/api/hub_config.py b/hubs/views/api/hub_config.py index 4da5d95..08444c8 100644 --- a/hubs/views/api/hub_config.py +++ b/hubs/views/api/hub_config.py @@ -25,7 +25,7 @@ def api_hub_config(name): 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({ @@ -66,8 +66,8 @@ def hub_config_put_config(hub, config): return value validator = RequestValidator(dict( - # Only allow the parameters listed in __json__(). - (key, None) for key in hub.config.__json__().keys() + # Only allow the parameters listed in the hub config. + (key, None) for key in hub.config.keys() )) validator.converters["chat_domain"] = _validate_chat_domain try: @@ -79,7 +79,7 @@ def hub_config_put_config(hub, config): # Now set the configuration values. for key, value in values.items(): - setattr(hub.config, key, value) + hub.config[key] = value def hub_config_put_users(hub, user_roles): diff --git a/hubs/views/hub.py b/hubs/views/hub.py index cc4025a..b683c70 100644 --- a/hubs/views/hub.py +++ b/hubs/views/hub.py @@ -13,10 +13,10 @@ from hubs.utils.views import ( @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.VISIBILITIES, "roles": ["owner", "member"], } urls = { diff --git a/hubs/widgets/halp/utils.py b/hubs/widgets/halp/utils.py index 23ffec8..47c514d 100644 --- a/hubs/widgets/halp/utils.py +++ b/hubs/widgets/halp/utils.py @@ -17,9 +17,10 @@ def find_hubs_for_msg(msg): 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): diff --git a/hubs/widgets/rules/__init__.py b/hubs/widgets/rules/__init__.py index 12e830e..e69ce0f 100644 --- a/hubs/widgets/rules/__init__.py +++ b/hubs/widgets/rules/__init__.py @@ -65,9 +65,10 @@ class BaseView(RootWidgetView): 'https://lists.fedoraproject.org/archives/list/{}@' 'lists.fedoraproject.org/').format(instance.hub.name) 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 + hub_config = instance.hub.config + 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, diff --git a/populate.py b/populate.py index 88c4337..d48c4e3 100755 --- a/populate.py +++ b/populate.py @@ -39,10 +39,13 @@ for username in users: 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 @@ db.commit() # ############# 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 @@ db.commit() # ############# 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 @@ db.commit() # ############# 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 @@ db.commit() # ############# 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({ From 8b436dce67aad8e0040916f6ae455febd8325858 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 13:58:21 +0000 Subject: [PATCH 3/11] Add new config keys --- diff --git a/hubs/views/api/hub_config.py b/hubs/views/api/hub_config.py index 08444c8..1b7b273 100644 --- a/hubs/views/api/hub_config.py +++ b/hubs/views/api/hub_config.py @@ -77,7 +77,11 @@ def hub_config_put_config(hub, config): result["fields"] = e.args[0] raise ConfigChangeError(result) - # Now set the configuration values. + # Clear the config values that aren't in the request. + for key in hub.config.keys(): + if key not in values.keys(): + del hub.config[key] + # Now set the new configuration values. for key, value in values.items(): hub.config[key] = value diff --git a/hubs/views/hub.py b/hubs/views/hub.py index b683c70..8968dd1 100644 --- a/hubs/views/hub.py +++ b/hubs/views/hub.py @@ -18,6 +18,7 @@ def hub(name): "chat_networks": app.config["CHAT_NETWORKS"], "hub_visibility": hubs.models.VISIBILITIES, "roles": ["owner", "member"], + "dev_platforms": hubs.models.DEV_PLATFORMS, } urls = { "widgets": flask.url_for("api_hub_widgets", hub=hub.name), From 804e52a244e96d1b8607d2987dbe80523e95895a Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 13:58:21 +0000 Subject: [PATCH 4/11] Update the config dialog to work with the new schema --- diff --git a/hubs/static/client/app/components/HubConfig/HubConfig.css b/hubs/static/client/app/components/HubConfig/HubConfig.css index 07acdd8..571bc3a 100644 --- a/hubs/static/client/app/components/HubConfig/HubConfig.css +++ b/hubs/static/client/app/components/HubConfig/HubConfig.css @@ -44,3 +44,6 @@ } } +.HubConfigDialog .modal-body table.table td { + vertical-align: middle; +} diff --git a/hubs/static/client/app/components/HubConfig/HubConfigDialog.js b/hubs/static/client/app/components/HubConfig/HubConfigDialog.js index 3f42ed6..dfa564a 100644 --- a/hubs/static/client/app/components/HubConfig/HubConfigDialog.js +++ b/hubs/static/client/app/components/HubConfig/HubConfigDialog.js @@ -6,7 +6,10 @@ import { 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'; @@ -29,10 +32,22 @@ const messages = defineMessages({ 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", @@ -118,6 +133,14 @@ export default class HubConfigDialog extends React.Component { /> } {!this.props.hub.user_hub && + } + handleChange={this.props.onConfigChange} + /> + } + {!this.props.hub.user_hub && } + {!this.props.hub.user_hub && + } + handleChange={this.props.onConfigChange} + /> + } + {!this.props.hub.user_hub && + } + handleChange={this.props.onConfigListChange} + /> + } {/*} />*/} diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelCalendar.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelCalendar.js new file mode 100644 index 0000000..1a76d7f --- /dev/null +++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelCalendar.js @@ -0,0 +1,77 @@ +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() { + var address = "", + stillLoading = ( + typeof this.props.hubConfig.calendar === "undefined" + ); + + return ( +
{e.preventDefault();}}> + + + +
+ + +

+ + team +

+
+ + ); + } +} diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js index 516f846..234ac0e 100644 --- a/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js +++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js @@ -38,6 +38,16 @@ const messages = defineMessages({ 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 = "#", @@ -74,7 +84,7 @@ export default class ChatPanel extends React.Component { type="text" className="form-control" name="chat_channel" id="hub-settings-chat-channel" disabled={stillLoading} - onChange={this.props.handleChange} + onChange={this.handleChange} value={channel} />

@@ -90,7 +100,7 @@ export default class ChatPanel extends React.Component { 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} diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelDevPlatform.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelDevPlatform.js new file mode 100644 index 0000000..6bb97b1 --- /dev/null +++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelDevPlatform.js @@ -0,0 +1,232 @@ +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}); + } + + 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: + }); + 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( + + ); + 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 ( +

{e.preventDefault();}}> + + + { currentPlatforms.length !== 0 && +
+ + + + + + + + + + + { currentPlatforms.map((platform) => ( + + + + + + )) + } + +
+ + + + +
+ { platformsByName[platform.name]["display_name"] } + + + { platform.project } + + + +
+
+ } + +
+ +
+ +
+
+
+ +
+ +

+ + team/repo +

+
+
+
+
+ + { this.state.error && + + {this.state.error} + + } +
+
+ + ); + } +} diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js index cce20a5..2a938d0 100644 --- a/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js +++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js @@ -59,9 +59,20 @@ const messages = defineMessages({ 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; return (
{e.preventDefault();}}> @@ -74,7 +85,7 @@ export default class GeneralPanel extends React.Component { type="text" className="form-control" name="summary" id="hub-settings-general-summary" disabled={stillLoading} - onChange={this.props.handleChange} + onChange={this.handleChange} value={this.props.hubConfig.summary || ""} />

@@ -90,13 +101,13 @@ export default class GeneralPanel extends React.Component { id="hub-settings-general-leftwidth" disabled={stillLoading} min="1" max="11" - onChange={this.props.handleChange} + onChange={this.handleChange} value={this.props.hubConfig.left_width || ""} />

@@ -108,7 +119,7 @@ export default class GeneralPanel extends React.Component { +

+ + team@lists.fedoraproject.org +

+
+ + ); + } +} diff --git a/hubs/static/client/app/components/HubConfig/index.js b/hubs/static/client/app/components/HubConfig/index.js index 4a4062b..737e273 100644 --- a/hubs/static/client/app/components/HubConfig/index.js +++ b/hubs/static/client/app/components/HubConfig/index.js @@ -28,6 +28,8 @@ class HubConfig extends React.Component { 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); @@ -51,8 +53,39 @@ class HubConfig extends React.Component { handleConfigChange(e) { const name = e.target.name, value = e.target.value; this.setState((prevState, props) => { - prevState.config[name] = value; - return prevState; + let newConfig = prevState.config; + newConfig[name] = value; + return {config: newConfig}; + }); + } + + doConfigChange(key, value) { + this.setState((prevState, props) => { + let newConfig = prevState.config; + newConfig[key] = value; + return {config: newConfig}; + }); + } + + doConfigListChange(action, key, value) { + if (!key) { return; } + this.setState((prevState, props) => { + 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}; }); } @@ -97,7 +130,8 @@ class HubConfig extends React.Component { 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} diff --git a/hubs/static/client/app/components/HubHeader.js b/hubs/static/client/app/components/HubHeader.js index e4a6fd7..c2edd14 100644 --- a/hubs/static/client/app/components/HubHeader.js +++ b/hubs/static/client/app/components/HubHeader.js @@ -13,7 +13,7 @@ import "./HubHeader.css"; class HubHeader extends React.Component { render() { - const right_width = 12 - (this.props.hub.config.left_width || 8); + const right_width = this.props.hub.config ? 12 - this.props.hub.config.left_width : 4; return (
{ this.props.isLoading && diff --git a/hubs/static/client/app/components/WidgetsArea.js b/hubs/static/client/app/components/WidgetsArea.js index ec712b7..0bc6df4 100644 --- a/hubs/static/client/app/components/WidgetsArea.js +++ b/hubs/static/client/app/components/WidgetsArea.js @@ -21,7 +21,7 @@ class WidgetsArea extends React.PureComponent { if (!this.props.hub.name) { return null; } - const right_width = 12 - (this.props.hub.config.left_width || 8); + const right_width = this.props.hub.config ? 12 - this.props.hub.config.left_width : 4; return (
{ this.props.widgets.isLoading && From 414256ae40062d1c7fb8e4037e9fb3047f897a25 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 13:58:21 +0000 Subject: [PATCH 5/11] Validate some of the config keys --- diff --git a/docs/api/utils.rst b/docs/api/utils.rst index 5338291..8d46ef2 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -20,3 +20,8 @@ Datagrepper utils ----------------- .. automodule:: hubs.utils.datagrepper + +Validators +---------- + +.. automodule:: hubs.utils.validators diff --git a/docs/api/widgets.rst b/docs/api/widgets.rst index f1102bc..00002fb 100644 --- a/docs/api/widgets.rst +++ b/docs/api/widgets.rst @@ -22,11 +22,6 @@ Widget parameters .. automodule:: hubs.widgets.parameters -Widget parameter validators ---------------------------- - -.. automodule:: hubs.widgets.validators - Widget view ----------- diff --git a/hubs/models.py b/hubs/models.py index 6c44dc1..1287096 100644 --- a/hubs/models.py +++ b/hubs/models.py @@ -41,7 +41,7 @@ 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.utils import username2avatar, validators from hubs.signals import hub_created, user_created log = logging.getLogger(__name__) @@ -379,6 +379,11 @@ class HubConfigProxy(MutableMapping): "avatar": "", } LISTS = [] + VALIDATORS = { + "github": validators.GithubRepo, + "pagure": validators.PagureRepo, + "chat_domain": validators.ChatDomain, + } def __init__(self, hub): self.hub = hub @@ -448,6 +453,21 @@ class HubConfigProxy(MutableMapping): result[key] = self.DEFAULTS.get(key) return result + def validate(self, config): + # Raise ValueError if the new config does not validate. + validated = {} + 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 + if key in self.LISTS: + validated[key] = [self.VALIDATORS[key](v) for v in value] + else: + validated[key] = self.VALIDATORS[key](value) + return validated + # Methods below are not necessary but are optimizations def items(self): diff --git a/hubs/tests/test_widget_validators.py b/hubs/tests/test_widget_validators.py deleted file mode 100644 index 77a2a54..0000000 --- a/hubs/tests/test_widget_validators.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import unicode_literals - -import unittest - -from hubs.widgets import validators - -from hubs.tests import APPTest - - -class ValidatorsTest(APPTest): - - def test_required(self): - self.assertRaises(ValueError, validators.Required, "") - - def test_text(self): - self.assertEqual(validators.Text("\xe9"), "\xe9") - - def test_integer(self): - self.assertEqual(validators.Integer("1"), 1) - with self.assertRaises(ValueError) as cm: - validators.Integer("text") - self.assertEqual(str(cm.exception), "text is not an integer") - - @unittest.skip("Not implemented yet") - def test_link(self): - value = 'dummy' - self.assertEqual(validators.Link.from_string(value), value) - self.assertRaises(ValueError, validators.Link, "text") - - def test_username(self): - self.assertEqual(validators.Username("ralph"), "ralph") - self.assertRaises( - ValueError, validators.Username, "nobody") - - def test_github_organization(self): - self.assertEqual( - validators.GithubOrganization("fedora-infra"), - "fedora-infra") - self.assertRaises( - ValueError, - validators.GithubOrganization, - "something-that-does-not-exist") - - def test_github_repo(self): - self.assertEqual( - validators.GithubRepo('/'.join(["fedora-infra", - "fedmsg"])), - "fedmsg") - self.assertRaises(ValueError, validators.GithubRepo, - '/'.join(["fedora-infra", - "something-that-does-not-exist"])) - - def test_fmncontext(self): - self.assertEqual(validators.FMNContext("email"), "email") - self.assertRaises( - ValueError, validators.FMNContext, "dummy") - - def test_pagure_repo(self): - self.assertEqual( - validators.PagureRepo("fedora-hubs"), "fedora-hubs") - self.assertRaises(ValueError, validators.PagureRepo, - "something-that-does-not-exist") diff --git a/hubs/tests/utils/test_validators.py b/hubs/tests/utils/test_validators.py new file mode 100644 index 0000000..391e9c4 --- /dev/null +++ b/hubs/tests/utils/test_validators.py @@ -0,0 +1,62 @@ +from __future__ import unicode_literals + +import unittest + +from hubs.utils import validators + +from hubs.tests import APPTest + + +class ValidatorsTest(APPTest): + + def test_required(self): + self.assertRaises(ValueError, validators.Required, "") + + def test_text(self): + self.assertEqual(validators.Text("\xe9"), "\xe9") + + def test_integer(self): + self.assertEqual(validators.Integer("1"), 1) + with self.assertRaises(ValueError) as cm: + validators.Integer("text") + self.assertEqual(str(cm.exception), "text is not an integer") + + @unittest.skip("Not implemented yet") + def test_link(self): + value = 'dummy' + self.assertEqual(validators.Link.from_string(value), value) + self.assertRaises(ValueError, validators.Link, "text") + + def test_username(self): + self.assertEqual(validators.Username("ralph"), "ralph") + self.assertRaises( + ValueError, validators.Username, "nobody") + + def test_github_organization(self): + self.assertEqual( + validators.GithubOrganization("fedora-infra"), + "fedora-infra") + self.assertRaises( + ValueError, + validators.GithubOrganization, + "something-that-does-not-exist") + + def test_github_repo(self): + self.assertEqual( + validators.GithubRepo('/'.join(["fedora-infra", + "fedmsg"])), + "fedmsg") + self.assertRaises(ValueError, validators.GithubRepo, + '/'.join(["fedora-infra", + "something-that-does-not-exist"])) + + def test_fmncontext(self): + self.assertEqual(validators.FMNContext("email"), "email") + self.assertRaises( + ValueError, validators.FMNContext, "dummy") + + def test_pagure_repo(self): + self.assertEqual( + validators.PagureRepo("fedora-hubs"), "fedora-hubs") + self.assertRaises(ValueError, validators.PagureRepo, + "something-that-does-not-exist") diff --git a/hubs/utils/github.py b/hubs/utils/github.py index 5da725a..57da0d9 100644 --- a/hubs/utils/github.py +++ b/hubs/utils/github.py @@ -13,7 +13,7 @@ def github_org_is_valid(username): 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 @@ def github_repo_is_valid(username, repo): 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_pulls(token, username, repo): 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): diff --git a/hubs/utils/validators.py b/hubs/utils/validators.py new file mode 100644 index 0000000..f0767bb --- /dev/null +++ b/hubs/utils/validators.py @@ -0,0 +1,132 @@ +""" +Validate and convert the value of widget or hub configuration. + +Validators are used to validate and convert +: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 + +import flask +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.""" + return value + + +def Required(value): + """Raises an error if the value is ``False``-like.""" + if not bool(value): + raise ValueError("the parameter is required") + # if callable(value): + # # Act as a decorator + # return lambda v: Required(value(v)) + return value + + +def Text(value): + """Raises an error if the value can't be converted to unicode.""" + return kitchen.text.converters.to_unicode(value) + + +def Integer(value): + """Raises an error if the value can't be converted to an integer.""" + try: + return int(value) + except ValueError: + raise ValueError("{} is not an integer".format(value)) + + +def Link(value): + """Raises an error if the value doesn't look like a link.""" + # TODO -- verify that this is actually a link + return value + + +def Username(value): + """Raises an error if the value isn't an existing username. + + There must be a corresponding :py:class:`~hubs.models.User` record. + + 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 User.by_username(value) is not None: + return value + raise ValueError('Invalid username') + + +def GithubOrganization(value): + """Fails if the Github organization name does not exist.""" + if not github_org_is_valid(value): + raise ValueError('Github organization does not exist') + return value + + +def GithubRepo(value): + """Fails if the Github repository name does not 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 repository: {}'.format(value)) + return repo + + +def FMNContext(value): + """Fails if the value is not a valid FMN context name.""" + # TODO get this from the fedmsg config. + if value in ['irc', 'email', 'android', 'desktop', 'hubs']: + return value + raise ValueError('Invalid FMN context') + + +def PagureRepo(value): + """Fails if the Pagure repository name does not exist.""" + response = requests.get("https://pagure.io/%s" % value, timeout=5) + if response.ok: + return value + raise ValueError('Invalid Pagure repo: {}'.format(value)) + + +def CommaSeparatedList(value): + """Fails if the value isn't a list. + + If the value is a string, list, it will be interpreted as a comma-separated + list and converted to a Python list. If there is no comma in the original + value, it will produce a list with a single element. Whitespaces will be + stripped from the elements, so spaces are allowed around the commas. + """ + if not value: + return [] + if isinstance(value, six.string_types): + return [ + elem.strip() for elem in value.split(",") if elem.strip() + ] + 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 diff --git a/hubs/views/api/hub_config.py b/hubs/views/api/hub_config.py index 1b7b273..c5aafbd 100644 --- a/hubs/views/api/hub_config.py +++ b/hubs/views/api/hub_config.py @@ -55,35 +55,18 @@ def api_hub_config(name): def hub_config_put_config(hub, config): # 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 the hub config. - (key, None) for key in hub.config.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] raise ConfigChangeError(result) - + # 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 key not in values.keys(): del hub.config[key] - # Now set the new configuration values. - for key, value in values.items(): - hub.config[key] = value def hub_config_put_users(hub, user_roles): diff --git a/hubs/widgets/about/__init__.py b/hubs/widgets/about/__init__.py index 0da5c7b..4610541 100644 --- a/hubs/widgets/about/__init__.py +++ b/hubs/widgets/about/__init__.py @@ -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 diff --git a/hubs/widgets/badges/__init__.py b/hubs/widgets/badges/__init__.py index 505d2b3..c7fb323 100644 --- a/hubs/widgets/badges/__init__.py +++ b/hubs/widgets/badges/__init__.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals 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 diff --git a/hubs/widgets/bugzilla/__init__.py b/hubs/widgets/bugzilla/__init__.py index a0643cc..be52a83 100644 --- a/hubs/widgets/bugzilla/__init__.py +++ b/hubs/widgets/bugzilla/__init__.py @@ -4,8 +4,8 @@ import logging 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 diff --git a/hubs/widgets/dummy/__init__.py b/hubs/widgets/dummy/__init__.py index c5e4f1d..fa0443f 100644 --- a/hubs/widgets/dummy/__init__.py +++ b/hubs/widgets/dummy/__init__.py @@ -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 diff --git a/hubs/widgets/feed/__init__.py b/hubs/widgets/feed/__init__.py index d6c4c61..59ce76a 100644 --- a/hubs/widgets/feed/__init__.py +++ b/hubs/widgets/feed/__init__.py @@ -6,7 +6,7 @@ import logging 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 diff --git a/hubs/widgets/github_pr/__init__.py b/hubs/widgets/github_pr/__init__.py index a8b350a..4674062 100644 --- a/hubs/widgets/github_pr/__init__.py +++ b/hubs/widgets/github_pr/__init__.py @@ -2,9 +2,8 @@ from __future__ import unicode_literals 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 diff --git a/hubs/widgets/githubissues/__init__.py b/hubs/widgets/githubissues/__init__.py index c8c2956..ba0abcd 100644 --- a/hubs/widgets/githubissues/__init__.py +++ b/hubs/widgets/githubissues/__init__.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals 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 diff --git a/hubs/widgets/halp/__init__.py b/hubs/widgets/halp/__init__.py index 39732e0..c556e3d 100644 --- a/hubs/widgets/halp/__init__.py +++ b/hubs/widgets/halp/__init__.py @@ -4,8 +4,8 @@ from __future__ import unicode_literals 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 diff --git a/hubs/widgets/irc/__init__.py b/hubs/widgets/irc/__init__.py index b884264..cf12b02 100644 --- a/hubs/widgets/irc/__init__.py +++ b/hubs/widgets/irc/__init__.py @@ -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 diff --git a/hubs/widgets/library/__init__.py b/hubs/widgets/library/__init__.py index 817de1c..849497d 100644 --- a/hubs/widgets/library/__init__.py +++ b/hubs/widgets/library/__init__.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import flask -from hubs.widgets import validators +from hubs.utils import validators from hubs.widgets.base import Widget diff --git a/hubs/widgets/linechart/__init__.py b/hubs/widgets/linechart/__init__.py index 9fdccb2..549a0b5 100644 --- a/hubs/widgets/linechart/__init__.py +++ b/hubs/widgets/linechart/__init__.py @@ -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 diff --git a/hubs/widgets/meetings/__init__.py b/hubs/widgets/meetings/__init__.py index 9d8fc2e..4f488d5 100644 --- a/hubs/widgets/meetings/__init__.py +++ b/hubs/widgets/meetings/__init__.py @@ -5,8 +5,8 @@ import collections 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 diff --git a/hubs/widgets/pagure_pr/__init__.py b/hubs/widgets/pagure_pr/__init__.py index 6eee830..463dce8 100644 --- a/hubs/widgets/pagure_pr/__init__.py +++ b/hubs/widgets/pagure_pr/__init__.py @@ -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 diff --git a/hubs/widgets/pagureissues/__init__.py b/hubs/widgets/pagureissues/__init__.py index 4cc033f..c0636df 100644 --- a/hubs/widgets/pagureissues/__init__.py +++ b/hubs/widgets/pagureissues/__init__.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals 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 diff --git a/hubs/widgets/parameters.py b/hubs/widgets/parameters.py index 0b04bff..3433ddc 100644 --- a/hubs/widgets/parameters.py +++ b/hubs/widgets/parameters.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals, absolute_import -from .validators import Noop +from hubs.utils.validators import Noop class WidgetParameter(object): diff --git a/hubs/widgets/rules/__init__.py b/hubs/widgets/rules/__init__.py index e69ce0f..9f5827e 100644 --- a/hubs/widgets/rules/__init__.py +++ b/hubs/widgets/rules/__init__.py @@ -2,8 +2,7 @@ from __future__ import unicode_literals 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 diff --git a/hubs/widgets/sticky/__init__.py b/hubs/widgets/sticky/__init__.py index 304dd6e..db8c541 100644 --- a/hubs/widgets/sticky/__init__.py +++ b/hubs/widgets/sticky/__init__.py @@ -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 diff --git a/hubs/widgets/validators.py b/hubs/widgets/validators.py deleted file mode 100644 index ef19ad0..0000000 --- a/hubs/widgets/validators.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -Validate and convert the value of widget parameters. - -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. - -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 - - -def Noop(value): - """Does no validation, just return the value.""" - return value - - -def Required(value): - """Raises an error if the value is ``False``-like.""" - if not bool(value): - raise ValueError("the parameter is required") - # if callable(value): - # # Act as a decorator - # return lambda v: Required(value(v)) - return value - - -def Text(value): - """Raises an error if the value can't be converted to unicode.""" - return kitchen.text.converters.to_unicode(value) - - -def Integer(value): - """Raises an error if the value can't be converted to an integer.""" - try: - return int(value) - except ValueError: - raise ValueError("{} is not an integer".format(value)) - - -def Link(value): - """Raises an error if the value doesn't look like a link.""" - # TODO -- verify that this is actually a link - return value - - -def Username(value): - """Raises an error if the value isn't an existing username. - - There must be a corresponding :py:class:`~hubs.models.User` record. - - This validator does not return the User instance because it is not - JSON-serializable, it returns the username unchanged. - """ - 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: - return value - raise ValueError('Invalid username') - - -def GithubOrganization(value): - """Fails if the Github organization name does not exist.""" - if not github_org_is_valid(value): - raise ValueError('Github organization does not exist') - return value - - -def GithubRepo(value): - """Fails if the Github repository name does not exist.""" - username, repo = value.split('/') - if not github_repo_is_valid(username, repo): - raise ValueError('Github repository does not exist') - return repo - - -def FMNContext(value): - """Fails if the value is not a valid FMN context name.""" - # TODO get this from the fedmsg config. - if value in ['irc', 'email', 'android', 'desktop', 'hubs']: - return value - raise ValueError('Invalid FMN context') - - -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: - return value - raise ValueError('Invalid pagure repo') - - -def CommaSeparatedList(value): - """Fails if the value isn't a list. - - If the value is a string, list, it will be interpreted as a comma-separated - list and converted to a Python list. If there is no comma in the original - value, it will produce a list with a single element. Whitespaces will be - stripped from the elements, so spaces are allowed around the commas. - """ - if not value: - return [] - if isinstance(value, six.string_types): - return [ - elem.strip() for elem in value.split(",") if elem.strip() - ] - if not isinstance(value, list): - raise ValueError("Expected a list") - return value diff --git a/hubs/widgets/workflow/updates2stable.py b/hubs/widgets/workflow/updates2stable.py index 9845cc4..8ac3ae0 100644 --- a/hubs/widgets/workflow/updates2stable.py +++ b/hubs/widgets/workflow/updates2stable.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals 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 From 043a6bf03438e8987abf272a8dfa418d26bd000c Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 13:58:21 +0000 Subject: [PATCH 6/11] Add the pagure and github configs --- diff --git a/hubs/models.py b/hubs/models.py index 1287096..c171807 100644 --- a/hubs/models.py +++ b/hubs/models.py @@ -49,6 +49,10 @@ log = logging.getLogger(__name__) 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"}, +) class Association(BASE): @@ -207,6 +211,8 @@ class Hub(ObjectAuthzMixin, BASE): 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 @@ -363,8 +369,8 @@ class HubConfigProxy(MutableMapping): KEYS = ( "archived", "summary", "left_width", "avatar", "visibility", - "chat_domain", "chat_channel", - ) + "chat_domain", "chat_channel", "mailing_list", "calendar", + ) + tuple(p["name"] for p in DEV_PLATFORMS) CONVERTERS = { "archived": BooleanConverter(), "left_width": Converter(int), @@ -378,7 +384,7 @@ class HubConfigProxy(MutableMapping): "left_width": 8, "avatar": "", } - LISTS = [] + LISTS = ("pagure", "github") VALIDATORS = { "github": validators.GithubRepo, "pagure": validators.PagureRepo, From 16a1a866aec74bf3fdce15a33ad3b89f48a5ac21 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 13:58:21 +0000 Subject: [PATCH 7/11] Update widgets to use the new config --- diff --git a/hubs/widgets/github_pr/__init__.py b/hubs/widgets/github_pr/__init__.py index 4674062..1f13a5f 100644 --- a/hubs/widgets/github_pr/__init__.py +++ b/hubs/widgets/github_pr/__init__.py @@ -13,6 +13,10 @@ log = logging.getLogger(__name__) 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" diff --git a/hubs/widgets/githubissues/__init__.py b/hubs/widgets/githubissues/__init__.py index ba0abcd..6140a62 100644 --- a/hubs/widgets/githubissues/__init__.py +++ b/hubs/widgets/githubissues/__init__.py @@ -8,6 +8,10 @@ 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" diff --git a/hubs/widgets/meetings/__init__.py b/hubs/widgets/meetings/__init__.py index 4f488d5..12b9041 100644 --- a/hubs/widgets/meetings/__init__.py +++ b/hubs/widgets/meetings/__init__.py @@ -18,12 +18,6 @@ class Meetings(Widget): 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 @@ class BaseView(RootWidgetView): if meeting['start_dt'] > now } return dict( - calendar=instance.config["calendar"], + calendar=instance.hub.config.get("calendar"), meetings=meetings, ) @@ -57,9 +51,12 @@ class BaseView(RootWidgetView): 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 @@ class GetMeetings(CachedFunction): 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): diff --git a/hubs/widgets/meetings/templates/root.html b/hubs/widgets/meetings/templates/root.html index 7072d8c..2596d32 100644 --- a/hubs/widgets/meetings/templates/root.html +++ b/hubs/widgets/meetings/templates/root.html @@ -1,53 +1,59 @@ -{% for title, next in meetings.items() %} -
-
-
-
The {{ next.meeting_name }} is {{ next.start_dt | humanize }}:
-
-
- {% if next.display_duration %} - {{ next.start_date }} - {{ next.stop_date }} - {% else %} - {{next.start_date}} - {% endif %} -
-
- {% if next.display_time %} - @{{ next.start_time }}{% endif %}
+{% if not calendar %} +

+ You must configure a calendar in the hub configuration in order to use this widget. +

+{% else %} + {% for title, next in meetings.items() %} +
+
+
+
The {{ next.meeting_name }} is {{ next.start_dt | humanize }}:
+
+
+ {% if next.display_duration %} + {{ next.start_date }} - {{ next.stop_date }} + {% else %} + {{next.start_date}} + {% endif %} +
+
+ {% if next.display_time %} + @{{ next.start_time }}{% endif %}
- {{ next.location }} + {{ next.location }} +
+
-
- - - + {% if next.get('meeting_information_html') %} +
+
+ {{ next.meeting_information_html[:150] }} + {%- if next.meeting_information_html | length > 150 %}... +

+ Full description +

+ {% endif %} +
+ {% endif %}
- {% if next.get('meeting_information_html') %} -
-
- {{ next.meeting_information_html[:150] }} - {%- if next.meeting_information_html | length > 150 %}... -

- Full description -

- {% endif %} -
-
- {% endif %} -
- -{% else %} -
-
- No coming meetings in {{ calendar }}. + + {% else %} +
+
+ No coming meetings in {{ calendar }}. +
-
-

- -

-{% endfor %} +

+ +

+ {% endfor %} +{% endif %} diff --git a/hubs/widgets/pagure_pr/__init__.py b/hubs/widgets/pagure_pr/__init__.py index 463dce8..1515d35 100644 --- a/hubs/widgets/pagure_pr/__init__.py +++ b/hubs/widgets/pagure_pr/__init__.py @@ -9,6 +9,9 @@ import requests 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): diff --git a/hubs/widgets/pagureissues/__init__.py b/hubs/widgets/pagureissues/__init__.py index c0636df..ba6f8c7 100644 --- a/hubs/widgets/pagureissues/__init__.py +++ b/hubs/widgets/pagureissues/__init__.py @@ -9,6 +9,9 @@ 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): diff --git a/hubs/widgets/rules/__init__.py b/hubs/widgets/rules/__init__.py index 9f5827e..35cb523 100644 --- a/hubs/widgets/rules/__init__.py +++ b/hubs/widgets/rules/__init__.py @@ -48,7 +48,9 @@ class Rules(Widget): 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 = [{ @@ -59,12 +61,14 @@ class BaseView(RootWidgetView): 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 - hub_config = instance.hub.config if hub_config["chat_channel"]: irc_channel = hub_config["chat_channel"] irc_network = hub_config["chat_domain"] From dda72382dfba170cdf8104de8fc90e1626347213 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 13:58:22 +0000 Subject: [PATCH 8/11] Show config validation errors in the dialog --- diff --git a/hubs/models.py b/hubs/models.py index c171807..54ae32e 100644 --- a/hubs/models.py +++ b/hubs/models.py @@ -386,9 +386,10 @@ class HubConfigProxy(MutableMapping): } LISTS = ("pagure", "github") VALIDATORS = { - "github": validators.GithubRepo, + "github": validators.GithubOrgAndRepo, "pagure": validators.PagureRepo, "chat_domain": validators.ChatDomain, + "mailing_list": validators.Email, } def __init__(self, hub): @@ -444,12 +445,13 @@ class HubConfigProxy(MutableMapping): 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(conf.value) + result[conf.key].append(converter.from_db(conf.value)) else: - result[conf.key] = conf.value + result[conf.key] = converter.from_db(conf.value) # Add defaults for key in self.KEYS: if key not in result: @@ -461,17 +463,40 @@ class HubConfigProxy(MutableMapping): 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(): + print(repr(key), repr(value)) if key not in self.KEYS: raise ValueError("Invalid config key: {}".format(key)) if key not in self.VALIDATORS: validated[key] = value continue - if key in self.LISTS: - validated[key] = [self.VALIDATORS[key](v) for v in value] - else: - validated[key] = self.VALIDATORS[key](value) + 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 diff --git a/hubs/static/client/app/components/HubConfig/HubConfigDialog.js b/hubs/static/client/app/components/HubConfig/HubConfigDialog.js index dfa564a..fb9e0a8 100644 --- a/hubs/static/client/app/components/HubConfig/HubConfigDialog.js +++ b/hubs/static/client/app/components/HubConfig/HubConfigDialog.js @@ -13,6 +13,7 @@ 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({ @@ -71,7 +72,15 @@ export default class HubConfigDialog extends React.Component { ); const footer = ( -
+
+ { this.props.isLoading && + + } + { this.props.hub.error && +
+ {this.props.hub.error.message} +
+ } @@ -138,6 +148,7 @@ export default class HubConfigDialog extends React.Component { globalConfig={this.props.globalConfig} tabTitle={} handleChange={this.props.onConfigChange} + error={this.props.hub.error} /> } {!this.props.hub.user_hub && @@ -146,6 +157,7 @@ export default class HubConfigDialog extends React.Component { globalConfig={this.props.globalConfig} tabTitle={} handleChange={this.props.onConfigChange} + error={this.props.hub.error} /> } {!this.props.hub.user_hub && @@ -154,6 +166,7 @@ export default class HubConfigDialog extends React.Component { globalConfig={this.props.globalConfig} tabTitle={} handleChange={this.props.onConfigChange} + error={this.props.hub.error} /> } {!this.props.hub.user_hub && @@ -162,6 +175,7 @@ export default class HubConfigDialog extends React.Component { globalConfig={this.props.globalConfig} tabTitle={} handleChange={this.props.onConfigListChange} + error={this.props.hub.error} /> } {/* {e.preventDefault();}}> @@ -60,12 +59,19 @@ export default class CalendarPanel extends React.Component { + { invalid && +
+ {invalid} +
+ }

team diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js index 234ac0e..9134b5a 100644 --- a/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js +++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js @@ -71,6 +71,8 @@ export default class ChatPanel extends React.Component { ); } + const invalid = this.props.error ? this.props.error.fields.chat_channel : null; + return (

{e.preventDefault();}}> @@ -81,12 +83,19 @@ export default class ChatPanel extends React.Component { + { invalid && +
+ {invalid} +
+ }

#fedora-devel diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelDevPlatform.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelDevPlatform.js index 6bb97b1..e7f623f 100644 --- a/hubs/static/client/app/components/HubConfig/HubConfigPanelDevPlatform.js +++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelDevPlatform.js @@ -66,7 +66,10 @@ export default class DevPlatformPanel extends React.Component { handleChange(e) { const name = e.target.name, value = e.target.value; - this.setState({[name]: value}); + this.setState({ + [name]: value, + error: null, // Remove the "already connected" message. + }); } addPlatform() { @@ -129,7 +132,7 @@ export default class DevPlatformPanel extends React.Component { { currentPlatforms.length !== 0 &&

- +
@@ -167,9 +170,16 @@ export default class DevPlatformPanel extends React.Component { }
+ { this.props.error && this.props.error.fields.devplatform_project && +
+ {this.props.error.fields.devplatform_project} +
+ }
} - +

+ +

+ { invalid.summary && +
+ {invalid.summary} +
+ }

@@ -97,13 +110,20 @@ export default class GeneralPanel extends React.Component { + { invalid.left_width && +
+ {invalid.left_width} +
+ }

+ { invalid.avatar && +

+ {invalid.avatar} +
+ }

@@ -153,5 +186,4 @@ export default class GeneralPanel extends React.Component { ); } - } diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelMailingList.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelMailingList.js index aab92f5..df6d416 100644 --- a/hubs/static/client/app/components/HubConfig/HubConfigPanelMailingList.js +++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelMailingList.js @@ -45,10 +45,10 @@ export default class MailingListPanel extends React.Component { } render() { - var address = "", - stillLoading = ( + const stillLoading = ( typeof this.props.hubConfig.mailing_list === "undefined" ); + const invalid = this.props.error ? this.props.error.fields.mailing_list : null; return (
{e.preventDefault();}}> @@ -60,13 +60,20 @@ export default class MailingListPanel extends React.Component { + { invalid && +
+ {invalid} +
+ }

team@lists.fedoraproject.org diff --git a/hubs/static/client/app/components/HubConfig/index.js b/hubs/static/client/app/components/HubConfig/index.js index 737e273..c16dd76 100644 --- a/hubs/static/client/app/components/HubConfig/index.js +++ b/hubs/static/client/app/components/HubConfig/index.js @@ -5,7 +5,11 @@ import { 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,7 @@ class HubConfig extends React.Component { 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); @@ -39,24 +41,16 @@ class HubConfig extends React.Component { 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}); - } - - handleConfigChange(e) { - const name = e.target.name, value = e.target.value; - this.setState((prevState, props) => { - let newConfig = prevState.config; - newConfig[name] = value; - return {config: newConfig}; - }); + this.props.dispatch(closeConfigDialog()) } doConfigChange(key, value) { @@ -107,7 +101,6 @@ class HubConfig extends React.Component { handleSaveClicked(e) { e.preventDefault(); this.props.dispatch(saveHub(this.state.config, this.state.users)); - this.setState({isDialogOpen: false}); } render() { @@ -123,7 +116,7 @@ class HubConfig extends React.Component { return (

{openButton} - {this.state.isDialogOpen && + {this.props.isDialogOpen && }
@@ -151,6 +145,8 @@ const mapStateToProps = (state) => { globalConfig: state.globalConfig, urls: state.urls, currentUser: state.currentUser, + isDialogOpen: state.ui.hubConfigDialogOpen, + isLoading: state.ui.hubConfigDialogLoading, } }; diff --git a/hubs/static/client/app/components/WidgetChrome.js b/hubs/static/client/app/components/WidgetChrome.js index 1061fb1..3c94f64 100644 --- a/hubs/static/client/app/components/WidgetChrome.js +++ b/hubs/static/client/app/components/WidgetChrome.js @@ -24,7 +24,7 @@ class WidgetChrome extends React.PureComponent { 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 @@ const mapDispatchToProps = dispatch => { return { onEdit: (widgetId) => { dispatch(openConfigDialog(widgetId)); }, onDelete: (widgetId) => { dispatch(deleteWidget(widgetId)); } - } } diff --git a/hubs/static/client/app/core/actions/hub.js b/hubs/static/client/app/core/actions/hub.js index 7c31285..b220e3f 100644 --- a/hubs/static/client/app/core/actions/hub.js +++ b/hubs/static/client/app/core/actions/hub.js @@ -47,6 +47,7 @@ export function fetchHub() { /* 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 putHub(config, users) { } } -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 @@ export function saveHub(config, users) { 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 @@ export function dissociateUser(role) { ); } } + + + +/* 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, + } +} diff --git a/hubs/static/client/app/core/reducers/hub.js b/hubs/static/client/app/core/reducers/hub.js index 9798ba1..623cd94 100644 --- a/hubs/static/client/app/core/reducers/hub.js +++ b/hubs/static/client/app/core/reducers/hub.js @@ -4,9 +4,12 @@ import { 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 @@ export function hubReducer( state={ name: null, isLoading: false, + error: null, old: {}, }, action @@ -46,6 +50,7 @@ export function hubReducer( config: action.config, users: action.users, old: {config: state.config, users: state.users}, + error: null, }; case HUB_PUT_FAILURE: return { @@ -54,6 +59,10 @@ export function hubReducer( // 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 @@ export function hubReducer( 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 + } +} diff --git a/hubs/static/client/app/core/reducers/index.js b/hubs/static/client/app/core/reducers/index.js index acb6644..981550a 100644 --- a/hubs/static/client/app/core/reducers/index.js +++ b/hubs/static/client/app/core/reducers/index.js @@ -3,7 +3,9 @@ import flashMessages from "./flashMessages"; import sseReducer from "./sse"; import { hubReducer, - hubEditMode + hubEditMode, + hubConfigDialogOpen, + hubConfigDialogLoading, } from "./hub"; import { widgetsReducer, @@ -31,6 +33,8 @@ const ui = combineReducers({ flashMessages, widgetsEditMode, widgetConfigDialogOpen, + hubConfigDialogOpen, + hubConfigDialogLoading, }); diff --git a/hubs/static/client/app/core/utils.js b/hubs/static/client/app/core/utils.js index 9a44d56..77148c8 100644 --- a/hubs/static/client/app/core/utils.js +++ b/hubs/static/client/app/core/utils.js @@ -43,12 +43,9 @@ export function apiCall(url, fetchConfig, extractData=true) { return result; } } else { - throw new Error(result.message); + throw result; } }, - error => { - throw new Error(error.message); - } ); } diff --git a/hubs/utils/validators.py b/hubs/utils/validators.py index f0767bb..49d0426 100644 --- a/hubs/utils/validators.py +++ b/hubs/utils/validators.py @@ -69,6 +69,17 @@ def Username(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): @@ -130,3 +141,9 @@ def ChatDomain(value): 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 diff --git a/hubs/views/api/hub_config.py b/hubs/views/api/hub_config.py index c5aafbd..70cda82 100644 --- a/hubs/views/api/hub_config.py +++ b/hubs/views/api/hub_config.py @@ -8,8 +8,7 @@ import hubs.models 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__) @@ -41,11 +40,12 @@ def api_hub_config(name): 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,6 +54,11 @@ def api_hub_config(name): def hub_config_put_config(hub, config): + config = { + key: value + for key, value in config.items() + if value is not None + } # Validate values try: values = hub.config.validate(config) @@ -65,7 +70,7 @@ def hub_config_put_config(hub, config): hub.config.update(values) # Clear the config values that aren't in the request. for key in hub.config.keys(): - if key not in values.keys(): + if config.get(key) is None: del hub.config[key] From cf6ecbc5564816b40ad380b8fac067c89e9abc54 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 13:58:22 +0000 Subject: [PATCH 9/11] Fix tests --- diff --git a/hubs/models.py b/hubs/models.py index 54ae32e..cede5ac 100644 --- a/hubs/models.py +++ b/hubs/models.py @@ -27,7 +27,11 @@ import json import logging import operator from collections import defaultdict -from collections.abc import MutableMapping +try: + from collections.abc import MutableMapping +except ImportError: + # Python 2 + from collections import MutableMapping import bleach import flask @@ -467,7 +471,6 @@ class HubConfigProxy(MutableMapping): validated = {} errors = {} for key, value in config.items(): - print(repr(key), repr(value)) if key not in self.KEYS: raise ValueError("Invalid config key: {}".format(key)) if key not in self.VALIDATORS: diff --git a/hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_github_organization b/hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_github_organization new file mode 100644 index 0000000..a40c7be --- /dev/null +++ b/hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_github_organization @@ -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 diff --git a/hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_github_repo b/hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_github_repo new file mode 100644 index 0000000..31df57f --- /dev/null +++ b/hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_github_repo @@ -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 diff --git a/hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_pagure_repo b/hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_pagure_repo new file mode 100644 index 0000000..eba1377 --- /dev/null +++ b/hubs/tests/vcr-request-data/hubs.tests.utils.test_validators.ValidatorsTest.test_pagure_repo @@ -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: "\n\n\n \n Overview\ + \ - fedora-hubs - Pagure\n \n \n \n \n \n \n \n \n \n \n
\n
\n
\n\ + \ \n \ + \
\n
\n\n \n
\n \n
\n \n\n\ + \
\n
\n
\n
\n
\n\n
\n\n\n
\n
\n
\n \n fedora-hubs\n\ + \ \n
\nFedora Hubs\ + \  |  http://pagure.io/fedora-hubs
\n\n
\n \n
  • \n \ + \ \n \n Overview\n \n
  • \n \ + \
  • \n \n \n Docs\n\ + \ \n
  • \n\n
  • \n \ + \ \n \ + \ \n \ + \ Commits\n \ + \ \n
  • \n\n
  • \n \n \n Files\n \n
  • \n\n\ + \
  • \n \n \n Releases\n\ + \ \n
  • \n\n
  • \n \ + \ \n \ + \ \n \ + \ Issues \n \ + \ \n \ + \ 132\n \n \n \ + \
  • \n\n
  • \n \n \n Pull Requests \n \n 10\n \n\ + \ \n
  • \n\n
  • \n \ + \ \n \n Stats \n \n
  • \n\ + \n\n \n
    \n
    \n\n
    \n \n
    \n
    \n
    \n

    Fedora Hubs

    \n\ +

    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.

    \n

    Hubs is currently under development.\ + \ (We had a development instance at\nhttps://hubs-dev.fedorainfracloud.org/)

    \n
    \n\ +

    Get Involved

    \n

    Visit our mailing list\nand join us in the #fedora-hubs\ + \ IRC channel on irc.freenode.net.

    \n

    For a more detailed overview of\ + \ what Fedora Hubs is, see the\ndocumentation.

    \n

    To set up a development environment and start\ + \ contributing, check out\nthe development guide.

    \n
    \n
    \n

    Meetings

    \n\ +

    Meetings are held weekly in #fedora-hubs\ + \ at 14:00UTC and the minutes for\nevery meeting are archived.\nIn the meetings we review our statuses from the preceding\ + \ week and do ticket triage, too.

    \n
    \n

    Steps\ + \ to run a meeting

    \n
      \n
    • #startmeeting hubs-devel
    • \n
    • #topic\ + \ Roll Call (Wait for 2 minutes for the Roll Call)
    • \n
    • #chair [nick\ + \ 1, nick 2,...., nick N](All the people present for the meeting)
    • \n
    • #topic\ + \ Action items from last meeting (Find the last meeting log from https://meetbot-raw.fedoraproject.org/teams/hubs-devel)
    • \n\ +
    • #topic Status Updates (Cycle through all the nicks)
    • \n
    • #topic Ticket\ + \ <subject link_to_the_ticket>
    • \n
    • #topic Open Floor
    • \n
    • #endmeeting
    • \n\ +
    \n

    Send the minutes of the meeting to hubs-devel@lists.fedoraproject.org

    \n
    \n
    \n
    \n\n \ + \
    \n
    \n
    \n \ + \
    \n
    \n \ + \
    Contributors
    \n
    \n \n \n \ + \ \n \ + \ \n \ + \ \n \ + \ \n \ + \ \n \ + \ \n \ + \ \n \ + \ \n \ + \ \n \ + \ \n \ + \
    \n \n \ + \ \n bkorren (bkorren)\n \n \ + \ - admin\n
    \n
    \n\ + \ \n \n Eric Barbour (atelic)\n \n\ + \ - admin\n
    \n \ + \ \n \ + \ \n \ + \ \n \ + \ \n
    \n\ + \
    Branches
    \n
    \n
    \n
    \n \ + \ \n develop\n\ + \
    \n
    \n \n
    \n\ + \
    \n\n
    \n \n\ + \
    \n
    \n\ + \
    \n
    \n
    \n \n\ + \ fix_fedmsgstats\n\ + \ \n\n
    \n
    \n
    \n
    \n \ + \
    \n
    \n \ + \ \n \ + \ jenkins\n \ + \ \n\n
    \n
    \n
    \n
    \n \ + \
    \n \ + \
    \n \n \ + \ master\n \n\ + \n
    \n
    \n
    \n
    \n
    \n \ + \
    \n \n unittest\n \n\n \ + \
    \n
    \n \ + \
    \n
    \n
    \n
    Source\ + \ GIT URLs more
    \n\ + \
    \n
    \n \ + \
    \n
    GIT
    \n \n \ + \
    \n
    \n
    \n
    Docs GIT URLs
    \n\ + \
    \n
    \n
    GIT
    \n \n \ + \
    \n
    \n
    \n \ + \
    \n
    \n
    \n created 2 years ago\n \ + \
    \n
    \n\n \n \ + \
    \n
    \n
    \n
    \n\n\n
    \n\ + \
    \n\n
    \n
    \n

    \n Copyright\ + \ © 2014-2017 Red Hat\n pagure —\n 3.11.2 — Documentation\n

    \n

    SSH Hostkey/Fingerprint

    \n
    \n
    \n\n \n \n \n \n \n\n\n\n\ + \n\n\n\n"} + 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: "\n\n\n \n Page not\ + \ found :'( - Pagure\n \n \n \n \n \n \n \n \n \n
    \n \n
    \n
    \n \n \"pagure\n \n
    \n
    \n
    \n\n \n\ + \n
    \n
    \n
    \n
    \n \ + \
    \n\n
    \n\n\ + \n
    \n
    \n
    \n

    Page not found (404)

    \n

    With the message:

    \n\ + \
    \n

    Project not found

    \n
    \n\ + \

    You have either entered a bad URL or the page has moved, removed,\ + \ or otherwise rendered unavailable.
    \n Please use the main navigation\ + \ menu to get (re)started.

    \n
    \n
    \n
    \n
    \n\ + \n
    \n
    \n

    \n Copyright ©\ + \ 2014-2017 Red Hat\n pagure\ + \ —\n 3.11.2 — Documentation\n

    \n

    SSH Hostkey/Fingerprint

    \n
    \n
    \n\n \n \n \n \n \n\n\n\n\ + "} + 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 diff --git a/hubs/tests/views/test_api_hub_config.py b/hubs/tests/views/test_api_hub_config.py index 386c448..b7d1746 100644 --- a/hubs/tests/views/test_api_hub_config.py +++ b/hubs/tests/views/test_api_hub_config.py @@ -36,6 +36,10 @@ class TestAPIHubConfig(APPTest): "avatar": avatar_url, 'chat_channel': None, 'chat_domain': None, + "calendar": None, + "mailing_list": None, + "github": [], + "pagure": [], "left_width": 8, "summary": "Ralph", "visibility": "public", @@ -87,7 +91,6 @@ class TestAPIHubConfig(APPTest): data=json.dumps({ "config": { "summary": "changed value", - "chat_domain": "", } }) ) @@ -96,9 +99,6 @@ class TestAPIHubConfig(APPTest): 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"]) def test_put_unknown_data(self): # Unknown PUT data is silently ignored @@ -111,12 +111,8 @@ class TestAPIHubConfig(APPTest): 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') diff --git a/hubs/tests/widgets/test_meetings.py b/hubs/tests/widgets/test_meetings.py index 6536856..b73c221 100644 --- a/hubs/tests/widgets/test_meetings.py +++ b/hubs/tests/widgets/test_meetings.py @@ -10,6 +10,7 @@ class TestMeetings(WidgetTest): 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 @@ class TestMeetings(WidgetTest): 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) diff --git a/hubs/views/api/hub_config.py b/hubs/views/api/hub_config.py index 70cda82..7e20352 100644 --- a/hubs/views/api/hub_config.py +++ b/hubs/views/api/hub_config.py @@ -63,8 +63,13 @@ def hub_config_put_config(hub, config): try: 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) # Set the new configuration values. hub.config.update(values) From e7e3a7a3efa34389ef55c9fd26f9e9f7d4508f02 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 14:38:56 +0000 Subject: [PATCH 10/11] Split the models module --- diff --git a/hubs/models.py b/hubs/models.py deleted file mode 100644 index cede5ac..0000000 --- a/hubs/models.py +++ /dev/null @@ -1,859 +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 -from collections import defaultdict -try: - from collections.abc import MutableMapping -except ImportError: - # Python 2 - from collections import MutableMapping - -import bleach -import flask -import six -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, validators -from hubs.signals import hub_created, user_created - -log = logging.getLogger(__name__) - - -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"}, -) - - -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") - 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], - } - - -class Converter(object): - - 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) - - -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 "" % (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() diff --git a/hubs/models/__init__.py b/hubs/models/__init__.py new file mode 100644 index 0000000..9d299c9 --- /dev/null +++ b/hubs/models/__init__.py @@ -0,0 +1,31 @@ +# -*- 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 +from .visitcounter import VisitCounter # noqa: F401 diff --git a/hubs/models/association.py b/hubs/models/association.py new file mode 100644 index 0000000..58803a4 --- /dev/null +++ b/hubs/models/association.py @@ -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() diff --git a/hubs/models/constants.py b/hubs/models/constants.py new file mode 100644 index 0000000..ace2cb4 --- /dev/null +++ b/hubs/models/constants.py @@ -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", + }, +) diff --git a/hubs/models/hub.py b/hubs/models/hub.py new file mode 100644 index 0000000..ea0d49b --- /dev/null +++ b/hubs/models/hub.py @@ -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], + } diff --git a/hubs/models/hubconfig.py b/hubs/models/hubconfig.py new file mode 100644 index 0000000..10d1df6 --- /dev/null +++ b/hubs/models/hubconfig.py @@ -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) diff --git a/hubs/models/savednotification.py b/hubs/models/savednotification.py new file mode 100644 index 0000000..d5412b6 --- /dev/null +++ b/hubs/models/savednotification.py @@ -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() diff --git a/hubs/models/user.py b/hubs/models/user.py new file mode 100644 index 0000000..2025833 --- /dev/null +++ b/hubs/models/user.py @@ -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) diff --git a/hubs/models/visitcounter.py b/hubs/models/visitcounter.py new file mode 100644 index 0000000..20dedfe --- /dev/null +++ b/hubs/models/visitcounter.py @@ -0,0 +1,87 @@ +# -*- 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, Session +from .hub import Hub +from .user import User + + +log = logging.getLogger(__name__) + + +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 diff --git a/hubs/models/widget.py b/hubs/models/widget.py new file mode 100644 index 0000000..b11e43e --- /dev/null +++ b/hubs/models/widget.py @@ -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 "" % (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 diff --git a/hubs/views/hub.py b/hubs/views/hub.py index 8968dd1..ad746db 100644 --- a/hubs/views/hub.py +++ b/hubs/views/hub.py @@ -16,9 +16,9 @@ def hub(name): hub = get_hub(name) global_config = { "chat_networks": app.config["CHAT_NETWORKS"], - "hub_visibility": hubs.models.VISIBILITIES, + "hub_visibility": hubs.models.constants.VISIBILITIES, "roles": ["owner", "member"], - "dev_platforms": hubs.models.DEV_PLATFORMS, + "dev_platforms": hubs.models.constants.DEV_PLATFORMS, } urls = { "widgets": flask.url_for("api_hub_widgets", hub=hub.name), From ca4c0e3c44678788664cad567d19b0efd5c24ad8 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Dec 18 2017 15:01:29 +0000 Subject: [PATCH 11/11] Remove VisitCounter (unused) --- diff --git a/hubs/models/__init__.py b/hubs/models/__init__.py index 9d299c9..58f9ed3 100644 --- a/hubs/models/__init__.py +++ b/hubs/models/__init__.py @@ -28,4 +28,3 @@ from .hubconfig import HubConfig # noqa: F401 from .widget import Widget # noqa: F401 from .user import User # noqa: F401 from .savednotification import SavedNotification # noqa: F401 -from .visitcounter import VisitCounter # noqa: F401 diff --git a/hubs/models/visitcounter.py b/hubs/models/visitcounter.py deleted file mode 100644 index 20dedfe..0000000 --- a/hubs/models/visitcounter.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- 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, Session -from .hub import Hub -from .user import User - - -log = logging.getLogger(__name__) - - -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 diff --git a/hubs/tests/test_models.py b/hubs/tests/test_models.py index c2ecda9..51d066e 100644 --- a/hubs/tests/test_models.py +++ b/hubs/tests/test_models.py @@ -90,66 +90,6 @@ class ModelTest(hubs.tests.APPTest): 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) diff --git a/hubs/tests/views/test_user.py b/hubs/tests/views/test_user.py index eb1ed6a..46a2828 100644 --- a/hubs/tests/views/test_user.py +++ b/hubs/tests/views/test_user.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals import json -import unittest from mock import Mock, patch @@ -143,50 +142,3 @@ class TestDeleteNotifications(hubs.tests.APPTest): '/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') diff --git a/hubs/views/user.py b/hubs/views/user.py index f0e8ac4..bfd2b32 100644 --- a/hubs/views/user.py +++ b/hubs/views/user.py @@ -95,29 +95,3 @@ def delete_notifs(idx): flask.g.db.delete(notification) flask.g.db.commit() return flask.jsonify(dict(status="OK")) - - -@app.route('/visit//', methods=['GET', 'POST']) -@app.route('/visit/', 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) diff --git a/populate.py b/populate.py index d48c4e3..e1eae97 100755 --- a/populate.py +++ b/populate.py @@ -294,8 +294,6 @@ hub.widgets.append(widget) 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')