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 {
+
+
+ { invalid.description &&
+
+ {invalid.description}
+
+ }
+
+
+
+
+
diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelUser.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelUser.js
index 705d44f..6fc4107 100644
--- a/hubs/static/client/app/components/HubConfig/HubConfigPanelUser.js
+++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelUser.js
@@ -3,6 +3,7 @@ import {
injectIntl,
defineMessages,
FormattedMessage,
+ FormattedHTMLMessage,
} from 'react-intl';
import CompletionInput from '../CompletionInput';
@@ -36,6 +37,26 @@ const messages = defineMessages({
id: "hubs.core.config.users.add",
defaultMessage: "Add",
},
+ fas_managed: {
+ id: "hubs.core.config.users.fas_managed",
+ defaultMessage: "User management is
handled in FAS.",
+ },
+ pending: {
+ id: "hubs.core.config.users.pending",
+ defaultMessage: "Pending requests",
+ },
+ no_pending: {
+ id: "hubs.core.config.users.no_pending",
+ defaultMessage: "No pending request.",
+ },
+ current: {
+ id: "hubs.core.config.users.current",
+ defaultMessage: "Current users",
+ },
+ no_current: {
+ id: "hubs.core.config.users.no_current",
+ defaultMessage: "No current user.",
+ },
});
@@ -69,11 +90,15 @@ export default class UserPanel extends React.Component {
filterSuggestedUsers(user) {
// Only suggest non-members.
- return (this.props.users.indexOf(user) === -1);
+ return (
+ this.props.users[this.props.role].indexOf(user) === -1
+ &&
+ this.props.users[`pending-${this.props.role}`].indexOf(user) === -1
+ );
}
render() {
- var users = this.props.users.map(function(user) {
+ const users = this.props.users[this.props.role].map(user => {
const locked = (
this.props.role === "owner" &&
this.props.currentUser.logged_in &&
@@ -88,26 +113,18 @@ export default class UserPanel extends React.Component {
onChange={this.props.handleChange}
locked={locked}
/>
- );
- }.bind(this));
-
- /*
- const IntlCompletionInput = injectIntl(function(props) {
- return (
-
- );
- }.bind(this));
- */
+ );
+ });
+ const pendingUsers = this.props.users[`pending-${this.props.role}`].map(user => (
+
+ ));
+
const getSuggestionValue = (suggestion) => {
return suggestion.username;
};
@@ -124,59 +141,88 @@ export default class UserPanel extends React.Component {
return (
{this.props.tabTitle}
- {(this.props.role == "member") &&
-
-
- [Membership requests will be shown here]
-
-
+ { this.props.handleChange === null &&
+
+
}
-
- {users.length !== 0 &&
-
-
-
-
-
-
-
-
-
- {users}
-
-
+
+
+ {(message) => (
+
+ )}
+
+
+
+
+ }
+ {users.length !== 0 ?
+
+
+
+
+
+ { this.props.handleChange !== null &&
+
+ }
+
+
+
+ {users}
+
+
+ :
+
}
);
@@ -202,27 +248,29 @@ class UserRow extends React.Component {
{this.props.user.fullname} |
{this.props.user.username} |
-
- {this.props.locked ?
-
- :
-
- }
- |
+ { this.props.onChange !== null &&
+
+ {this.props.locked ?
+
+ :
+
+ }
+ |
+ }
);
}
diff --git a/hubs/static/client/app/components/HubConfig/index.js b/hubs/static/client/app/components/HubConfig/index.js
index c16dd76..6c173d6 100644
--- a/hubs/static/client/app/components/HubConfig/index.js
+++ b/hubs/static/client/app/components/HubConfig/index.js
@@ -130,6 +130,7 @@ class HubConfig extends React.Component {
currentUser={this.props.currentUser}
hub={this.props.hub}
isLoading={this.props.isLoading}
+ usingFas={this.props.usingFas}
/>
}
@@ -147,6 +148,7 @@ const mapStateToProps = (state) => {
currentUser: state.currentUser,
isDialogOpen: state.ui.hubConfigDialogOpen,
isLoading: state.ui.hubConfigDialogLoading,
+ usingFas: state.globalConfig.membership_in_fas,
}
};
diff --git a/hubs/static/client/app/components/HubHeader/HubMembership.js b/hubs/static/client/app/components/HubHeader/HubMembership.js
index 3f56ee1..dff6316 100644
--- a/hubs/static/client/app/components/HubHeader/HubMembership.js
+++ b/hubs/static/client/app/components/HubHeader/HubMembership.js
@@ -30,11 +30,13 @@ class HubMembership extends React.Component {
}
onJoin() {
- this.props.dispatch(associateUser("member"));
+ const role = this.props.usingFas ? "pending-member" : "member";
+ this.props.dispatch(associateUser(role));
}
onLeave() {
- this.props.dispatch(dissociateUser("member"));
+ const role = this.props.usingFas ? "pending-member" : "member";
+ this.props.dispatch(dissociateUser(role));
}
onGiveUpAdmin() {
@@ -66,22 +68,47 @@ class HubMembership extends React.Component {
}
let secondButton = null;
if (this.props.hub.type === "team") {
- if (this.userHasRole("owner")) {
- secondButton = (
-
- );
+ if (this.props.usingFas) {
+ if (this.userHasRole("owner")) {
+ secondButton = (
+
+ );
+ } else if (this.userHasRole("member")) {
+ secondButton = (
+
+ );
+ } else {
+ secondButton = (
+
+ );
+ }
} else {
- secondButton = (
-
- );
+ if (this.userHasRole("owner")) {
+ secondButton = (
+
+ );
+ } else {
+ secondButton = (
+
+ );
+ }
}
}
return (
@@ -100,14 +127,18 @@ class HubMembership extends React.Component {
HubMembership.propTypes = {
hub: PropTypes.object.isRequired,
currentUser: PropTypes.object,
+ usingFas: PropTypes.bool,
+}
+HubMembership.defaultProps = {
+ usingFas: false,
}
-
const mapStateToProps = (state) => {
return {
hub: state.entities.hub,
currentUser: state.currentUser,
+ usingFas: state.globalConfig.membership_in_fas,
}
};
@@ -155,6 +186,27 @@ class HubJoinButton extends React.Component {
}
+class HubJoinRequestButton extends React.Component {
+ render() {
+ return (
+
+ );
+ }
+}
+
+
class HubGiveUpAdminButton extends React.Component {
render() {
return (
@@ -171,3 +223,35 @@ class HubGiveUpAdminButton extends React.Component {
);
}
}
+
+
+class HubFixedAdminButton extends React.Component {
+ render() {
+ return (
+
+ );
+ }
+}
+
+
+class HubFixedMemberButton extends React.Component {
+ render() {
+ return (
+
+ );
+ }
+}
diff --git a/hubs/static/client/app/components/SSESource.js b/hubs/static/client/app/components/SSESource.js
index 930a38c..dcab4be 100644
--- a/hubs/static/client/app/components/SSESource.js
+++ b/hubs/static/client/app/components/SSESource.js
@@ -5,16 +5,11 @@ import ReconnectingEventSource from "reconnecting-eventsource";
import {
sseEvent,
sseConnected,
- sseDisconnected
+ sseDisconnected,
+ MESSAGE_TYPES,
} from "../core/actions/sse";
-const MESSAGE_TYPES = [
- "hubs:widget-updated",
- "hubs:new-notification",
-];
-
-
class SSESource extends React.PureComponent {
constructor(props) {
diff --git a/hubs/static/client/app/components/StateButton.js b/hubs/static/client/app/components/StateButton.js
index e789219..3ab88bf 100644
--- a/hubs/static/client/app/components/StateButton.js
+++ b/hubs/static/client/app/components/StateButton.js
@@ -37,7 +37,7 @@ export default class StateButton extends React.Component {
let iconClassName = "mr-2 fa fa-";
let text;
if (this.props.isOn) {
- if (this.state.isHovering) {
+ if (this.state.isHovering && this.props.onTurnOff !== null) {
buttonClassName += "btn-outline-primary nohover";
iconClassName += "times";
text = this.props.turnOffText;
@@ -89,8 +89,8 @@ StateButton.defaultProps = {
turnOnText: "Turn on",
turnOffText: "Turn off",
turnedOnText: "On",
- onTurnOn: () => null,
- onTurnOff: () => null,
+ onTurnOn: null,
+ onTurnOff: null,
titleText: "",
disabled: false,
}
diff --git a/hubs/static/client/app/core/actions/hub.js b/hubs/static/client/app/core/actions/hub.js
index b220e3f..24cbe0e 100644
--- a/hubs/static/client/app/core/actions/hub.js
+++ b/hubs/static/client/app/core/actions/hub.js
@@ -50,7 +50,7 @@ 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) {
+function requestSaveHub(config, users) {
return {
type: HUB_PUT_REQUEST,
config, users
@@ -75,8 +75,15 @@ function putHubFailure(error) {
export function saveHub(config, users) {
return (dispatch, getState) => {
let url = getState().urls.hubConfig;
- dispatch(putHub(config, users));
- const body = JSON.stringify({config, users});
+ const state = getState();
+ let body = {config};
+ if (state.globalConfig.membership_in_fas) {
+ users = null;
+ } else {
+ body.users = users;
+ }
+ dispatch(requestSaveHub(config, users));
+ body = JSON.stringify(body);
return apiCall(url, {method: "PUT", body}).then(
result => {
dispatch(putHubSuccess());
diff --git a/hubs/static/client/app/core/actions/sse.js b/hubs/static/client/app/core/actions/sse.js
index 5d15860..7c64955 100644
--- a/hubs/static/client/app/core/actions/sse.js
+++ b/hubs/static/client/app/core/actions/sse.js
@@ -1,8 +1,11 @@
+import { fetchCurrentUser } from './user';
+import { fetchHub } from './hub';
+
+
export const SSE_CONNECTED = 'SSE_CONNECTED';
export const SSE_DISCONNECTED = 'SSE_DISCONNECTED';
export const SSE_RECONNECTED = 'SSE_RECONNECTED';
-
export function sseConnected(source) {
return (dispatch, getState) => {
const state = getState();
@@ -38,22 +41,41 @@ export function sseDisconnected(source) {
export const WIDGET_NEEDS_UPDATE = 'WIDGET_NEEDS_UPDATE';
export const NEW_NOTIFICATION = 'NEW_NOTIFICATION';
+export const MESSAGE_TYPES = [
+ "hubs:widget-updated",
+ "hubs:user-updated",
+ "hubs:hub-updated",
+ "hubs:new-notification",
+];
export function sseEvent(type, data) {
- switch (type) {
- case "hubs:widget-updated":
- const widgetId = data;
- return {
- type: WIDGET_NEEDS_UPDATE,
- widgetId,
- };
- case "hubs:new-notification":
- // TODO: implement the reducers for this action
- const username = data;
- return {
- type: NEW_NOTIFICATION,
- username,
- };
+ return (dispatch, getState) => {
+ switch (type) {
+ case "hubs:widget-updated":
+ const widgetId = data;
+ return dispatch({
+ type: WIDGET_NEEDS_UPDATE,
+ widgetId,
+ });
+ case "hubs:user-updated":
+ const currentUser = getState().currentUser.nickname;
+ const affectedUsers = data.usernames || [];
+ if (affectedUsers.indexOf(currentUser) !== -1) {
+ dispatch(fetchCurrentUser());
+ }
+ // Reaload the hub member counters
+ dispatch(fetchHub());
+ break;
+ case "hubs:hub-updated":
+ return dispatch(fetchHub());
+ case "hubs:new-notification":
+ // TODO: implement the reducers for this action
+ const username = data;
+ return dispatch({
+ type: NEW_NOTIFICATION,
+ username,
+ });
+ }
}
}
diff --git a/hubs/static/client/app/core/reducers/hub.js b/hubs/static/client/app/core/reducers/hub.js
index 623cd94..5d5dcef 100644
--- a/hubs/static/client/app/core/reducers/hub.js
+++ b/hubs/static/client/app/core/reducers/hub.js
@@ -48,7 +48,7 @@ export function hubReducer(
isLoading: true,
// Optimistic update
config: action.config,
- users: action.users,
+ users: action.users || state.users,
old: {config: state.config, users: state.users},
error: null,
};
diff --git a/hubs/tests/__init__.py b/hubs/tests/__init__.py
index b6b9036..53ae181 100644
--- a/hubs/tests/__init__.py
+++ b/hubs/tests/__init__.py
@@ -12,6 +12,7 @@ import munch
import requests
import vcr
from flask.signals import template_rendered
+from mock import Mock
here = dirname(__file__)
cassette_dir = os.path.join(dirname(__file__), 'vcr-request-data')
@@ -48,11 +49,17 @@ class APPTest(unittest.TestCase):
self.app = hubs.app.app.test_client()
self.app.testing = True
+ # Setup the app context
+ self.app_context = hubs.app.app.app_context()
+ self.app_context.__enter__()
+ flask.g.task_queue = Mock()
+
self.populate()
def tearDown(self):
self.session.rollback()
hubs.database.Session.remove()
+ self.app_context.__exit__(None, None, None)
self.vcr.__exit__()
def populate(self):
diff --git a/hubs/tests/hubs_test.cfg b/hubs/tests/hubs_test.cfg
index bae6178..8e38379 100644
--- a/hubs/tests/hubs_test.cfg
+++ b/hubs/tests/hubs_test.cfg
@@ -17,3 +17,9 @@ SSE_URL = {
"port": "8080",
"path": "/sse",
}
+
+MANAGE_MEMBERSHIP_IN_FAS = True
+
+# Don't send emails during testing
+EMAIL_HOST = "localhost"
+EMAIL_PORT = 1025
diff --git a/hubs/tests/models/test_hub.py b/hubs/tests/models/test_hub.py
index b99e305..9486d34 100644
--- a/hubs/tests/models/test_hub.py
+++ b/hubs/tests/models/test_hub.py
@@ -17,7 +17,8 @@ class HubTest(hubs.tests.APPTest):
# check if association exists
username = 'ralph'
user = hubs.models.User.get(username)
- assoc = hubs.models.Association.get(hub, user, 'owner')
+ assoc = hubs.models.Association.query.filter_by(
+ hub=hub, user=user, role='owner').first()
self.assertIsNotNone(assoc)
# check if widgets exist
@@ -28,7 +29,8 @@ class HubTest(hubs.tests.APPTest):
self.session.delete(hub)
# check if association is removed
- assoc = hubs.models.Association.get(hub, user, 'owner')
+ assoc = hubs.models.Association.query.filter_by(
+ hub=hub, user=user, role='owner').first()
self.assertIsNone(assoc)
# check if widgets are removed
diff --git a/hubs/tests/models/test_user.py b/hubs/tests/models/test_user.py
index 09a03a2..9ffad59 100644
--- a/hubs/tests/models/test_user.py
+++ b/hubs/tests/models/test_user.py
@@ -15,7 +15,8 @@ class UserTest(hubs.tests.APPTest):
# check if association exists
hub = hubs.models.Hub.by_name(username, "user")
- assoc = hubs.models.Association.get(hub, user, 'owner')
+ assoc = hubs.models.Association.query.filter_by(
+ hub=hub, user=user, role='owner')
self.assertIsNotNone(assoc)
# delete the user
diff --git a/hubs/tests/test_authn.py b/hubs/tests/test_authn.py
index 1ff17c1..376a029 100644
--- a/hubs/tests/test_authn.py
+++ b/hubs/tests/test_authn.py
@@ -10,15 +10,12 @@ class AuthnTestCase(APPTest):
def setUp(self):
super(AuthnTestCase, self).setUp()
- self.app_context = hubs.app.app.app_context()
- self.app_context.__enter__()
self.req_context = hubs.app.app.test_request_context()
self.req_context.__enter__()
flask.g.db = self.session
def tearDown(self):
self.req_context.__exit__(None, None, None)
- self.app_context.__exit__(None, None, None)
super(AuthnTestCase, self).tearDown()
def test_expired_auth(self):
diff --git a/hubs/tests/test_authz.py b/hubs/tests/test_authz.py
index 9429124..b81669b 100644
--- a/hubs/tests/test_authz.py
+++ b/hubs/tests/test_authz.py
@@ -1,6 +1,5 @@
from __future__ import absolute_import, unicode_literals
-from hubs.app import app
from hubs.authz import ObjectAuthzMixin, AccessLevel, PERMISSIONS
from hubs.tests import APPTest, FakeUser
@@ -19,15 +18,6 @@ class TestObj(ObjectAuthzMixin):
class AuthzTestCase(APPTest):
- def setUp(self):
- super(AuthzTestCase, self).setUp()
- self.app_context = app.app_context()
- self.app_context.__enter__()
-
- def tearDown(self):
- self.app_context.__exit__(None, None, None)
- super(AuthzTestCase, self).tearDown()
-
def test_mixin_access_level_anonymous(self):
obj = TestObj()
self.assertEqual(
diff --git a/hubs/tests/utils/test_views.py b/hubs/tests/utils/test_views.py
index a966237..a0dbead 100644
--- a/hubs/tests/utils/test_views.py
+++ b/hubs/tests/utils/test_views.py
@@ -10,15 +10,6 @@ from hubs.utils.views import get_sse_url, move_widget, query_hubs
class ViewUtilsTest(APPTest):
- def setUp(self):
- super(ViewUtilsTest, self).setUp()
- self.app_context = app.app_context()
- self.app_context.__enter__()
-
- def tearDown(self):
- self.app_context.__exit__(None, None, None)
- super(ViewUtilsTest, self).tearDown()
-
def test_sse_url(self):
with app.test_request_context():
self.assertEqual(get_sse_url(""), "http://localhost:8080/sse/")
diff --git a/hubs/tests/views/test_api_association.py b/hubs/tests/views/test_api_association.py
index 54c68c2..46557cc 100644
--- a/hubs/tests/views/test_api_association.py
+++ b/hubs/tests/views/test_api_association.py
@@ -1,5 +1,7 @@
from __future__ import unicode_literals
+from mock import Mock, patch
+
import hubs.tests
import hubs.models
from hubs.app import app
@@ -57,6 +59,7 @@ class TestHubAssociations(hubs.tests.APPTest):
h = hubs.models.Hub.by_name('infra', "team")
self.assertNotIn(self.user.nickname, usernames(h.stargazers))
+ @patch.dict(app.config, {"MANAGE_MEMBERSHIP_IN_FAS": False})
def test_join(self):
hub = hubs.models.Hub.by_name('infra', "team")
with hubs.tests.auth_set(app, self.user):
@@ -65,6 +68,7 @@ class TestHubAssociations(hubs.tests.APPTest):
h = hubs.models.Hub.by_name('infra', "team")
self.assertIn(self.user.nickname, usernames(h.members))
+ @patch.dict(app.config, {"MANAGE_MEMBERSHIP_IN_FAS": False})
def test_leave(self):
hub = hubs.models.Hub.by_name('infra', "team")
# Need a real user model to subscribe to the hub
@@ -75,3 +79,46 @@ class TestHubAssociations(hubs.tests.APPTest):
self.assertEqual(resp.status_code, 200)
h = hubs.models.Hub.by_name('infra', "team")
self.assertNotIn(self.user.nickname, usernames(h.members))
+
+ @patch("hubs.utils.fas.smtplib")
+ def test_join_pending(self, smtplib):
+ SMTP = Mock()
+ smtplib.SMTP.return_value = SMTP
+ hub = hubs.models.Hub.by_name('infra', "team")
+ with hubs.tests.auth_set(app, self.user):
+ resp = self.app.post('/api/hubs/{}/pending-members'.format(hub.id))
+ self.assertEqual(resp.status_code, 200)
+ h = hubs.models.Hub.by_name('infra', "team")
+ pending_members = [
+ a.user.username for a in h.associations
+ if a.role == "pending-member"
+ ]
+ self.assertIn(self.user.nickname, pending_members)
+ SMTP.sendmail.assert_called()
+ self.assertEqual(
+ SMTP.sendmail.call_args[0][0], "hubs@fedoraproject.org")
+ self.assertEqual(
+ SMTP.sendmail.call_args[0][1],
+ ["infra-administrators@fedoraproject.org"])
+ self.assertIn(
+ "From: hubs@fedoraproject.org",
+ SMTP.sendmail.call_args[0][2])
+ self.assertIn(
+ "To: infra-administrators@fedoraproject.org",
+ SMTP.sendmail.call_args[0][2])
+
+ def test_cancel_pending(self):
+ hub = hubs.models.Hub.by_name('infra', "team")
+ # Need a real user model to subscribe to the hub
+ User = hubs.models.User.by_username(self.user.nickname)
+ hub.subscribe(User, role='pending-member')
+ with hubs.tests.auth_set(app, self.user):
+ resp = self.app.delete(
+ '/api/hubs/{}/pending-members'.format(hub.id))
+ self.assertEqual(resp.status_code, 200)
+ h = hubs.models.Hub.by_name('infra', "team")
+ pending_members = [
+ a.user.username for a in h.associations
+ if a.role == "pending-member"
+ ]
+ self.assertNotIn(self.user.nickname, pending_members)
diff --git a/hubs/tests/views/test_api_hub_config.py b/hubs/tests/views/test_api_hub_config.py
index bf6f341..773c9ce 100644
--- a/hubs/tests/views/test_api_hub_config.py
+++ b/hubs/tests/views/test_api_hub_config.py
@@ -3,6 +3,7 @@ from __future__ import unicode_literals
import datetime
from flask import json
+from mock import patch
from hubs.app import app
from hubs.models import Hub, User, Association
@@ -39,6 +40,7 @@ class TestAPIHubConfig(APPTest):
'chat_channel': None,
'chat_domain': None,
"calendar": None,
+ "description": None,
"mailing_list": None,
"github": [],
"pagure": [],
@@ -56,6 +58,8 @@ class TestAPIHubConfig(APPTest):
},
],
"member": [],
+ "pending-owner": [],
+ "pending-member": [],
"stargazer": [],
"subscriber": [],
},
@@ -148,6 +152,19 @@ class TestAPIHubConfig(APPTest):
hub_config = Hub.by_name("decause", "user").config
self.assertEqual(hub_config["summary"], "Decause")
+ def test_put_users_fas(self):
+ user = FakeAuthorization('ralph')
+ hub = Hub.by_name('decause', "user")
+ with auth_set(app, user):
+ result = self.app.put(
+ '/api/hubs/%s/config' % hub.id,
+ content_type="application/json",
+ data=json.dumps({"config": {"summary": "Defaced!"}}))
+ self.assertEqual(result.status_code, 403)
+ result_data = json.loads(result.get_data(as_text=True))
+ self.assertEqual(result_data["status"], "ERROR")
+
+ @patch.dict(app.config, {"MANAGE_MEMBERSHIP_IN_FAS": False})
def test_put_users(self):
hub = Hub.by_name('ralph', "user")
for username in ["devyani7", "dhrish"]:
@@ -179,12 +196,13 @@ class TestAPIHubConfig(APPTest):
[(a.user_id, a.role) for a in hub.associations],
[
('decause', 'member'),
- ('dhrish', u'member'),
- ('ralph', u'owner'),
+ ('dhrish', 'member'),
+ ('ralph', 'owner'),
('shalini', 'subscriber'),
]
)
+ @patch.dict(app.config, {"MANAGE_MEMBERSHIP_IN_FAS": False})
def test_put_users_other_roles(self):
# Only deal with the "member" and "owner" roles.
hub = Hub.by_name('ralph', "user")
@@ -208,7 +226,7 @@ class TestAPIHubConfig(APPTest):
self.assertEqual(result_data["status"], "OK")
self.assertListEqual(
[(a.user_id, a.role) for a in hub.associations],
- [('ralph', u'owner')]
+ [('ralph', 'owner')]
)
def test_suggest_users_no_filter(self):
diff --git a/hubs/utils/fas.py b/hubs/utils/fas.py
new file mode 100644
index 0000000..6dff930
--- /dev/null
+++ b/hubs/utils/fas.py
@@ -0,0 +1,264 @@
+from __future__ import unicode_literals
+
+import logging
+import smtplib
+
+import fedora
+import flask
+from six.moves.email_mime_text import MIMEText
+
+from hubs.database import Session
+from hubs.models import Hub, User, Association
+from hubs.utils import get_fedmsg_config
+
+
+log = logging.getLogger(__name__)
+
+
+class FASClient(object):
+
+ HANDLED_ROLES = ("owner", "member", "pending-owner", "pending-member")
+
+ def __init__(self, client=None):
+ if client is None:
+ fedmsg_config = get_fedmsg_config()
+ client = fedora.client.fas2.AccountSystem(
+ username=fedmsg_config["fas_credentials"]["username"],
+ password=fedmsg_config["fas_credentials"]["password"],
+ )
+ self.client = client
+ self.db = Session()
+
+ def person_by_username(self, fas_name):
+ return self.client.person_by_username(fas_name)
+
+ def group_by_name(self, fas_name):
+ return self.client.group_by_name(fas_name)
+
+ def sync_team_hub(self, hub):
+ fas_group = self.client.group_by_name(hub.name)
+ # Config
+ attr_map = {
+ "display_name": "summary",
+ "irc_network": "chat_domain",
+ "irc_channel": "chat_channel",
+ "mailing_list": "mailing_list",
+ "mailing_list_url": "mailing_list_url",
+ }
+ for fas_attr, hub_attr in attr_map.items():
+ if fas_group[fas_attr] is None:
+ continue
+ hub.config[hub_attr] = fas_group[fas_attr]
+ return fas_group
+
+ def sync_team_hub_roles(self, hub, fas_group=None):
+ if fas_group is None:
+ fas_group = self.client.group_by_name(hub.name)
+ affected_users = set()
+ # List roles in FAS
+ fas_roles = []
+ # According to https://pagure.io/fedora-hubs/issue/389#comment-481972
+ # "unapproved" members are still members in Hubs.
+ all_fas_group_roles = (
+ fas_group["approved_roles"] + fas_group["unapproved_roles"]
+ )
+ for membership in all_fas_group_roles:
+ if membership["role_type"] in ("administrator", "sponsor"):
+ role = "owner"
+ else:
+ role = "member"
+ if membership["role_status"] == "pending":
+ role = "pending-{}".format(role)
+ person = self.client.person_by_id(membership["person_id"])
+ fas_roles.append((person["username"], role))
+ # List roles in Hubs
+ existing_associations = Association.query.filter(
+ Association.hub == hub,
+ Association.role.in_(self.HANDLED_ROLES),
+ ).all()
+ # Remove extra roles
+ for assoc in existing_associations:
+ if (assoc.user.username, assoc.role) not in fas_roles:
+ log.debug("Removing role %s from user %s on hub %s",
+ assoc.role, assoc.user.username, hub.name)
+ self.db.delete(assoc)
+ affected_users.add(assoc.user.username)
+ # Add missing roles
+ existing_associations = [
+ (assoc.user.username, assoc.role)
+ for assoc in existing_associations
+ ]
+ for username, role in fas_roles:
+ if (username, role) in existing_associations:
+ continue
+ user = User.query.get(username)
+ if user is None:
+ continue
+ log.debug("Adding role %s to user %s on hub %s",
+ role, username, hub.name)
+ hub.subscribe(user, role)
+ affected_users.add(username)
+ return list(affected_users)
+
+ def sync_user_roles(self, user, hub=None):
+ fas_user = self.client.person_by_username(user.username)
+ affected_hubs = set()
+ # List roles in FAS
+ fas_roles = []
+ for group_name, fas_role in fas_user["group_roles"].items():
+ if hub is not None and group_name != hub.name:
+ continue
+ # # See https://pagure.io/fedora-hubs/issue/389#comment-481972
+ # # "unapproved" members are still members in Hubs.
+ # if fas_role["role_status"] == "unapproved":
+ # continue
+ if fas_role["role_type"] in ("administrator", "sponsor"):
+ role = "owner"
+ else:
+ role = "member"
+ if fas_role["role_status"] == "pending":
+ role = "pending-{}".format(role)
+ fas_roles.append((group_name, role))
+ # List roles in Hubs
+ existing_associations = Association.query.filter(
+ Association.user == user,
+ Association.role.in_(self.HANDLED_ROLES),
+ )
+ if hub is None:
+ existing_associations = existing_associations.join(Hub).filter(
+ Hub.hub_type == "team",
+ )
+ else:
+ existing_associations = existing_associations.filter(
+ Association.hub == hub,
+ )
+ existing_associations = existing_associations.all()
+ # Remove extra roles
+ for assoc in existing_associations:
+ if (assoc.hub.name, assoc.role) not in fas_roles:
+ log.debug("Removing role %s from user %s on hub %s",
+ assoc.role, user.username, assoc.hub.name)
+ self.db.delete(assoc)
+ affected_hubs.add(assoc.hub.id)
+ # Add missing roles
+ existing_associations = [
+ (assoc.hub.name, assoc.role)
+ for assoc in existing_associations
+ ]
+ for group_name, role in fas_roles:
+ if (group_name, role) in existing_associations:
+ continue
+ hub = Hub.by_name(group_name, "team")
+ if hub is None:
+ continue
+ log.debug("Adding role %s to user %s on hub %s",
+ role, user.username, hub.name)
+ hub.subscribe(user, role)
+ affected_hubs.add(hub.id)
+ return list(affected_hubs)
+
+
+# The functions below will be called by the backend (worker)
+
+
+def sync_team_hub(hub_id, created=False):
+ hub = Hub.query.get(hub_id)
+ if hub is None:
+ return []
+ log.debug("Syncing team hub %s with FAS", hub.name)
+ fas_client = FASClient()
+ try:
+ # Sync group config and roles
+ fas_group = fas_client.sync_team_hub(hub)
+ if created:
+ # Only set the description on creation because we want to allow hub
+ # admins to later change it to a different content from what's in
+ # FAS.
+ hub.config["description"] = fas_group["apply_rules"]
+ # Sync roles on creation
+ affected_users = fas_client.sync_team_hub_roles(hub, fas_group)
+ else:
+ affected_users = []
+ fas_client.db.commit()
+ except Exception:
+ fas_client.db.rollback()
+ raise
+ log.info("Synced team hub %s with FAS (%d affected users)",
+ hub.name, len(affected_users))
+ return affected_users
+
+
+def sync_team_hub_roles(hub_id):
+ hub = Hub.query.get(hub_id)
+ if hub is None:
+ return []
+ log.debug("Syncing team hub %s's roles with FAS", hub.name)
+ fas_client = FASClient()
+ try:
+ # Sync group config and roles
+ affected_users = fas_client.sync_team_hub_roles(hub)
+ fas_client.db.commit()
+ except Exception:
+ fas_client.db.rollback()
+ raise
+ log.info("Synced team hub %s's roles with FAS (%d affected users)",
+ hub.name, len(affected_users))
+ return affected_users
+
+
+def sync_user_roles(username, hub_id):
+ user = User.query.get(username)
+ if user is None:
+ return []
+ hub = Hub.query.get(hub_id)
+ if hub is None:
+ return []
+ log.debug("Syncing user %s's roles on hub %s with FAS", username, hub.name)
+ fas_client = FASClient()
+ try:
+ # Sync user roles
+ affected_hubs = fas_client.sync_user_roles(user, hub)
+ fas_client.db.commit()
+ except Exception:
+ fas_client.db.rollback()
+ raise
+ log.info("Synced user %s's roles with FAS (%d affected hubs)",
+ username, len(affected_hubs))
+ return affected_hubs
+
+
+# Send an email when there's a new membership request
+
+def notify_membership_request(user, hub, role):
+ if not role.startswith("pending-"):
+ return # Don't notify for subscriptions and stargazing.
+ content = """
+Fedora user {username} ({realname}) would like to join {groupname}, a Fedora group that you admin.
+The request was made via Fedora Hubs.
+
+To accept {username}'s membership request, please visit the following URL and enter
+their Fedora Account System ID (FAS ID) into the "Add User" box and submit it:
+
+https://admin.fedoraproject.org/accounts/group/view/{groupname}
+
+FAS ID: {username}
+
+Thanks for serving as a group administrator in the Fedora community!
+
+Cheers,
+The Fedora Hubs Team
+""".format( # noqa:E501
+ username=user.username, realname=user.fullname, groupname=hub.name,
+ )
+ # Create a text/plain message
+ msg = MIMEText(content)
+ msg['Subject'] = "New Recruit for your Fedora team ({})!".format(hub.name)
+ email_from = "hubs@fedoraproject.org"
+ email_to = "{}-administrators@fedoraproject.org".format(hub.name)
+ msg['From'] = email_from
+ msg['To'] = email_to
+ # Send the message via the local SMTP server.
+ app_config = flask.current_app.config
+ s = smtplib.SMTP(app_config["EMAIL_HOST"], app_config["EMAIL_PORT"])
+ s.sendmail(email_from, [email_to], msg.as_string())
+ s.quit()
diff --git a/hubs/views/api/hub_association.py b/hubs/views/api/hub_association.py
index a21a32d..aa34e2e 100644
--- a/hubs/views/api/hub_association.py
+++ b/hubs/views/api/hub_association.py
@@ -8,6 +8,8 @@ from hubs.app import app
from hubs.utils.views import (
get_hub_by_id, require_hub_access, authenticated, get_user_permissions
)
+from hubs.utils.fas import notify_membership_request
+
log = logging.getLogger(__name__)
@@ -21,15 +23,21 @@ def api_hub_associations(hub_id, role):
"message": "You must be logged-in",
}), 403
hub = get_hub_by_id(hub_id)
+ if app.config["MANAGE_MEMBERSHIP_IN_FAS"]:
+ if role in ("owner", "member"):
+ return flask.jsonify({
+ "status": "ERROR",
+ "message": "User management is handled in FAS",
+ }), 400
if flask.request.method == "POST":
if role == "owner":
return flask.jsonify({
"status": "ERROR",
"message": "You are not allowed to do that",
}), 403
- # TODO: don't auto-subscribe to members if the membership requires
- # moderation (how do we know that?)
hub.subscribe(flask.g.user, role=role)
+ if app.config["MANAGE_MEMBERSHIP_IN_FAS"]:
+ notify_membership_request(flask.g.user, hub, role)
flask.g.db.commit()
elif flask.request.method == "DELETE":
try:
diff --git a/hubs/views/api/hub_config.py b/hubs/views/api/hub_config.py
index 62dd1e4..7cdbccb 100644
--- a/hubs/views/api/hub_config.py
+++ b/hubs/views/api/hub_config.py
@@ -80,6 +80,11 @@ def hub_config_put_config(hub, config):
def hub_config_put_users(hub, user_roles):
+ if app.config["MANAGE_MEMBERSHIP_IN_FAS"]:
+ raise ConfigChangeError({
+ "status": "ERROR",
+ "message": "User management is handled in FAS",
+ })
ONLY_ROLES = ("owner", "member") # Leave alone subscribers and stargazers.
new_associations = []
for role in user_roles:
diff --git a/hubs/views/hub.py b/hubs/views/hub.py
index b8ae080..686c8ed 100644
--- a/hubs/views/hub.py
+++ b/hubs/views/hub.py
@@ -19,6 +19,7 @@ def hub(hub_type, hub_name):
"hub_visibility": hubs.models.constants.VISIBILITIES,
"roles": ["owner", "member"],
"dev_platforms": hubs.models.constants.DEV_PLATFORMS,
+ "membership_in_fas": app.config["MANAGE_MEMBERSHIP_IN_FAS"],
}
urls = {
"widgets": flask.url_for("api_hub_widgets", hub_id=hub.id),
diff --git a/hubs/widgets/github_pr/__init__.py b/hubs/widgets/github_pr/__init__.py
index 1f13a5f..e638778 100644
--- a/hubs/widgets/github_pr/__init__.py
+++ b/hubs/widgets/github_pr/__init__.py
@@ -81,7 +81,10 @@ class GetPRs(CachedFunction):
)
def should_invalidate(self, message):
- category = message["topic"].split('.')[3]
+ try:
+ category = message["topic"].split('.')[3]
+ except IndexError:
+ return False
if category != "github":
return False
owner = message['msg']['repository']['owner']
diff --git a/populate-from-fas.py b/populate-from-fas.py
index f375030..303b3bd 100755
--- a/populate-from-fas.py
+++ b/populate-from-fas.py
@@ -12,6 +12,7 @@ import fedora.client.fas2
import hubs.database
import hubs.models
from hubs.utils import get_fedmsg_config
+from hubs.utils.fas import FASClient
fedmsg_config = get_fedmsg_config()
@@ -23,6 +24,7 @@ fasclient = fedora.client.fas2.AccountSystem(
username=input('Your FAS username: '),
password=getpass.getpass(),
)
+hubs_fas_client = FASClient(fasclient)
timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(None)
@@ -73,7 +75,6 @@ for letter in reversed(sorted(list(set(string.letters.lower())))):
continue
name = membership['name']
- summary = membership['display_name']
if any([name.startswith(prefix) for prefix in prefix_blacklist]):
continue
@@ -82,23 +83,6 @@ for letter in reversed(sorted(list(set(string.letters.lower())))):
hub = hubs.models.Hub.by_name(name, "team")
if hub is None:
- hub = hubs.models.Hub.create_group_hub(
- name=name,
- summary=summary,
- apply_rules=membership['apply_rules'],
- irc_channel=membership['irc_channel'],
- irc_network=membership['irc_network'],
- join_message=membership['joinmsg'],
- mailing_list=membership['mailing_list'],
- mailing_list_url=membership['mailing_list_url'],
- )
-
- if hubs_user not in hub.subscribers:
- hub.subscribe(hubs_user, role='subscriber')
- if hubs_user not in hub.members:
- hub.subscribe(hubs_user, role='member')
- if hubs_user not in hub.owners:
- if role['role_type'] in [u'administrator', u'sponsor']:
- hub.subscribe(hubs_user, role='owner')
+ hubs.models.Hub.create_group_hub(name)
db.commit()
diff --git a/sync-group-from-fas.py b/sync-group-from-fas.py
new file mode 100755
index 0000000..b9664ae
--- /dev/null
+++ b/sync-group-from-fas.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+
+from __future__ import unicode_literals, print_function
+
+import sys
+from argparse import ArgumentParser
+from fedora.client import AppError
+
+import hubs.app
+import hubs.database
+from hubs.models import Hub
+from hubs.utils import get_fedmsg_config
+from hubs.utils.fas import sync_team_hub
+
+
+def parse_args():
+ parser = ArgumentParser()
+ parser.add_argument(
+ "name", nargs="*", help="team hub to sync")
+ parser.add_argument(
+ "--no-roles", action="store_true", help="do not sync roles")
+ return parser.parse_args()
+
+
+def main():
+ args = parse_args()
+ fedmsg_config = get_fedmsg_config()
+ hubs.database.init(fedmsg_config['hubs.sqlalchemy.uri'])
+ if not args.name:
+ hubs_to_sync = Hub.query.filter_by(hub_type="team").all()
+ else:
+ hubs_to_sync = []
+ for hub_name in args.name:
+ hub = hubs.models.Hub.by_name(hub_name, "team")
+ if hub is None:
+ print("The team hub {} does not exist.".format(hub_name))
+ return 1
+ hubs_to_sync.append(hub)
+ with hubs.app.app.app_context():
+ db = hubs.database.Session()
+ for hub in hubs_to_sync:
+ try:
+ affected_users = sync_team_hub(hub.id, not args.no_roles)
+ except AppError as e:
+ print("Failed syncing hub {}: {}".format(hub.name, e.message))
+ continue
+ db.commit()
+ print("Hub {} synced! {} affected users".format(
+ hub.name, len(affected_users)))
+
+
+if __name__ == "__main__":
+ sys.exit(main() or 0)