#440 Integrate the stats widget in the hub header
Merged 4 years ago by abompard. Opened 4 years ago by abompard.
abompard/fedora-hubs feature/hub-stats  into  develop

file modified
-2
@@ -58,7 +58,6 @@ 

      'hubs.widgets.dummy:Dummy',

      'hubs.widgets.library:Library',

      'hubs.widgets.linechart:Linechart',

-     'hubs.widgets.fedmsgstats:FedmsgStats',

      'hubs.widgets.feed:Feed',

      'hubs.widgets.github_pr:GitHubPRs',

      'hubs.widgets.githubissues:GitHubIssues',
@@ -69,7 +68,6 @@ 

      'hubs.widgets.pagure_pr:PagurePRs',

      'hubs.widgets.pagureissues:PagureIssues',

      'hubs.widgets.rules:Rules',

-     'hubs.widgets.stats:Stats',

      'hubs.widgets.sticky:Sticky',

      'hubs.widgets.subscriptions:Subscriptions',

      'hubs.widgets.workflow.updates2stable:Updates2Stable',

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

      hub.widgets.append(widget)

  

      widget = hubs.models.Widget(

-         plugin='fedmsgstats', index=0,

-         _config=json.dumps({

-             'username': username,

-         }))

-     hub.widgets.append(widget)

