From 7b4735e1e05dc4ef5af83f1800ce8eb921a005e8 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Jan 16 2018 16:33:37 +0000 Subject: Use FAS to create, manage and sync memberships Fixes #389, #474 --- diff --git a/create-group-from-fas.py b/create-group-from-fas.py new file mode 100755 index 0000000..34c515e --- /dev/null +++ b/create-group-from-fas.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python + +from __future__ import unicode_literals, print_function + +from argparse import ArgumentParser + +from fedora.client import AppError + +import hubs.app +import hubs.database +import hubs.models +from hubs.utils import get_fedmsg_config +from hubs.utils.fas import FASClient + + +def create_hub(name): + pass + + +def parse_args(): + parser = ArgumentParser() + parser.add_argument( + "name", help="group to create the hub for") + return parser.parse_args() + + +def main(): + args = parse_args() + hub_name = args.name + fedmsg_config = get_fedmsg_config() + hubs.database.init(fedmsg_config['hubs.sqlalchemy.uri']) + hub = hubs.models.Hub.by_name(hub_name, "team") + if hub is not None: + print("This hub already exists.") + return + fas_client = FASClient() + try: + fas_client.group_by_name(hub_name) + except AppError: + print("Could not find this group in FAS.") + return + # Setup app context to have access to the task queue. + with hubs.app.app.app_context(): + db = hubs.database.Session() + hubs.app.create_task_queue() + hubs.models.Hub.create_group_hub(hub_name, "") + db.commit() + print("Hub {} created!".format(hub_name)) + print("It will be synced from FAS in the background.") + + +if __name__ == "__main__": + main() diff --git a/hubs/backend/triage.py b/hubs/backend/triage.py index 56d9442..2226acf 100755 --- a/hubs/backend/triage.py +++ b/hubs/backend/triage.py @@ -58,6 +58,30 @@ def triage(msg): })) return # Notifications don't appear anywhere else, stop here. + # Handle FAS changes. + if '.fas.group.member.' in topic: + hub = hubs.models.Hub.by_name(msg["msg"]["group"], "team") + if hub is not None: + yield retask.task.Task(json.dumps({ + 'type': 'sync-team-hub-roles', + 'hub': hub.id, + })) + if '.fas.group.update' in topic: + hub = hubs.models.Hub.by_name(msg["msg"]["group"], "team") + if hub is not None: + yield retask.task.Task(json.dumps({ + 'type': 'sync-team-hub', + 'hub': hub.id, + })) + if '.fas.role.update' in topic: + hub = hubs.models.Hub.by_name(msg["msg"]["group"], "team") + if hub is not None: + yield retask.task.Task(json.dumps({ + 'type': 'sync-user-roles', + 'hub': hub.id, + 'username': msg["msg"]["user"], + })) + # Store the list of concerned hubs to check later in the # should_invalidate() method of Feed widgets. msg["_hubs"] = hubs.feed.get_hubs_for_msg(msg) diff --git a/hubs/backend/worker.py b/hubs/backend/worker.py index 5252a66..6ecc778 100755 --- a/hubs/backend/worker.py +++ b/hubs/backend/worker.py @@ -40,6 +40,7 @@ import hubs.database import hubs.feed import hubs.models import hubs.widgets.base +from hubs.utils import fas log = logging.getLogger('hubs.backend.worker') @@ -135,6 +136,45 @@ def main(args=None): username, "user/{}".format(username), ) + elif item_type == "sync-team-hub": + affected_users = fas.sync_team_hub( + item["hub"], item.get("created", False)) + add_sse_task( + sse_queue, + "hubs:hub-updated", + None, + "hub/{}".format(item["hub"]), + ) + for username in affected_users: + add_sse_task( + sse_queue, + "hubs:user-updated", + {"usernames": affected_users}, + "hub/{}".format(item["hub"]), + ) + elif item_type == "sync-team-hub-roles": + affected_users = fas.sync_team_hub_roles(item["hub"]) + if affected_users: + add_sse_task( + sse_queue, + "hubs:user-updated", + {"usernames": affected_users}, + "hub/{}".format(item["hub"]), + ) + # TODO: Add a message in the UI saying that the membership has + # been accepted? + elif item_type == "sync-user-roles": + affected_hubs = fas.sync_user_roles( + item["username"], item["hub"]) + for hub_id in affected_hubs: + add_sse_task( + sse_queue, + "hubs:user-updated", + {"usernames": [item["username"]]}, + "hub/{}".format(hub_id), + ) + # TODO: Add a message in the UI saying that the membership has + # been accepted? log.debug(" Done.") except KeyboardInterrupt: pass diff --git a/hubs/database.py b/hubs/database.py index 91458d2..03d75de 100644 --- a/hubs/database.py +++ b/hubs/database.py @@ -30,6 +30,7 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import scoped_session +from sqlalchemy.schema import MetaData from hubs.utils import get_fedmsg_config @@ -37,7 +38,17 @@ from hubs.utils import get_fedmsg_config log = logging.getLogger(__name__) -BASE = declarative_base() +naming_convention = { + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" +} + +BASE = declarative_base( + metadata=MetaData(naming_convention=naming_convention) +) # http://docs.sqlalchemy.org/en/latest/orm/contextual.html#unitofwork-contextual Session = scoped_session(sessionmaker()) diff --git a/hubs/default_config.py b/hubs/default_config.py index b19c65a..2262593 100644 --- a/hubs/default_config.py +++ b/hubs/default_config.py @@ -50,6 +50,11 @@ CHAT_NETWORKS = [ DATAGREPPER_URI = 'https://apps.fedoraproject.org/datagrepper' +MANAGE_MEMBERSHIP_IN_FAS = True + +EMAIL_HOST = "localhost" +EMAIL_PORT = 25 + WIDGETS = [ 'hubs.widgets.about:About', 'hubs.widgets.badges:Badges', diff --git a/hubs/defaults.py b/hubs/defaults.py index 9042f78..a6bf8b8 100644 --- a/hubs/defaults.py +++ b/hubs/defaults.py @@ -76,31 +76,18 @@ def add_user_widgets(hub): return hub -def add_group_widgets(hub, - # These are all things that come from FAS - apply_rules=None, - join_message=None, - ): +def add_group_widgets(hub): """ Some defaults for an automatically created group hub. """ - if apply_rules: - widget = hubs.models.Widget( - plugin='sticky', index=0, left=True, - _config=json.dumps({ - "text": apply_rules, - })) - hub.widgets.append(widget) - - if join_message: - widget = hubs.models.Widget( - plugin='about', index=0, - _config=json.dumps({ - "text": join_message, - })) - hub.widgets.append(widget) + widget = hubs.models.Widget( + plugin='feed', index=0, left=True, + _config=json.dumps({ + 'message_limit': 20 + })) + hub.widgets.append(widget) widget = hubs.models.Widget( - plugin='rules', index=1, + plugin='rules', index=0, _config=json.dumps({ # TODO -- can we guess their urls? 'link': None, @@ -111,18 +98,15 @@ def add_group_widgets(hub, ) hub.widgets.append(widget) - # IRC hub.widgets.append( hubs.models.Widget( - plugin='irc', index=2, + plugin='irc', index=1, _config=json.dumps({ 'height': 450, }) ) ) - # TODO - set up mailing list with the right mailing list. - # TODO - set up feed with aggregate of users stuff unless we have some # other preset. diff --git a/hubs/migrations/versions/5c624b266713_pending_roles.py b/hubs/migrations/versions/5c624b266713_pending_roles.py new file mode 100644 index 0000000..7c19198 --- /dev/null +++ b/hubs/migrations/versions/5c624b266713_pending_roles.py @@ -0,0 +1,50 @@ +# This Alembic database migration is part of the Fedora Hubs project. +# Copyright (C) 2018 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 . +""" +Pending roles + +Revision ID: 5c624b266713 +Revises: 20b23e867aeb +Create Date: 2018-01-09 10:50:45.262652 +""" + +from __future__ import absolute_import, unicode_literals + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '5c624b266713' +down_revision = 'a245837dd23c' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("association") as batch_op: + batch_op.create_check_constraint( + "role", + "role IN ('member', 'pending-member', 'owner', 'pending-owner', " + "'stargazer', 'subscriber')", + ) + + +def downgrade(): + with op.batch_alter_table("association") as batch_op: + batch_op.create_check_constraint( + "role", + "role IN ('member', 'owner', 'stargazer', 'subscriber')", + ) diff --git a/hubs/models/association.py b/hubs/models/association.py index 98e73de..f7e92f6 100644 --- a/hubs/models/association.py +++ b/hubs/models/association.py @@ -51,11 +51,3 @@ class Association(BASE): '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 index d939672..52a5dc5 100644 --- a/hubs/models/constants.py +++ b/hubs/models/constants.py @@ -25,7 +25,12 @@ from __future__ import unicode_literals HUB_TYPES = ("user", "team", "stream") -ROLES = ('subscriber', 'member', 'owner', 'stargazer') +ROLES = ( + 'stargazer', + 'subscriber', + 'pending-member', 'member', + 'pending-owner', 'owner', +) VISIBILITIES = ("public", "preview", "private") diff --git a/hubs/models/hub.py b/hubs/models/hub.py index f09dbfd..3f9a12f 100644 --- a/hubs/models/hub.py +++ b/hubs/models/hub.py @@ -30,9 +30,11 @@ 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.defaults import ( + add_group_widgets, add_user_widgets, add_stream_widgets, +) from hubs.utils import username2avatar from hubs.signals import hub_created from .association import Association @@ -134,6 +136,15 @@ class Hub(ObjectAuthzMixin, BASE): # etc... session = object_session(self) session.add(Association(user=user, hub=self, role=role)) + session.flush() + # Members & owners are subscribers by default too (#474) + if self.hub_type == "team" and role in ( + "member", "pending-member", "owner", "pending-owner"): + existing_subscriber = Association.query.filter_by( + hub=self, user=user, role="subscriber") + if existing_subscriber.count() == 0: + session.add(Association( + hub=self, user=user, role="subscriber")) session.commit() def unsubscribe(self, user, role='subscriber'): @@ -142,7 +153,8 @@ class Hub(ObjectAuthzMixin, BASE): # times, doing different roles, etc.. publish a fedmsg message, # etc... session = object_session(self) - association = Association.get(hub=self, user=user, role=role) + association = Association.query.filter_by( + hub=self, user=user, role=role).first() if not association: raise KeyError("%r is not a %r of %r" % (user, role, self)) if role == 'owner': @@ -155,6 +167,16 @@ class Hub(ObjectAuthzMixin, BASE): association.role = 'member' else: session.delete(association) + session.flush() + # Members are subscribers by default too, unsubscribe them when they + # leave. (#474) + if role in ("member", "pending-member"): + existing_subscriber = Association.query.filter_by( + hub=self, user=user, role="subscriber") + try: + session.delete(existing_subscriber.one()) + except sa.orm.exc.NoResultFound: + pass session.commit() @classmethod @@ -176,23 +198,21 @@ class Hub(ObjectAuthzMixin, BASE): session.add(hub) hub.config["summary"] = fullname hub.config["avatar"] = username2avatar(username) - session.flush() + # Commit before sending the signal or other processes won't see it + # (the workers). Flushing isn't enough. + session.commit() hub_created.send(hub) return hub @classmethod - def create_group_hub(cls, name, summary, **extra): + def create_group_hub(cls, name, summary): session = Session() hub = cls(name=name, hub_type="team") 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) + # Commit before sending the signal or other processes won't see it + # (the workers). Flushing isn't enough. + session.commit() + hub_created.send(hub) return hub @classmethod @@ -200,18 +220,26 @@ class Hub(ObjectAuthzMixin, BASE): session = Session() hub = cls(name=username, hub_type="stream") session.add(hub) + # Commit before sending the signal or other processes won't see it + # (the workers). Flushing isn't enough. + session.commit() hub_created.send(hub) return hub def on_created(self, **extra): if self.hub_type == "user": - hubs.defaults.add_user_widgets(self) + add_user_widgets(self) user = User.query.get(self.name) self.subscribe(user, role='owner') elif self.hub_type == "team": - hubs.defaults.add_group_widgets(self, **extra) + add_group_widgets(self) + flask.g.task_queue.enqueue( + "sync-team-hub", + hub=self.id, + created=True, + ) elif self.hub_type == "stream": - hubs.defaults.add_stream_widgets(self) + add_stream_widgets(self) def on_updated(self, old_config): for widget_instance in self.widgets: diff --git a/hubs/models/hubconfig.py b/hubs/models/hubconfig.py index 08d2923..c3531c1 100644 --- a/hubs/models/hubconfig.py +++ b/hubs/models/hubconfig.py @@ -88,8 +88,9 @@ class EnumConverter(Converter): class HubConfigProxy(MutableMapping): KEYS = ( - "archived", "summary", "left_width", "avatar", "visibility", - "chat_domain", "chat_channel", "mailing_list", "calendar", + "archived", "summary", "description", "left_width", "avatar", + "visibility", "chat_domain", "chat_channel", "mailing_list", + "calendar", ) + tuple(p["name"] for p in DEV_PLATFORMS) CONVERTERS = { "archived": BooleanConverter(), diff --git a/hubs/models/user.py b/hubs/models/user.py index ffb5a9e..ccc7fe9 100644 --- a/hubs/models/user.py +++ b/hubs/models/user.py @@ -26,6 +26,7 @@ import datetime import logging import operator +import flask import sqlalchemy as sa from sqlalchemy.orm import relation @@ -148,3 +149,7 @@ class User(BASE): Hub.create_user_hub(self.username, self.fullname) if Hub.by_name(self.username, "stream") is None: Hub.create_stream_hub(self.username) + flask.g.task_queue.enqueue( + "sync-user-roles", + username=self.username, + ) diff --git a/hubs/models/widget.py b/hubs/models/widget.py index df4e6ca..752d929 100644 --- a/hubs/models/widget.py +++ b/hubs/models/widget.py @@ -66,7 +66,7 @@ class Widget(ObjectAuthzMixin, BASE): _config = sa.Column(sa.Text, default="{}") index = sa.Column(sa.Integer, nullable=False) - left = sa.Column(sa.Boolean, nullable=False, default=False) + left = sa.Column(sa.Boolean(name="left"), nullable=False, default=False) visibility = sa.Column( sa.Enum(*VISIBILITY, name="widget_visibility"), default="public", nullable=False) diff --git a/hubs/signals.py b/hubs/signals.py index fbc7e3a..8321d25 100644 --- a/hubs/signals.py +++ b/hubs/signals.py @@ -31,9 +31,6 @@ widget_updated = hubs_signals.signal('widget-updated') hub_created = hubs_signals.signal('hub-created') hub_updated = hubs_signals.signal('hub-updated') user_created = hubs_signals.signal('user-created') -# TODO: hook to FAS: -# - user_created to sync memberships -# - hub_created when its a group hub to sync memberships @widget_updated.connect diff --git a/hubs/static/client/app/components/HubConfig/HubConfigDialog.js b/hubs/static/client/app/components/HubConfig/HubConfigDialog.js index 498a0cf..a802f13 100644 --- a/hubs/static/client/app/components/HubConfig/HubConfigDialog.js +++ b/hubs/static/client/app/components/HubConfig/HubConfigDialog.js @@ -113,24 +113,26 @@ export default class HubConfigDialog extends React.Component { tabs.push( } urls={this.props.urls} currentUser={this.props.currentUser} + hub={this.props.hub} /> , } urls={this.props.urls} currentUser={this.props.currentUser} + hub={this.props.hub} /> , { + ["summary", "description", "left_width", "visibility", "avatar"].forEach((key) => { invalid[key] = this.props.error.fields[key]; }); } @@ -108,6 +116,27 @@ export default class GeneralPanel extends React.Component {

+ +