#510 FAS integration
Merged 6 years ago by abompard. Opened 6 years ago by abompard.
abompard/fedora-hubs feature/fas-sync  into  develop

@@ -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()

@@ -133,6 +133,7 @@ 

      def _send_event(self, request, event, data):

          if event:

              request.write("event: {}\r\n".format(event).encode("utf-8"))

+         data = json.dumps(data)

          request.write("data: {}\r\n".format(data).encode("utf-8"))

          # The last CRLF is required to dispatch the event to the client.

          request.write(b"\r\n")

file modified
+24
@@ -58,6 +58,30 @@ 

          }))

          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)

file modified
+40
@@ -40,6 +40,7 @@ 

  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 @@ 

                      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

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

  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 @@ 

  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())

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

  

  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',

file modified
+9 -25
@@ -76,31 +76,18 @@ 

      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 @@ 

      )

      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.

  

@@ -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 <http://www.gnu.org/licenses/>.

+ """

+ 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')",

+         )

@@ -51,11 +51,3 @@ 

          '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()

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

  

  HUB_TYPES = ("user", "team", "stream")

  

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

+ ROLES = (

+     'stargazer',

+     'subscriber',

+     'pending-member', 'member',

+     'pending-owner', 'owner',

+ )

  

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

  

file modified
+43 -15
@@ -30,9 +30,11 @@ 

  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 @@ 

          # 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 @@ 

          # 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 @@ 

                  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 @@ 

          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 @@ 

          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:

file modified
+3 -2
@@ -88,8 +88,9 @@ 

  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(),

file modified
+5
@@ -26,6 +26,7 @@ 

  import logging

  import operator

  

+ import flask

  import sqlalchemy as sa

  from sqlalchemy.orm import relation

  
@@ -148,3 +149,7 @@ 

              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,

+             )

file modified
+2 -2
@@ -66,7 +66,7 @@ 

      _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)
@@ -156,7 +156,7 @@ 

          }

  

      def __repr__(self):

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

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

  

      @property

      def module(self):

file modified
-3
@@ -31,9 +31,6 @@ 

  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

@@ -113,24 +113,26 @@ 

        tabs.push(

          <UserPanel

            key="owners"

-           users={this.props.users.owner}

+           users={this.props.users}

            globalConfig={this.props.globalConfig}

            role="owner"

-           handleChange={this.props.onUserChange}

+           handleChange={this.props.usingFas ? null : this.props.onUserChange}

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

            urls={this.props.urls}

            currentUser={this.props.currentUser}

+           hub={this.props.hub}

            />

          ,

          <UserPanel

            key="members"

-           users={this.props.users.member}

+           users={this.props.users}

            globalConfig={this.props.globalConfig}

            role="member"

-           handleChange={this.props.onUserChange}

+           handleChange={this.props.usingFas ? null : this.props.onUserChange}

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

            urls={this.props.urls}

            currentUser={this.props.currentUser}

+           hub={this.props.hub}

            />

          ,

          <MailingListPanel

@@ -23,6 +23,14 @@ 

      id: "hubs.core.config.general.summary_help",

      defaultMessage: "This text will be displayed at the top of the hub.",

    },

+   description: {

+     id: "hubs.core.config.general.description",

+     defaultMessage: "Description",

+   },

+   description_help: {

+     id: "hubs.core.config.general.description_help",

+     defaultMessage: "This text will be displayed in a large field under the hub header.",

+   },

    left_width: {

      id: "hubs.core.config.general.left_width",

      defaultMessage: "Left width",
@@ -76,7 +84,7 @@ 

  

      let invalid = {};

      if (this.props.error && this.props.error.fields) {

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

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

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

        });

      }
@@ -108,6 +116,27 @@ 

            </p>

          </div>

          <div className="form-group row">

+           <label htmlFor="hub-settings-general-description">

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

+           </label>

+           <textarea

+             name="description"

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

+             id="hub-settings-general-description"

+             disabled={stillLoading}

+             onChange={this.handleChange}

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

+             />

+           { invalid.description &&

+             <div className="invalid-feedback">

+               {invalid.description}

+             </div>

+           }

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

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

+           </p>

+         </div>

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

            <label htmlFor="hub-settings-general-leftwidth">

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

            </label>

@@ -3,6 +3,7 @@ 

    injectIntl,

    defineMessages,

    FormattedMessage,

+   FormattedHTMLMessage,

    } from 'react-intl';

  import CompletionInput from '../CompletionInput';

  
@@ -36,6 +37,26 @@ 

      id: "hubs.core.config.users.add",

      defaultMessage: "Add",

    },

+   fas_managed: {

+     id: "hubs.core.config.users.fas_managed",

+     defaultMessage: "User management is <a href=\"{url}\">handled in FAS</a>.",

+   },

+   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 @@ 

  

    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 @@ 

            onChange={this.props.handleChange}

            locked={locked}

            />

-         );

-     }.bind(this));

- 

-     /*

-     const IntlCompletionInput = injectIntl(function(props) {

-         return (

-           <CompletionInput

-             inputProps={{

-               name: "username",

-               placeholder: props.intl.formatMessage(messages.add_user),

-             }}

-             url={(

-               props.urls.hubConfigSuggestUsers

-               + "?exclude-role=" + props.role

-             )}

-             onChange={this.handleAddInputChange}

-             />

-             );

-     }.bind(this));

-     */

+       );

+     });

+     const pendingUsers = this.props.users[`pending-${this.props.role}`].map(user => (

+       <UserRow

+         key={user.username}

+         user={user}

+         role={`pending-${this.props.role}`}

+         globalConfig={this.props.globalConfig}

+         onChange={this.props.handleChange}

+         />

+     ));

+ 

      const getSuggestionValue = (suggestion) => {

        return suggestion.username;

      };
@@ -124,59 +141,88 @@ 

      return (

        <div>

          <h3>{this.props.tabTitle}</h3>

-         {(this.props.role == "member") &&

-           <div>

-             <p className="text-muted">

-               [Membership requests will be shown here]

-             </p>

-             <hr />

+         { this.props.handleChange === null &&

+           <div className="alert alert-info">

+             <FormattedHTMLMessage

+               {...messages.fas_managed}

+               values={{

+                 url: `https://admin.fedoraproject.org/accounts/group/view/${this.props.hub.name}`

+               }}

+               />

            </div>

          }

-         <form

-           className="form-inline mb-3"

-           onSubmit={this.handleAdd}

-           >

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

-             <FormattedMessage {...messages.add_user}>

-               {(message) => (

-                 <CompletionInput

-                   inputProps={{

-                     name: "username",

-                     placeholder: message,

-                     value: this.state.addUserInputContents

-                   }}

-                   url={(

-                     this.props.urls.hubConfigSuggestUsers

-                     + "?exclude-role=" + this.props.role

-                   )}

-                   getSuggestionValue={getSuggestionValue}

-                   renderSuggestion={renderSuggestion}

-                   onChange={this.handleAddChanged}

-                   onSelect={this.handleAddSelected}

-                   />

-               )}

-             </FormattedMessage>

-             </div>

-           <button

-             className="btn btn-primary ml-3"

-             disabled={this.props.loading}

+         <h4 className="my-2">

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

+         </h4>

+         { pendingUsers.length === 0 ?

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

+           :

+           <table className="table table-hover table-sm">

+             <thead>

+               <tr>

+                 <FormattedMessage {...messages.fullname} tagName="th" />

+                 <FormattedMessage {...messages.username} tagName="th" />

+               </tr>

+             </thead>

+             <tbody>

+               {pendingUsers}

+             </tbody>

+           </table>

+         }

+         <h4 className="mt-4 mb-2">

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

+         </h4>

+         { this.props.handleChange !== null &&

+           <form

+             className="form-inline mb-3"

+             onSubmit={this.handleAdd}

              >

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

-           </button>

-         </form>

-         {users.length !== 0 &&

-         <table className="table table-hover table-sm">

-           <thead>

-             <tr>

-               <FormattedMessage {...messages.fullname} tagName="th" />

-               <FormattedMessage {...messages.username} tagName="th" />

-               <FormattedMessage {...messages.change_role} tagName="th" />

-             </tr>

-           </thead>

-           <tbody>

-             {users}

-           </tbody>

-         </table>

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

+               <FormattedMessage {...messages.add_user}>

+                 {(message) => (

+                   <CompletionInput

+                     inputProps={{

+                       name: "username",

+                       placeholder: message,

+                       value: this.state.addUserInputContents

+                     }}

+                     url={(

+                       this.props.urls.hubConfigSuggestUsers

+                       + "?exclude-role=" + this.props.role

+                     )}

+                     getSuggestionValue={getSuggestionValue}

+                     renderSuggestion={renderSuggestion}

+                     onChange={this.handleAddChanged}

+                     onSelect={this.handleAddSelected}

+                     />

+                 )}

+               </FormattedMessage>

+               </div>

+             <button

+               className="btn btn-primary ml-3"

+               disabled={this.props.loading}

+               >

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

+             </button>

+           </form>

+         }

+         {users.length !== 0 ?

+           <table className="table table-hover table-sm">

+             <thead>

+               <tr>

+                 <FormattedMessage {...messages.fullname} tagName="th" />

+                 <FormattedMessage {...messages.username} tagName="th" />

+                 { this.props.handleChange !== null &&

+                   <FormattedMessage {...messages.change_role} tagName="th" />

+                 }

+               </tr>

+             </thead>

+             <tbody>

+               {users}

+             </tbody>

+           </table>

+           :

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

          }

        </div>

      );
@@ -202,27 +248,29 @@ 

        <tr>

          <td>{this.props.user.fullname}</td>

          <td>{this.props.user.username}</td>

-         <td>

-           {this.props.locked ?

-             <FormattedMessage {...messages.na} />

-           :

-             <select

-               name={this.props.user.username}

-               value={this.props.role}

-               className="form-control"

-               onChange={this.handleChange}

-               >

-               {this.props.globalConfig.roles.map(function(role) {

-                 return (

-                   <option value={role} key={role}>{role}</option>

-                   );

-               })}

-               <FormattedMessage {...messages.remove_user}>

-                 {(value) => ( <option value="">{value}</option> )}

-               </FormattedMessage>

-             </select>

-           }

-         </td>

+         { this.props.onChange !== null &&

+           <td>

+             {this.props.locked ?

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

+             :

+               <select

+                 name={this.props.user.username}

+                 value={this.props.role}

+                 className="form-control"

+                 onChange={this.handleChange}

+                 >

+                 {this.props.globalConfig.roles.map(function(role) {

+                   return (

+                     <option value={role} key={role}>{role}</option>

+                     );

+                 })}

+                 <FormattedMessage {...messages.remove_user}>

+                   {(value) => ( <option value="">{value}</option> )}

+                 </FormattedMessage>

+               </select>

+             }

+           </td>

+         }

        </tr>

      );

    }

@@ -130,6 +130,7 @@ 

              currentUser={this.props.currentUser}

              hub={this.props.hub}

              isLoading={this.props.isLoading}

+             usingFas={this.props.usingFas}

              />

          }

        </div>
@@ -147,6 +148,7 @@ 

      currentUser: state.currentUser,

      isDialogOpen: state.ui.hubConfigDialogOpen,

      isLoading: state.ui.hubConfigDialogLoading,

+     usingFas: state.globalConfig.membership_in_fas,

    }

  };

  

@@ -30,11 +30,13 @@ 

    }

  

    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 @@ 

      }

      let secondButton = null;

      if (this.props.hub.type === "team") {

-       if (this.userHasRole("owner")) {

-         secondButton = (

-           <HubGiveUpAdminButton

-             onGiveUpAdmin={this.onGiveUpAdmin}

-             {...commonProps}

-             />

-         );

+       if (this.props.usingFas) {

+         if (this.userHasRole("owner")) {

+           secondButton = (

+             <HubFixedAdminButton

+               title={commonProps.title}

+               />

+           );

+         } else if (this.userHasRole("member")) {

+           secondButton = (

+             <HubFixedMemberButton

+               title={commonProps.title}

+               />

+           );

+         } else {

+           secondButton = (

+             <HubJoinRequestButton

+               hasRequested={this.userHasRole("pending-member")}

+               onRequest={this.onJoin}

+               onCancel={this.onLeave}

+               {...commonProps}

+               />

+           );

+         }

        } else {

-         secondButton = (

-           <HubJoinButton

-             isMember={this.userHasRole("member")}

-             onJoin={this.onJoin}

-             onLeave={this.onLeave}

-             {...commonProps}

-             />

-         );

+         if (this.userHasRole("owner")) {

+           secondButton = (

+             <HubGiveUpAdminButton

+               onGiveUpAdmin={this.onGiveUpAdmin}

+               {...commonProps}

+               />

+           );

+         } else {

+           secondButton = (

+             <HubJoinButton

+               isMember={this.userHasRole("member")}

+               onJoin={this.onJoin}

+               onLeave={this.onLeave}

+               {...commonProps}

+               />

+           );

+         }

        }

      }

      return (
@@ -100,14 +127,18 @@ 

  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 HubJoinRequestButton extends React.Component {

+   render() {

+     return (

+       <StateButton

+         isOn={this.props.hasRequested}

+         turnOnText="Join this hub"

+         turnOffText="Cancel request"

+         turnedOnText="Membership requested"

+         turnedOnIcon="user"

+         turnedOffIcon="user"

+         onTurnOn={this.props.onRequest}

+         onTurnOff={this.props.onCancel}

+         className="btn-sm"

+         disabled={this.props.disabled}

+         title={this.props.title}

+         />

+     );

+   }

+ }

+ 

+ 

  class HubGiveUpAdminButton extends React.Component {

    render() {

      return (
@@ -171,3 +223,35 @@ 

      );

    }

  }

+ 

+ 

+ class HubFixedAdminButton extends React.Component {

+   render() {

+     return (

+       <StateButton

+         isOn={true}

+         turnedOnText="Admin"

+         turnedOnIcon="key"

+         className="btn-sm"

+         disabled={true}

+         title={this.props.title}

+         />

+     );

+   }

+ }

+ 

+ 

+ class HubFixedMemberButton extends React.Component {

+   render() {

+     return (

+       <StateButton

+         isOn={true}

+         turnedOnText="Member"

+         turnedOnIcon="user"

+         className="btn-sm"

+         disabled={true}

+         title={this.props.title}

+         />

+     );

+   }

+ }

@@ -5,16 +5,11 @@ 

  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) {

@@ -37,7 +37,7 @@ 

      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 @@ 

    turnOnText: "Turn on",

    turnOffText: "Turn off",

    turnedOnText: "On",

-   onTurnOn: () => null,

-   onTurnOff: () => null,

+   onTurnOn: null,

+   onTurnOff: null,

    titleText: "",

    disabled: false,

  }

@@ -50,7 +50,7 @@ 

  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 @@ 

  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());

@@ -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 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,

+         });

+     }

    }

  }

  

@@ -48,7 +48,7 @@ 

          isLoading: true,

          // Optimistic update

          config: action.config,

-         users: action.users,

+         users: action.users || state.users,

          old: {config: state.config, users: state.users},

          error: null,

        };

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

  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 @@ 

          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):

@@ -17,3 +17,9 @@ 

      "port": "8080",

      "path": "/sse",

  }

+ 

+ MANAGE_MEMBERSHIP_IN_FAS = True

+ 

+ # Don't send emails during testing

+ EMAIL_HOST = "localhost"

+ EMAIL_PORT = 1025

@@ -17,7 +17,8 @@ 

          # 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 @@ 

          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

@@ -15,7 +15,8 @@ 

  

          # 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

@@ -10,15 +10,12 @@ 

  

      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):

file modified
-10
@@ -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 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(

@@ -10,15 +10,6 @@ 

  

  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/")

@@ -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 @@ 

              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 @@ 

              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 @@ 

              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)

@@ -3,6 +3,7 @@ 

  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 @@ 

                  'chat_channel': None,

                  'chat_domain': None,

                  "calendar": None,

+                 "description": None,

                  "mailing_list": None,

                  "github": [],

                  "pagure": [],
@@ -56,6 +58,8 @@ 

                      },

                  ],

                  "member": [],

+                 "pending-owner": [],

+                 "pending-member": [],

                  "stargazer": [],

                  "subscriber": [],

              },
@@ -148,6 +152,19 @@ 

          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 @@ 

              [(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 @@ 

          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):

file added
+264
@@ -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()

@@ -8,6 +8,8 @@ 

  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 @@ 

              "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:

@@ -80,6 +80,11 @@ 

  

  

  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:

file modified
+1
@@ -19,6 +19,7 @@ 

          "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),

@@ -81,7 +81,10 @@ 

          )

  

      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']

@@ -41,14 +41,17 @@ 

    </p>

    {% endif %}

  

+   {% if mailing_list_url or irc_channel %}

    <h6>Communication</h6>

    <ul class="list-unstyled mb-0">

+     {% if mailing_list and mailing_list_url %}

      <li>

        <i class="fa fa-envelope-o" aria-hidden="true"></i>

        <span class="ml-2">

          <a href="{{mailing_list_url}}">{{mailing_list}}</a>

        </span>

      </li>

+     {% endif %}

      {% if irc_channel %}

      <li>

        <i class="fa fa-comment-o" aria-hidden="true"></i>
@@ -58,6 +61,7 @@ 

      </li>

      {% endif %}

    </ul>

+   {% endif %}

  </div>

  

  

file modified
+3 -19
@@ -12,6 +12,7 @@ 

  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 @@ 

      username=input('Your FAS username: '),

      password=getpass.getpass(),

  )

+ hubs_fas_client = FASClient(fasclient)

  timeout = socket.getdefaulttimeout()

  socket.setdefaulttimeout(None)

  
@@ -73,7 +75,6 @@ 

                  continue

  

              name = membership['name']

-             summary = membership['display_name']

  

              if any([name.startswith(prefix) for prefix in prefix_blacklist]):

                  continue
@@ -82,23 +83,6 @@ 

  

              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()

@@ -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)

See #389.

Adds hook that syncs team hub properties and roles from FAS when they are created or when there's a change.
Also add two scripts to create team hubs and sync them, which can be run on-demand.

Very big PR, sorry I couldn't cut it into more understandable chunks.

rebased onto a23f4e1edf9d6fade0f9daed8163ef8a4f316480

6 years ago

rebased onto 6b6bab561f744cc314f216f1976e48dbe71b045f

6 years ago

rebased onto 7b4735e

6 years ago

Pull-Request has been merged by abompard

6 years ago
Metadata
Changes Summary 42
+53
file added
create-group-from-fas.py
+1 -0
file changed
hubs/backend/sse_server.py
+24 -0
file changed
hubs/backend/triage.py
+40 -0
file changed
hubs/backend/worker.py
+12 -1
file changed
hubs/database.py
+5 -0
file changed
hubs/default_config.py
+9 -25
file changed
hubs/defaults.py
+50
file added
hubs/migrations/versions/5c624b266713_pending_roles.py
+0 -8
file changed
hubs/models/association.py
+6 -1
file changed
hubs/models/constants.py
+43 -15
file changed
hubs/models/hub.py
+3 -2
file changed
hubs/models/hubconfig.py
+5 -0
file changed
hubs/models/user.py
+2 -2
file changed
hubs/models/widget.py
+0 -3
file changed
hubs/signals.py
+6 -4
file changed
hubs/static/client/app/components/HubConfig/HubConfigDialog.js
+30 -1
file changed
hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js
+141 -93
file changed
hubs/static/client/app/components/HubConfig/HubConfigPanelUser.js
+2 -0
file changed
hubs/static/client/app/components/HubConfig/index.js
+102 -18
file changed
hubs/static/client/app/components/HubHeader/HubMembership.js
+2 -7
file changed
hubs/static/client/app/components/SSESource.js
+3 -3
file changed
hubs/static/client/app/components/StateButton.js
+10 -3
file changed
hubs/static/client/app/core/actions/hub.js
+37 -15
file changed
hubs/static/client/app/core/actions/sse.js
+1 -1
file changed
hubs/static/client/app/core/reducers/hub.js
+7 -0
file changed
hubs/tests/__init__.py
+6 -0
file changed
hubs/tests/hubs_test.cfg
+4 -2
file changed
hubs/tests/models/test_hub.py
+2 -1
file changed
hubs/tests/models/test_user.py
+0 -3
file changed
hubs/tests/test_authn.py
+0 -10
file changed
hubs/tests/test_authz.py
+0 -9
file changed
hubs/tests/utils/test_views.py
+47 -0
file changed
hubs/tests/views/test_api_association.py
+21 -3
file changed
hubs/tests/views/test_api_hub_config.py
+264
file added
hubs/utils/fas.py
+10 -2
file changed
hubs/views/api/hub_association.py
+5 -0
file changed
hubs/views/api/hub_config.py
+1 -0
file changed
hubs/views/hub.py
+4 -1
file changed
hubs/widgets/github_pr/__init__.py
+4 -0
file changed
hubs/widgets/rules/templates/root.html
+3 -19
file changed
populate-from-fas.py
+53
file added
sync-group-from-fas.py