-     widget = hubs.models.Widget(

          plugin='workflow.updates2stable', index=1,

          _config=json.dumps({

              'username': username,

file modified
+26 -13
@@ -109,7 +109,7 @@ 

      return session

  

  

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

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

  

  

  class Association(BASE):
@@ -122,7 +122,7 @@ 

                          sa.ForeignKey('users.username'),

                          primary_key=True)

      role = sa.Column(

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

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

  

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

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

          session.commit()

  

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

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

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

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

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

          # etc...
@@ -222,7 +222,16 @@ 

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

          if not association:

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

-         session.delete(association)

+         if role == 'owner':

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

+             is_member = bool(Association.query.filter_by(

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

+             if is_member:

+                 session.delete(association)

+             else:

+                 association.role = 'member'

+         else:

+             session.delete(association)

          session.commit()

  

      @classmethod
@@ -300,23 +309,27 @@ 

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

  

      def get_props(self):

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

-         roles = ["owner", "member"]

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

          result = {

              "name": self.name,

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

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

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

              "mtime": self.last_refreshed,

              "user_hub": self.user_hub,

          }

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

-             if assoc.role not in roles:

+             if assoc.role not in ROLES:

                  continue

-             user = {

-                 "username": assoc.user.username,

-                 "fullname": assoc.user.fullname,

-             }

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

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

+         if self.user_hub:

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

+             if user is None:

+                 result["subscribed_to"] = []

+             else:

+                 result["subscribed_to"] = [

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

+                         user=user, role="subscriber")

+                     ]

          return result

  

      def __json__(self):

@@ -1,5 +1,5 @@ 

  .HubConfig {

-     display: inline;

+     display: inline-block;

      margin: 0.25rem 0.5rem;

      text-align: left;

  }

@@ -4,17 +4,14 @@ 

  import Spinner from "./Spinner";

  import HubConfig from './HubConfig';

  import EditModeButton from './EditModeButton';

+ import HubStats from './HubStats';

+ import HubMembership from './HubMembership';

+ import HubStar from './HubStar';

For a second I thought this was Python and I was like "whoah I had no idea you could import this way!"

  import "./HubHeader.css";

  

  

  class HubHeader extends React.Component {

  

-   propTypes: {

-     hub: PropTypes.object.isRequired,

-     isLoading: PropTypes.bool,

-     currentUser: PropTypes.object,

-   }

- 

    render() {

      return (

        <div className="HubHeader">
@@ -24,6 +21,9 @@ 

          { this.props.hub.name &&

            <div className="row">

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

+               <HubStats

+                 hub={this.props.hub}

+                 />

                { this.props.hub.user_hub ?

                  <div>

                    <img
@@ -40,6 +40,7 @@ 

                  </div>

                :

                  <div>

+                   <HubStar />

                    <h2 className="team pt-0">

                      {this.props.hub.name}

                    </h2>
@@ -49,18 +50,27 @@ 

                  </div>

                }

              </div>

-             { this.props.currentUser.can_config_hub &&

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

-                 <HubConfig />

-                 <EditModeButton />

-               </div>

-             }

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

+               <HubMembership />

+               { this.props.currentUser.perms.config_hub &&

+                 <div>

+                   <HubConfig />

+                   <EditModeButton />

+                 </div>

+               }

+             </div>

            </div>

          }

        </div>

      );

    }

  }

+ HubHeader.propTypes = {

+   hub: PropTypes.object.isRequired,

+   isLoading: PropTypes.bool,

+   currentUser: PropTypes.object,

+ }

+ 

  

  

  const mapStateToProps = (state) => {

@@ -0,0 +1,173 @@ 

+ import React from 'react';

+ import PropTypes from 'prop-types';

+ import { connect } from 'react-redux';

+ import {

+   associateUser,

+   dissociateUser

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

+ import StateButton from "./StateButton";

+ 

+ 

+ 

+ class HubMembership extends React.Component {

+ 

+   constructor(props) {

+     super(props);

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

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

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

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

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

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

+   }

+ 

+   onSubscribe() {

+     this.props.dispatch(associateUser("subscriber"));

+   }

+ 

+   onUnsubscribe() {

+     this.props.dispatch(dissociateUser("subscriber"));

+   }

+ 

+   onJoin() {

+     this.props.dispatch(associateUser("member"));

+   }

+ 

+   onLeave() {

+     this.props.dispatch(dissociateUser("member"));

+   }

+ 

+   onGiveUpAdmin() {

+     this.props.dispatch(dissociateUser("owner"));

+   }

+ 

+   userHasRole(role) {

+     if (!this.props.currentUser.logged_in) {

+       return false;

+     }

+     const users = this.props.hub.users[role].map((user) => (user.username));

+     return (users.indexOf(this.props.currentUser.nickname) !== -1);

+   }

+ 

+   render() {

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

+       return null;

+     }

+     if (this.props.hub.user_hub && this.props.currentUser.perms.config_hub) {

+       return null;  // The user's own hub.

+     }

+     let commonProps = {disabled: false, title: ""}

+     if (!this.props.currentUser.logged_in) {

+       commonProps.disabled = true;

+       commonProps.title = "You must be logged in.";

+     } else if (this.props.hub.isLoading) {

+       commonProps.disabled = true;

+       commonProps.title = "loading...";

+     }

+     let secondButton = null;

+     if (!this.props.hub.user_hub) {

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

+       <div className="HubMembership">

+         <HubSubscribeButton

+           isSubscriber={this.userHasRole("subscriber")}

+           onSubscribe={this.onSubscribe}

+           onUnsubscribe={this.onUnsubscribe}

+           {...commonProps}

+           />

+         {secondButton}

+       </div>

+     );

+   }

+ }

+ HubMembership.propTypes = {

+   hub: PropTypes.object.isRequired,

+   currentUser: PropTypes.object,

+ }

+ 

+ 

+ 

+ const mapStateToProps = (state) => {

+   return {

+     hub: state.entities.hub,

+     currentUser: state.currentUser,

+   }

+ };

+ 

+ export default connect(mapStateToProps)(HubMembership);

+ 

+ 

+ class HubSubscribeButton extends React.Component {

+   render() {

+     return (

+       <StateButton

+         isOn={this.props.isSubscriber}

+         turnOnText="Subscribe"

+         turnOffText="Unsubscribe"

+         turnedOnText="Subscribed"

+         turnedOnIcon="check"

+         onTurnOn={this.props.onSubscribe}

+         onTurnOff={this.props.onUnsubscribe}

+         className="btn-sm mr-2"

+         disabled={this.props.disabled}

+         title={this.props.title}

+         />

+     );

+   }

+ }

+ 

+ 

+ class HubJoinButton extends React.Component {

+   render() {

+     return (

+       <StateButton

+         isOn={this.props.isMember}

+         turnOnText="Join this hub"

+         turnOffText="Leave this hub"

+         turnedOnText="Member"

+         turnedOnIcon="user"

+         turnedOffIcon="user"

+         onTurnOn={this.props.onJoin}

+         onTurnOff={this.props.onLeave}

+         className="btn-sm"

+         disabled={this.props.disabled}

+         title={this.props.title}

+         />

+     );

+   }

+ }

+ 

+ 

+ class HubGiveUpAdminButton extends React.Component {

+   render() {

+     return (

+       <StateButton

+         isOn={true}

+         turnOffText="Give up admin"

+         turnedOnText="Admin"

+         turnedOnIcon="key"

+         onTurnOff={this.props.onGiveUpAdmin}

+         className="btn-sm"

+         disabled={this.props.disabled}

+         title={this.props.title}

+         />

+     );

+   }

+ }

@@ -0,0 +1,8 @@ 

+ .HubStar {

+     position: absolute;

+     left: -1.2rem;

+     line-height: 2rem;

+     font-size: 1.2rem;

+     color: #000;

+     z-index: 1; /* Above the left menu */

+ }

@@ -0,0 +1,77 @@ 

+ import React from 'react';

+ import PropTypes from 'prop-types';

+ import { connect } from 'react-redux';

+ import {

+   associateUser,

+   dissociateUser

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

+ import "./HubStar.css";

+ 

+ 

+ class HubStar extends React.Component {

+ 

+   constructor(props) {

+     super(props);

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

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

+   }

+ 

+   onClick(e) {

+     e.preventDefault();

+     if (!this.props.currentUser.logged_in) {

+       return;

+     }

+     if (this.isStarred()) {

+       this.props.dispatch(dissociateUser("stargazer"));

+     } else {

+       this.props.dispatch(associateUser("stargazer"));

+     }

+   }

+ 

+   isStarred() {

+     if (!this.props.currentUser.logged_in) {

+       return false;

+     }

+     const stargazers = this.props.hub.users.stargazer.map((user) => (user.username));

+     return (stargazers.indexOf(this.props.currentUser.nickname) !== -1);

+   }

+ 

+   render() {

+     if (!this.props.hub.name || this.props.hub.user_hub) {

+       // Only for team hubs

+       return null;

+     }

+     const icon = this.isStarred() ? "star" : "star-o";

+     let otherProps = {}

+     if (!this.props.currentUser.logged_in) {

+       otherProps = {

+         disabled: true,

+         title: "You must be logged-in to star a hub"

+       }

+     }

+     return (

+       <button

+         className="HubStar btn-link"

+         onClick={this.onClick}

+         {...otherProps}

+         >

+         <i className={`fa fa-${icon}`}></i>

+       </button>

+     );

+   }

+ }

+ HubStar.propTypes = {

+   hub: PropTypes.object.isRequired,

+   currentUser: PropTypes.object,

+ }

+ 

+ 

+ 

+ const mapStateToProps = (state) => {

+   return {

+     hub: state.entities.hub,

+     currentUser: state.currentUser,

+   }

+ };

+ 

+ export default connect(mapStateToProps)(HubStar);

@@ -0,0 +1,10 @@ 

+ .HubStats {

+ }

+ 

+ .HubStatsCounter .title {

+     font-size: 80%;

+ }

+ .HubStatsCounter .value {

+     font-size: 150%;

+     line-height: 1em;

+ }

@@ -0,0 +1,69 @@ 

+ import React from 'react';

+ import PropTypes from 'prop-types';

+ import { connect } from 'react-redux';

+ import {

+   subscribe,

+   unsubscribe,

+   join,

+   leave,

+   giveUpAdmin,

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

+ import StateButton from "./StateButton";

+ import "./HubStats.css";

+ 

+ 

+ export default class HubStats extends React.Component {

+ 

+   render() {

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

+       return null;

+     }

+     return (

+       <div className="HubStats d-flex float-right h-100">

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

+           <HubStatsCounter

+             title="Members"

+             value={

+               this.props.hub.users.member.length

+               + this.props.hub.users.owner.length

+             }

+             />

+         }

+         <HubStatsCounter

+           title="Subscribers"

+           value={this.props.hub.users.subscriber.length}

+           />

+         { this.props.hub.user_hub &&

+           <HubStatsCounter

+             title="Subscribed to"

+             value={this.props.hub.subscribed_to.length}

+             />

+         }

+       </div>

+     );

+   }

+ }

+ HubStats.propTypes = {

+   hub: PropTypes.object.isRequired,

+ }

+ 

+ 

+ class HubStatsCounter extends React.Component {

+ 

+   render() {

+     return (

+       <div className="HubStatsCounter mx-2 align-self-center">

+         <div className="title text-uppercase text-secondary">

+           {this.props.title}

+         </div>

+         <div className="value text-primary text-center">

+           {this.props.value}

+         </div>

+       </div>

+     );

+   }

+ }

+ HubStatsCounter.propTypes = {

+   title: PropTypes.string.isRequired,

+   value: PropTypes.number.isRequired,

+ }

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

  @media (min-width: 992px) {

    .LeftMenu {

      position: absolute;

+     padding: 0.5rem 30px 0 0;

    }

  }

  

@@ -9,7 +9,7 @@ 

    render() {

      return (

        <div className={this.props.cssClass}>

-         <header>

+         <header className="container-fluid">

            <FlashMessages />

            <div className="row">

              <div className="col-lg-2">

@@ -0,0 +1,7 @@ 

+ /* By default "outline" buttons turn into "regular" buttons on hover */

+ .StateButton.btn-outline-primary.nohover:hover {

+     color: #007bff;

+     background-color: transparent;

+     background-image: none;

+     border-color: #007bff;

+ }

@@ -0,0 +1,94 @@ 

+ import React from 'react';

+ import PropTypes from 'prop-types';

+ import "./StateButton.css";

+ 

+ 

+ export default class StateButton extends React.Component {

+ 

+   constructor(props) {

+     super(props);

+     this.state = {

+       isHovering: false,

+     }

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

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

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

+   }

+ 

+   onClick(e) {

+     e.preventDefault();

+     if (this.props.isOn) {

+       this.props.onTurnOff();

+     } else {

+       this.props.onTurnOn();

+     }

+   }

+ 

+   onMouseOver() {

+     this.setState({isHovering: true});

+   }

+ 

+   onMouseOut() {

+     this.setState({isHovering: false});

+   }

+ 

+   render() {

+     let buttonClassName = "StateButton btn ";

+     let iconClassName = "mr-2 fa fa-";

+     let text;

+     if (this.props.isOn) {

+       if (this.state.isHovering) {

+         buttonClassName += "btn-outline-primary nohover";

+         iconClassName += "times";

+         text = this.props.turnOffText;

+       } else {

+         buttonClassName += "btn-primary";

+         iconClassName += this.props.turnedOnIcon;

+         text = this.props.turnedOnText;

+       }

+     } else {

+       buttonClassName += "btn-outline-primary";

+       iconClassName += this.props.turnedOffIcon;

+       text = this.props.turnOnText;

+     }

+     if (this.props.className) {

+       buttonClassName += " " + this.props.className;

+     }

+     return (

+       <button

+         className={buttonClassName}

+         onClick={this.onClick}

+         onMouseOver={this.onMouseOver}

+         onMouseOut={this.onMouseOut}

+         disabled={this.props.disabled}

+         title={this.props.title}

+         >

+         <i className={iconClassName} />

+         {text}

+       </button>

+     );

+   }

+ }

+ StateButton.propTypes = {

+   isOn: PropTypes.bool,

+   icon: PropTypes.string,

+   className: PropTypes.string,

+   turnOnText: PropTypes.string,

+   turnOffText: PropTypes.string,

+   turnedOnText: PropTypes.string,

+   onTurnOn: PropTypes.func.isRequired,

+   onTurnOff: PropTypes.func.isRequired,

+   titleText: PropTypes.string,

+   disabled: PropTypes.bool,

+ }

+ StateButton.defaultProps = {

+   isOn: false,

+   turnedOnIcon: "check",

+   turnedOffIcon: "plus",

+   className: "",

+   turnOnText: "Turn on",

+   turnOffText: "Turn off",

+   turnedOnText: "On",

+   titleText: "",

+   disabled: false,

+ }

@@ -55,10 +55,9 @@ 

    }

  }

  

- function putHubFailure(message) {

+ function putHubFailure() {

    return {

      type: HUB_PUT_FAILURE,

-     message

    }

  }

  
@@ -75,7 +74,68 @@ 

        },

        error => {

          dispatch(addFlashMessage(error.message, "error"));

-         return dispatch(putHubFailure(error.message));

+         return dispatch(putHubFailure());

        })

    }

  }

+ 

+ 

+ /* Membership & subscriptions */

+ 

+ export const HUB_ASSOC_REQUEST = 'HUB_ASSOC_REQUEST';

+ export const HUB_ASSOC_SUCCESS = 'HUB_ASSOC_SUCCESS';

+ export const HUB_ASSOC_FAILURE = 'HUB_ASSOC_FAILURE';

+ 

+ function requestHubAssoc(role) {

+   return {

+     type: HUB_ASSOC_REQUEST,

+     role

+   }

+ }

+ 

+ function receiveHubAssoc(role, users, perms) {

+   return {

+     type: HUB_ASSOC_SUCCESS,

+     role, users, perms,

+     receivedAt: Date.now()

+   }

+ }

+ 

+ function hubAssocFailure() {

+   return {

+     type: HUB_ASSOC_FAILURE,

+   }

+ }

+ 

+ function associateResponse(dispatch, role, result) {

+   //dispatch(fetchHub());

+   if (result.message) {

+     dispatch(addFlashMessage(result.message, "info"));

+   }

+   dispatch(receiveHubAssoc(role, result.users, result.perms));

+ }

+ 

+ function associateError(dispatch, role, error) {

+   dispatch(addFlashMessage(error.message, "error"));

+   return dispatch(hubAssocFailure());

+ }

+ 

+ export function associateUser(role) {

+   return (dispatch, getState) => {

+     const url = getState().urls.hub + role + "s";

+     return apiCall(url, {method: "POST"}).then(

+       result => associateResponse(dispatch, role, result),

+       error => associateError(dispatch, role, error)

+     );

+   }

+ }

+ 

+ export function dissociateUser(role) {

+   return (dispatch, getState) => {

+     const url = getState().urls.hub + role + "s";

+     return apiCall(url, {method: "DELETE"}).then(

+       result => associateResponse(dispatch, role, result),

+       error => associateError(dispatch, role, error)

+     );

+   }

+ }

@@ -4,6 +4,9 @@ 

    HUB_FETCH_FAILURE,

    HUB_PUT_REQUEST,

    HUB_PUT_FAILURE,

+   HUB_ASSOC_REQUEST,

+   HUB_ASSOC_SUCCESS,

+   HUB_ASSOC_FAILURE,

    } from '../actions/hub';

  

  
@@ -17,6 +20,7 @@ 

  ) {

    switch (action.type) {

      case HUB_FETCH_REQUEST:

+     case HUB_ASSOC_REQUEST:

        return {

          ...state,

          isLoading: true
@@ -29,6 +33,7 @@ 

          old: {}

        };

      case HUB_FETCH_FAILURE:

+     case HUB_ASSOC_FAILURE:

        return {

          ...state,

          isLoading: false
@@ -50,6 +55,12 @@ 

          config: state.old.config,

          users: state.old.users,

        };

+     case HUB_ASSOC_SUCCESS:

+       return {

+         ...state,

+         users: action.users,

+         isLoading: false,

+       };

      default:

        return state

    }

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

    widgetConfigDialogOpen,

    availableWidgetsReducer,

    } from "./widgets";

+ import { userReducer } from "./user";

  

  

  const entities = combineReducers({
@@ -39,7 +40,7 @@ 

    sse: sseReducer,

    globalConfig: noop,

    urls: noop,

-   currentUser: noop,

+   currentUser: userReducer,

    ui,

    entities,

  });

@@ -0,0 +1,16 @@ 

+ import {

+   HUB_ASSOC_SUCCESS,

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

+ 

+ 

+ export function userReducer(state=null, action) {

+   switch (action.type) {

+     case HUB_ASSOC_SUCCESS:

+       return {

+         ...state,

+         perms: action.perms,

+       };

+     default:

+       return state

+   }

+ }

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

      let content;

      if (!domain || !channel) {

        content = "No IRC channel configured.";

-       if (this.props.currentUser.can_config_hub) {

+       if (this.props.currentUser.perms.config_hub) {

          content += " Go to \"Hubs settings\" to configure it.";

        }

        content = (

file modified
-16
@@ -144,22 +144,6 @@ 

      flex-wrap: wrap;

  }

  

- .stats-table {

- width: 250px;

- padding: 0px;

- margin: 0px;

- display: inline-block;

- }

- .stats-table th {

- text-transform: uppercase;

- font-size: 80%;

- padding-right: 10px;

- color: #797a7c;

- }

- .stats-table td {

- font-size: 32pt;

- }

- 

  .square-32 {

      width: 32px;

      height: 32px;

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

  

  

  class ModelTest(hubs.tests.APPTest):

+ 

      def test_delete_user(self):

          # verify user exists

          username = 'ralph'
@@ -235,3 +236,14 @@ 

          widget = hubs.models.Widget(hub=hub, plugin="does-not-exist")

          self.session.add(widget)

          self.assertFalse(widget.enabled)

+ 

+     def test_unsubscribe_owner(self):

+         # Owners must be turned into regular members when unsubscribed.

+         hub = hubs.models.Hub.query.get("infra")

+         ralph = hubs.models.User.query.get("ralph")

+         self.session.add(hubs.models.Association(

+             hub=hub, user=ralph, role='owner'))

+         self.assertIn(ralph, hub.owners)

+         hub.unsubscribe(ralph, role="owner")

+         self.assertNotIn(ralph, hub.owners)

+         self.assertIn(ralph, hub.members)

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

  class TestAPIHubWidgets(APPTest):

  

      def test_get_widgets(self):

-         expected_ids = [35, 36, 34, 37, 38, 39, 40, 41, 42, 43, 44, 56]

+         expected_ids = [32, 33, 31, 34, 35, 36, 37, 38, 39, 40, 51]

          response = self.check_url("/api/hubs/ralph/widgets/")

          response_data = json.loads(response.get_data(as_text=True))

          self.assertEqual(response_data["status"], "OK")
@@ -220,7 +220,7 @@ 

          response = self.check_url("/api/hubs/ralph/widgets/37/", user=user)

          response_data = json.loads(response.get_data(as_text=True))

          self.assertEqual(response_data["status"], "OK")

-         self.assertEqual(response_data["data"]["name"], "fedmsgstats")

+         self.assertEqual(response_data["data"]["name"], "pagure_pr")

  

      def test_get_logged_out(self):

          hub = Hub.by_name('ralph')

@@ -1,31 +0,0 @@ 

- from __future__ import unicode_literals

- 

- from hubs.tests import FakeAuthorization, widget_instance

- from . import WidgetTest

- 

- 

- class TestFedmsgStats(WidgetTest):

-     plugin = 'fedmsgstats'  # The name in hubs.widgets.registry

- 

-     def test_data_simple(self):

-         widget = widget_instance('ralph', self.plugin)

-         user = FakeAuthorization('ralph')

-         response = self.check_url(

-             '/ralph/w/%s/%i/' % (self.plugin, widget.idx), user)

-         self.assertDictEqual(response.context, {

-             'fedmsgs': 83854,

-             'fedmsgs_text': '83,854',

-             'subscribers': [],

-             'subscribed_to': [],

-             'subscribers_text': '0',

-             'subscribed_text': '0',

-             'username': 'ralph',

-             'hub_subscribe_url': '/api/hubs/ralph/subscribe',

-             'hub_unsubscribe_url': '/api/hubs/ralph/unsubscribe',

-             'edit_mode': False,

-             'widget': widget.module,

-             'widget_instance': widget,

-         })

- 

-     def test_view_authz(self):

-         self._test_view_authz()

file modified
+11
@@ -236,6 +236,17 @@ 

      return decorator

  

  

+ def get_user_permissions(hub):

+     """Return a JSON-serializable dict of a user's permissions on a hub.

+ 

+     Arguments:

+         hub (hubs.models.Hub): A Hub instance.

+     """

+     return {

+         "config_hub": hub.allows(flask.g.user, "config"),

+     }

+ 

+ 

  def is_safe_url(target):

      """ Checks that the target url is safe and sending to the current

      website not some other malicious one.

@@ -2,6 +2,7 @@ 

  

  # flake8: noqa

  

+ from .hub import *

  from .hub_association import *

  from .hub_config import *

  from .hub_widget import *

@@ -0,0 +1,14 @@ 

+ from __future__ import absolute_import, unicode_literals

+ 

+ import flask

+ 

+ from hubs.app import app

+ from hubs.utils.views import get_hub, require_hub_access

+ 

+ 

+ @app.route('/api/hubs/<name>/', methods=['GET'])

+ @require_hub_access("view", json=True)

+ def api_hub(name):

I recommend a docblock here that includes the type of the parameter and return value.

+     hub = get_hub(name)

+     result = {"status": "OK", "data": hub.get_props()}

+     return flask.jsonify(result)

@@ -1,80 +1,50 @@ 

- from __future__ import absolute_import

+ from __future__ import absolute_import, unicode_literals

  

  import logging

  

  import flask

  

- import hubs.models

  from hubs.app import app

- from hubs.utils.views import get_hub, login_required

+ from hubs.utils.views import (

+     get_hub, require_hub_access, authenticated, get_user_permissions

+     )

  

  log = logging.getLogger(__name__)

  

  

- @app.route('/api/hubs/<hub>/subscribe', methods=['POST'])

- @login_required

- def api_hub_subscribe(hub):

+ @app.route('/api/hubs/<hub>/<role>s', methods=['POST', 'DELETE'])

+ @require_hub_access("view", url_param="hub", json=True)

+ def api_hub_associations(hub, role):

+     if not authenticated():

+         return flask.jsonify({

+             "status": "ERROR",

+             "message": "You must be logged-in",

+         }), 403

      hub = get_hub(hub)

-     user = hubs.models.User.by_username(flask.g.auth.nickname)

-     hub.subscribe(user)

-     flask.g.db.commit()

-     return flask.redirect(flask.url_for('hub', name=hub.name))

- 

- 

- @app.route('/api/hubs/<hub>/unsubscribe', methods=['POST'])

- @login_required

- def api_hub_unsubscribe(hub):

-     hub = get_hub(hub)

-     user = hubs.models.User.by_username(flask.g.auth.nickname)

-     try:

-         hub.unsubscribe(user)

-     except KeyError:

-         return flask.abort(400)

-     flask.g.db.commit()

-     return flask.redirect(flask.url_for('hub', name=hub.name))

- 

- 

- @app.route('/api/hubs/<hub>/star', methods=['POST'])

- @login_required

- def api_hub_star(hub):

-     hub = get_hub(hub)

-     user = hubs.models.User.by_username(flask.g.auth.nickname)

-     hub.subscribe(user, role='stargazer')

-     flask.g.db.commit()

-     return flask.redirect(flask.url_for('hub', name=hub.name))

- 

- 

- @app.route('/api/hubs/<hub>/unstar', methods=['POST'])

- @login_required

- def api_hub_unstar(hub):

-     hub = get_hub(hub)

-     user = hubs.models.User.by_username(flask.g.auth.nickname)

-     try:

-         hub.unsubscribe(user, role='stargazer')

-     except KeyError:

-         return flask.abort(400)

-     flask.g.db.commit()

-     return flask.redirect(flask.url_for('hub', name=hub.name))

- 

- 

- @app.route('/api/hubs/<hub>/join', methods=['POST'])

- @login_required

- def api_hub_join(hub):

-     hub = get_hub(hub)

-     user = hubs.models.User.by_username(flask.g.auth.nickname)

-     hub.subscribe(user, role='member')

-     flask.g.db.commit()

-     return flask.redirect(flask.url_for('hub', name=hub.name))

- 

- 

- @app.route('/api/hubs/<hub>/leave', methods=['POST'])

- @login_required

- def api_hub_leave(hub):

-     hub = get_hub(hub)

-     user = hubs.models.User.by_username(flask.g.auth.nickname)

-     try:

-         hub.unsubscribe(user, role='member')

-     except KeyError:

-         return flask.abort(400)

-     flask.g.db.commit()

-     return flask.redirect(flask.url_for('hub', name=hub.name))

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

+         flask.g.db.commit()

+     elif flask.request.method == "DELETE":

+         try:

+             hub.unsubscribe(flask.g.user, role=role)

+         except KeyError:

+             return flask.jsonify({

+                 "status": "ERROR",

+                 "message": "User {} does not have role {} on {}".format(

+                     flask.g.user.username, role, hub.name

+                 ),

+             }), 400

+         flask.g.db.commit()

+     flask.g.db.refresh(hub)

+     result = {"status": "OK", "data": {

+         "users": hub.get_props()["users"],

+         "perms": get_user_permissions(hub)

+     }}

+     return flask.jsonify(result)

file modified
+1 -1
@@ -1,4 +1,4 @@ 

- from __future__ import absolute_import

+ from __future__ import absolute_import, unicode_literals

  

  import logging

  

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

  from hubs.app import app

  from hubs.utils.views import (

      get_hub, get_sse_url, get_menu_entries, require_hub_access,

+     get_user_permissions

      )

  

  
@@ -22,16 +23,13 @@ 

          "widgets": flask.url_for("api_hub_widgets", hub=hub.name),

          "availableWidgets": flask.url_for("api_widgets"),

          "sse": get_sse_url("hub/{}".format(hub.name)),

+         "hub": flask.url_for("api_hub", name=hub.name),

          "hubConfig": flask.url_for("api_hub_config", name=hub.name),

          "hubConfigSuggestUsers": flask.url_for(

              "api_hub_config_suggest_users", name=hub.name),

      }

      current_user = flask.g.auth.copy()

-     current_user.update({

-         "can_view_hub": hub.allows(flask.g.user, "view"),

-         "can_config_hub": hub.allows(flask.g.user, "config"),

-         "can_manage_users": hub.allows(flask.g.user, "users.manage"),

-     })

+     current_user["perms"] = get_user_permissions(hub)

      menu = get_menu_entries()

      flash_messages = [

          {"msg": msg[1], "type": msg[0]} for msg in

@@ -1,79 +0,0 @@ 

- from __future__ import unicode_literals

- 

- import flask

- import fedmsg.config

- import fedmsg.meta

- import requests

- 

- from hubs.utils import get_fedmsg_config

- from hubs.utils.text import commas

- from hubs.widgets import validators

- from hubs.widgets.base import Widget

- from hubs.widgets.view import RootWidgetView

- from hubs.widgets.caching import CachedFunction

- 

- 

- fedmsg_config = get_fedmsg_config()

- 

- 

- class FedmsgStats(Widget):

- 

-     name = "fedmsgstats"

-     label = "Fedmsg stats"

-     position = "both"

-     parameters = [dict(

-         name="username",

-         label="Username",

-         default=None,

-         validator=validators.Username,

-         help="A FAS username.",

-         )]

- 

- 

- class BaseView(RootWidgetView):

- 

-     def get_context(self, instance, *args, **kwargs):

-         username = instance.config["username"]

-         context = dict(

-             username=username,

-             hub_subscribe_url=flask.url_for(

-                 'api_hub_subscribe', hub=instance.hub.name),

-             hub_unsubscribe_url=flask.url_for(

-                 'api_hub_unsubscribe', hub=instance.hub.name),

-         )

-         get_stats = GetStats(instance)

-         context.update(get_stats())

-         return context

- 

- 

- class GetStats(CachedFunction):

- 

-     def execute(self):

-         username = self.instance.config["username"]

-         url = "https://apps.fedoraproject.org/datagrepper/raw?user={username}"

-         url = url.format(username=username)

-         try:

-             response = requests.get(url, timeout=5)

-             fedmsgs = response.json()['total']

-         except (requests.exceptions.Timeout, ValueError):

-             fedmsgs = None

-             fedmsgs_text = "?"

-         else:

-             fedmsgs_text = commas(fedmsgs)

-         sub_list = []

-         for assoc in self.instance.hub.associations:

-             if assoc.user:

-                 sub_list = [u.name for u in assoc.user.subscriptions]

-         subscribers = [u.username for u in self.instance.hub.subscribers]

-         return dict(

-             fedmsgs=fedmsgs,

-             fedmsgs_text=fedmsgs_text,

-             subscribers=subscribers,

-             subscribed_to=sub_list,

-             subscribers_text=commas(len(subscribers)),

-             subscribed_text=commas(len(sub_list)),

-             )

- 

-     def should_invalidate(self, message):

-         usernames = fedmsg.meta.msg2usernames(message, **fedmsg_config)

-         return (self.instance.config['username'] in usernames)

@@ -1,23 +0,0 @@ 

- <div class="stats-container row">

-   <div class="col-sm-7 col-md-12 col-lg-7">

-   <table class="stats-table">

-     <tr><th>subscribers</th><th>subscribed to</th></tr>

-     <tr class="text-info"><td>{{subscribers_text}}</td><td class="text-center">{{subscribed_text}}</td></tr>

-   </table>

-   </div>

-   <div class="col-sm-5 col-md-12 col-lg-5 align-self-center">

-   {% if session['nickname'] != username %}

-     <ul class="list-unstyled">

-     {% if session['nickname'] in subscribers %}

-     <li><form action="{{hub_unsubscribe_url}}" method="POST">

-         <button class="btn btn-info"><span class="glyphicon glyphicon-remove-sign" aria-hidden="true"></span> Unsubscribe</button>

-     </form></li>

-     {% else %}

-     <li><form action="{{hub_subscribe_url}}" method="POST">

-         <button class="btn btn-secondary"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Subscribe</button>

-     </form></li>

-     {% endif %}

-     </ul>

-   {% endif %}

-   </div>

- </div>

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

- from __future__ import unicode_literals

- 

- import flask

- 

- from hubs.utils.text import commas

- from hubs.widgets.base import Widget

- from hubs.widgets.view import RootWidgetView

- from hubs.widgets.caching import CachedFunction

- 

- 

- class Stats(Widget):

- 

-     name = "stats"

-     position = "right"

- 

- 

- class BaseView(RootWidgetView):

- 

-     def get_context(self, instance, *args, **kwargs):

-         get_stats = GetStats(instance)

-         return get_stats()

- 

- 

- class GetStats(CachedFunction):

- 

-     def execute(self):

-         hub = self.instance.hub

-         owners = [u.username for u in hub.owners]

-         members = [u.username for u in hub.members]

-         subscribers = [u.username for u in hub.subscribers]

-         stargazers = [u.username for u in hub.stargazers]

- 

-         return dict(

-             owners=owners,

-             members=members,

-             subscribers=subscribers,

-             stargazers=stargazers,

- 

-             owners_text=commas(len(owners)),

-             members_text=commas(len(members)),

-             subscribers_text=commas(len(subscribers)),

-             stargazers_text=commas(len(stargazers)),

- 

-             hub_leave_url=flask.url_for('api_hub_leave', hub=hub.name),

-             hub_join_url=flask.url_for('api_hub_join', hub=hub.name),

-             hub_unstar_url=flask.url_for('api_hub_unstar', hub=hub.name),

-             hub_star_url=flask.url_for('api_hub_star', hub=hub.name),

-             hub_subscribe_url=flask.url_for(

-                 'api_hub_subscribe', hub=hub.name),

-             hub_unsubscribe_url=flask.url_for(

-                 'api_hub_unsubscribe', hub=hub.name),

-         )

- 

-     def should_invalidate(self, message):

-         if not message['topic'].endswith('hubs.hub.update'):

-             return False

-         if message['msg']['hub']['name'] == self.instance.hub.name:

-             return True

- 

-         # TODO -- also check for FAS group changes??  are we doing that?

- 

-         return False

@@ -1,74 +0,0 @@ 

- <div class="stats-container row">

- <div class="col-md-7">

-   <table class="stats-table">

-     <tr><th>Members</th><th>Subscribers</th></tr>

-     <tr class="text-info">

-       <td>{{members_text}}</td>

-       <td class="text-right">{{subscribers_text}}</td>

-     </tr>

-   </table>

- </div>

- <div class="col-md-5">

-   <ul class="list-unstyled">

-   {% if g.auth.nickname in subscribers %}

-   <li>

-     <form action="{{hub_unsubscribe_url}}" method="POST">

-       <button class="btn btn-info">

-         <span class="glyphicon glyphicon-remove-sign" aria-hidden="true"></span>

-         Unsubscribe

-       </button>

-     </form>

-   </li>

-   {% else %}

-   <li>

-     <form action="{{hub_subscribe_url}}" method="POST">

-       <button class="btn btn-secondary">

-         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>

-         Subscribe

-       </button>

-     </form>

-   </li>

-   {% endif %}

- 

-   {% if g.auth.nickname in stargazers %}

-   <li>

-     <form action="{{hub_unstar_url}}" method="POST">

-       <button class="btn btn-info">

-         <span class="glyphicon glyphicon-remove-sign" aria-hidden="true"></span>

-         Unstar Hub

-       </button>

-     </form>

-   </li>

-   {% else %}

-   <li>

-     <form action="{{hub_star_url}}" method="POST">

-       <button class="btn btn-secondary">

-         <span class="glyphicon glyphicon-star" aria-hidden="true"></span>

-         Star Hub

-       </button>

-     </form>

-   </li>

-   {% endif %}

- 

-   {% if g.auth.nickname in members %}

-   <li>

-     <form action="{{hub_leave_url}}" method="POST">

-       <button class="btn btn-info">

-         <span class="glyphicon glyphicon-remove-sign" aria-hidden="true"></span>

-         Leave Hub

-       </button>

-     </form>

-   </li>

-   {% else %}

-   <li>

-     <form action="{{hub_join_url}}" method="POST">

-       <button class="btn btn-secondary">

-         <span class="glyphicon glyphicon-user" aria-hidden="true"></span>

-         Join Hub

-       </button>

-     </form>

-   </li>

-   {% endif %}

-   </ul>

- </div>

- </div>

file modified
+5 -16
@@ -17,7 +17,6 @@ 

  # Register widgets we will use

  hubs.widgets.registry.register_list([

      "hubs.widgets.contact:Contact",

-     "hubs.widgets.stats:Stats",

      "hubs.widgets.rules:Rules",

      "hubs.widgets.meetings:Meetings",

      "hubs.widgets.about:About",
@@ -42,9 +41,7 @@ 

  session.add(hubs.models.HubConfig(

      hub=hub, summary='The Internationalization Team'))

  

- widget = hubs.models.Widget(plugin='contact', index=-1)

- hub.widgets.append(widget)

- widget = hubs.models.Widget(plugin='stats', index=0)

+ widget = hubs.models.Widget(plugin='contact', index=0)

  hub.widgets.append(widget)

  widget = hubs.models.Widget(

      plugin='rules', index=1, _config=json.dumps({
@@ -99,9 +96,7 @@ 

  session.add(hubs.models.HubConfig(

      hub=hub, summary='The Fedora Community Operations Team'))

  

- widget = hubs.models.Widget(plugin='contact', index=-1)

- hub.widgets.append(widget)

- widget = hubs.models.Widget(plugin='stats', index=0)

+ widget = hubs.models.Widget(plugin='contact', index=0)

  hub.widgets.append(widget)

  widget = hubs.models.Widget(

      plugin='rules', index=1, _config=json.dumps({
@@ -153,9 +148,7 @@ 

  session.add(hubs.models.HubConfig(

      hub=hub, summary='The Fedora Marketing Team'))

  

- widget = hubs.models.Widget(plugin='contact', index=-1)

- hub.widgets.append(widget)

- widget = hubs.models.Widget(plugin='stats', index=0)

+ widget = hubs.models.Widget(plugin='contact', index=0)

  hub.widgets.append(widget)

  widget = hubs.models.Widget(

      plugin='rules', index=1, _config=json.dumps({
@@ -214,9 +207,7 @@ 

  session.add(hubs.models.HubConfig(

      hub=hub, summary='The Fedora Design Team'))

  

- widget = hubs.models.Widget(plugin='contact', index=-1)

- hub.widgets.append(widget)

- widget = hubs.models.Widget(plugin='stats', index=0)

+ widget = hubs.models.Widget(plugin='contact', index=0)

  hub.widgets.append(widget)

  widget = hubs.models.Widget(

      plugin='rules', index=1, _config=json.dumps({
@@ -272,9 +263,7 @@ 

  session.add(hubs.models.HubConfig(

      hub=hub, summary='The Fedora Infra Team'))

  

- widget = hubs.models.Widget(plugin='contact', index=-1)

- hub.widgets.append(widget)

- widget = hubs.models.Widget(plugin='stats', index=0)

+ widget = hubs.models.Widget(plugin='contact', index=0)

  hub.widgets.append(widget)

  widget = hubs.models.Widget(

      plugin='rules', index=1, _config=json.dumps({

I recommend against leaving TODOs in the code - I suggest filing a ticket instead so it doesn't get forgotten.

For a second I thought this was Python and I was like "whoah I had no idea you could import this way!"

I recommend a docblock here that includes the type of the parameter and return value.

I recommend a docblock here as well.

Similar here - I recommend filing a ticket instead of a TODO.

If this code isn't needed, I recommend deleting it rather than commenting it.

LGTM!

P.S. If you have some time, I've got a lot of PRs that need review over at Bodhi: https://github.com/fedora-infra/bodhi/pulls

Thanks @bowlofeggs . I'll file the tickets instead of the todos and add the docblocks.

I'll go check out your PRs too :-)

rebased onto c3f1cbd

4 years ago

Pull-Request has been merged by abompard

4 years ago
Metadata
Changes Summary 34
+0 -2
file changed
hubs/default_config.py
+0 -6
file changed
hubs/defaults.py
+26 -13
file changed
hubs/models.py
+1 -1
file changed
hubs/static/client/app/components/HubConfig/HubConfig.css
+22 -12
file changed
hubs/static/client/app/components/HubHeader.js
+173
file added
hubs/static/client/app/components/HubMembership.js
+8
file added
hubs/static/client/app/components/HubStar.css
+77
file added
hubs/static/client/app/components/HubStar.js
+10
file added
hubs/static/client/app/components/HubStats.css
+69
file added
hubs/static/client/app/components/HubStats.js
+1 -0
file changed
hubs/static/client/app/components/LeftMenu.css
+1 -1
file changed
hubs/static/client/app/components/PageStructure.js
+7
file added
hubs/static/client/app/components/StateButton.css
+94
file added
hubs/static/client/app/components/StateButton.js
+63 -3
file changed
hubs/static/client/app/core/actions/hub.js
+11 -0
file changed
hubs/static/client/app/core/reducers/hub.js
+2 -1
file changed
hubs/static/client/app/core/reducers/index.js
+16
file added
hubs/static/client/app/core/reducers/user.js
+1 -1
file changed
hubs/static/client/app/widgets/irc/Widget.js
+0 -16
file changed
hubs/static/css/style.css
+12 -0
file changed
hubs/tests/test_models.py
+2 -2
file changed
hubs/tests/views/test_api_hub_widget.py
-31
file removed
hubs/tests/widgets/test_fedmsgstats.py
+11 -0
file changed
hubs/utils/views.py
+1 -0
file changed
hubs/views/api/__init__.py
+14
file added
hubs/views/api/hub.py
+39 -69
file changed
hubs/views/api/hub_association.py
+1 -1
file changed
hubs/views/api/hub_config.py
+3 -5
file changed
hubs/views/hub.py
-79
file removed
hubs/widgets/fedmsgstats/__init__.py
-23
file removed
hubs/widgets/fedmsgstats/templates/root.html
-62
file removed
hubs/widgets/stats/__init__.py
-74
file removed
hubs/widgets/stats/templates/root.html
+5 -16
file changed
populate.py