#338 Configuration panel for Hubs
Merged 7 years ago by abompard. Opened 7 years ago by abompard.
abompard/fedora-hubs hub-config  into  develop

file modified
+10
@@ -33,6 +33,16 @@ 

  #        the right tag to link people to.  AGPL ftw.

  SOURCE_URL = 'https://pagure.io/fedora-hubs/blob/develop/f'  # /hubs/widgets/badges.py'

  

+ CHAT_NETWORKS = [

+     {

+         "name": "Freenode",

+         "domain": "irc.freenode.net",

+     }, {

+         "name": "Matrix",

+         "domain": "matrix.org",

+     }

+ ]

+ 

  

  WIDGETS = [

      'hubs.widgets.about:About',

@@ -0,0 +1,85 @@ 

+ # This Alembic database migration is part of the Fedora Hubs project.

+ # Copyright (C) 2017  The Fedora Project

+ #

+ # This program is free software: you can redistribute it and/or modify

+ # it under the terms of the GNU Affero General Public License as published by

+ # the Free Software Foundation, either version 3 of the License, or

+ # (at your option) any later version.

+ #

+ # This program is distributed in the hope that it will be useful,

+ # but WITHOUT ANY WARRANTY; without even the implied warranty of

+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the

+ # GNU Affero General Public License for more details.

+ #

+ # You should have received a copy of the GNU Affero General Public License

+ # along with this program.  If not, see <http://www.gnu.org/licenses/>.

+ """

+ Hub config

+ 

+ Revision ID: 1d660b32b83a

+ Revises: 26cc11577009

+ Create Date: 2017-03-06 18:31:54.574205

+ """

+ 

+ from __future__ import absolute_import, unicode_literals

+ 

+ from alembic import op

+ import sqlalchemy as sa

+ 

+ 

+ # revision identifiers, used by Alembic.

+ revision = '1d660b32b83a'

+ down_revision = '26cc11577009'

+ branch_labels = None

+ depends_on = None

+ 

+ 

+ def upgrade():

+     op.create_table('hubs_config',

+         sa.Column('id', sa.Integer(), nullable=False),

+         sa.Column('hub_id', sa.String(length=50), nullable=False),

+         sa.Column('summary', sa.String(length=128), nullable=True),

+         sa.Column('left_width', sa.Integer(), nullable=False, server_default="8"),

+         sa.Column('avatar', sa.String(length=256), nullable=True),

+         sa.Column('header_img', sa.String(length=256), nullable=True),

+         sa.ForeignKeyConstraint(['hub_id'], ['hubs.name'], ),

+         sa.PrimaryKeyConstraint('id')

+     )

+     op.execute("""

+         INSERT INTO hubs_config

+             (hub_id, summary, left_width, avatar, header_img)

+         SELECT name, summary, left_width, avatar, header_img FROM hubs

+         """)

+     # http://alembic.zzzcomputing.com/en/latest/batch.html

+     with op.batch_alter_table("hubs") as batch_op:

Neat, I didn't know about this.

+         batch_op.drop_column('left_width')

+         batch_op.drop_column('header_img')

+         batch_op.drop_column('summary')

+         batch_op.drop_column('avatar')

+ 

+ 

+ def downgrade():

+     op.add_column('hubs', sa.Column('avatar', sa.VARCHAR(length=256), nullable=True))

+     op.add_column('hubs', sa.Column('summary', sa.VARCHAR(length=128), nullable=True))

+     op.add_column('hubs', sa.Column('header_img', sa.VARCHAR(length=256), nullable=True))

+     op.add_column('hubs', sa.Column('left_width', sa.INTEGER(), server_default="8", nullable=False))

+     connection = op.get_bind()

+     query = "SELECT hub_id, summary, left_width, avatar, header_img FROM hubs_config"

+     hubs_table = sa.sql.table('hubs',

+         sa.sql.column('name', sa.String),

+         sa.sql.column('summary', sa.String),

+         sa.sql.column('left_width', sa.Integer),

+         sa.sql.column('avatar', sa.String),

+         sa.sql.column('header_img', sa.String),

+     )

+     for row in connection.execute(query):

+         op.execute(hubs_table.update().where(

+             hubs_table.c.name==op.inline_literal(row["hub_id"])

+             ).values({

+                 "summary": op.inline_literal(row["summary"]),

+                 "left_width": op.inline_literal(row["left_width"]),

+                 "avatar": op.inline_literal(row["avatar"]),

+                 "header_img": op.inline_literal(row["header_img"]),

+             })

+         )

+     op.drop_table('hubs_config')

@@ -0,0 +1,48 @@ 

+ # This Alembic database migration is part of the Fedora Hubs project.

+ # Copyright (C) 2017  The Fedora Project

+ #

+ # This program is free software: you can redistribute it and/or modify

+ # it under the terms of the GNU Affero General Public License as published by

+ # the Free Software Foundation, either version 3 of the License, or

+ # (at your option) any later version.

+ #

+ # This program is distributed in the hope that it will be useful,

+ # but WITHOUT ANY WARRANTY; without even the implied warranty of

+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the

+ # GNU Affero General Public License for more details.

+ #

+ # You should have received a copy of the GNU Affero General Public License

+ # along with this program.  If not, see <http://www.gnu.org/licenses/>.

+ """

+ Hub configuration for the chat channel.

+ 

+ Revision ID: 6d4862ec4f93

+ Revises: 1d660b32b83a

+ Create Date: 2017-03-09 16:42:10.556935

+ """

+ 

+ from __future__ import absolute_import, unicode_literals

+ 

+ from alembic import op

+ import sqlalchemy as sa

+ 

+ 

+ # revision identifiers, used by Alembic.

+ revision = '6d4862ec4f93'

+ down_revision = '1d660b32b83a'

+ branch_labels = None

+ depends_on = None

+ 

+ 

+ def upgrade():

+     op.add_column('hubs_config', sa.Column(

+         'chat_channel', sa.String(length=256), nullable=True))

+     op.add_column('hubs_config', sa.Column(

+         'chat_domain', sa.String(length=256), nullable=True))

+ 

+ 

+ def downgrade():

+     # http://alembic.zzzcomputing.com/en/latest/batch.html

+     with op.batch_alter_table("hubs_config") as batch_op:

+         batch_op.drop_column('chat_domain')

+         batch_op.drop_column('chat_channel')

file modified
+40 -23
@@ -137,17 +137,11 @@ 

  class Hub(BASE):

      __tablename__ = 'hubs'

      name = sa.Column(sa.String(50), primary_key=True)

-     summary = sa.Column(sa.String(128))

      created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

      widgets = relation('Widget', cascade='all,delete', backref='hub')

-     left_width = sa.Column(sa.Integer, nullable=False, default=8)

+     config = relation('HubConfig', uselist=False, cascade='all,delete', backref='hub')

      archived = sa.Column(sa.Boolean, default=False)

      user_hub = sa.Column(sa.Boolean, default=False)

- 

-     # A URL to the "avatar" for this hub.

-     avatar = sa.Column(sa.String(256), default=placekitten)

-     header_img = sa.Column(sa.String(256), default=randomheader)

- 

      # Timestamps about various kinds of "freshness"

      last_refreshed = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

      last_edited = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)
@@ -244,10 +238,11 @@ 

  

      @classmethod

      def create_user_hub(cls, session, username, fullname):

-         hub = cls(name=username, summary=fullname,

-                   avatar=username2avatar(username),

-                   user_hub=True)

+         hub = cls(name=username, user_hub=True)

          session.add(hub)

+         hub_config = HubConfig(

+             hub=hub, summary=fullname, avatar=username2avatar(username))

+         session.add(hub_config)

  

          hubs.defaults.add_user_widgets(session, hub, username, fullname)

  
@@ -257,20 +252,17 @@ 

  

      @classmethod

      def create_group_hub(cls, session, name, summary, **extra):

-         hub = cls(name=name, summary=summary,

-                   # TODO -- do something else, smarter for group avatars

-                   avatar=username2avatar(name),

-                   user_hub=False)

+         hub = cls(name=name, user_hub=False)

          session.add(hub)

+         # TODO -- do something else, smarter for group avatars

+         hub_config = HubConfig(

+             hub=hub, summary=summary, avatar=username2avatar(name))

+         session.add(hub_config)

  

          hubs.defaults.add_group_widgets(session, hub, name, summary, **extra)

          return hub

  

      @property

-     def right_width(self):

-         return 12 - self.left_width

- 

-     @property

      def left_widgets(self):

          return sorted(

              [w for w in self.widgets
@@ -287,11 +279,9 @@ 

      def __json__(self):

          return {

              'name': self.name,

-             'summary': self.summary,

-             'avatar': self.avatar,

+             'config': self.config.__json__(),

  

              'widgets': [widget.idx for widget in self.widgets],

-             'left_width': self.left_width,

  

              'owners': [u.username for u in self.owners],

              'members': [u.username for u in self.members],
@@ -299,7 +289,34 @@ 

          }

  

  

- def _config_default(context):

+ class HubConfig(BASE):

+     __tablename__ = 'hubs_config'

+     id = sa.Column(sa.Integer, primary_key=True)

+     hub_id = sa.Column(sa.String(50), sa.ForeignKey('hubs.name'),

+                        nullable=False)

+     summary = sa.Column(sa.String(128))

+     left_width = sa.Column(sa.Integer, nullable=False, default=8)

+     # A URL to the "avatar" for this hub.

+     avatar = sa.Column(sa.String(256), default=placekitten)

+     header_img = sa.Column(sa.String(256), default=randomheader)

+     chat_channel = sa.Column(sa.String(256), nullable=True)

+     chat_domain = sa.Column(sa.String(256), nullable=True)

+ 

+     def __json__(self):

+         return {

+             'summary': self.summary,

+             'left_width': self.left_width,

+             'avatar': self.avatar,

+             'chat_channel': self.chat_channel,

+             'chat_domain': self.chat_domain,

+         }

+ 

+     @property

+     def right_width(self):

+         return 12 - self.left_width

+ 

+ 

+ def _widget_config_default(context):

      widget_name = context.current_parameters['plugin']

      widget = hubs.widgets.registry[widget_name]

      return json.dumps(dict([
@@ -313,7 +330,7 @@ 

      plugin = sa.Column(sa.String(50), nullable=False)

      created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

      hub_id = sa.Column(sa.String(50), sa.ForeignKey('hubs.name'))

-     _config = sa.Column(sa.String(256), default=_config_default)

+     _config = sa.Column(sa.String(256), default=_widget_config_default)

  

      index = sa.Column(sa.Integer, nullable=False)

      left = sa.Column(sa.Boolean, nullable=False, default=False)

@@ -0,0 +1,21 @@ 

+ import React from 'react';

+ 

+ 

+ export default class Tab extends React.Component {

+   render() {

+     return (

+       <li role="presentation">

+         <a href="#" role="tab"

+            className={"nav-link" + (this.props.active ? " active" : "")}

+            onClick={this.props.linkClicked}

+            >

+           {this.props.children}

+         </a>

+       </li>

+     );

+   }

+ }

+ 

+ 

+ 

+ // vim: set ts=2 sw=2 et:

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

+ import React from 'react';

+ 

+ 

+ export default class TabPanel extends React.Component {

+   render() {

+     return (

+       <div role="tabpanel"

+            className={"tab-pane" + (this.props.active ? " active" : "")}>

+         {this.props.children}

+       </div>

+     );

+   }

+ }

+ 

+ 

+ // vim: set ts=2 sw=2 et:

@@ -0,0 +1,60 @@ 

+ import React from 'react';

+ import Tab from './Tab.jsx';

+ import TabPanel from './TabPanel.jsx';

+ 

+ 

+ export default class TabSet extends React.Component {

+ 

+   constructor(props) {

+     super(props);

+     this.state = {

+       activeTab: 0

+     }

+   }

+ 

+   linkClicked(e, index) {

+     e.preventDefault();

+     this.setState({activeTab: index});

+   }

+ 

+   render() {

+     var tabLinks = this.props.children.map(function(panel, index) {

+       return (

+         <Tab active={this.state.activeTab === index}

+              linkClicked={(e) => this.linkClicked(e, index)}

+              key={index.toString()}>

+           {panel.props.tabTitle}

+         </Tab>

+         );

+     }.bind(this));

+ 

+     var tabPanels = this.props.children.map(function(panel, index) {

+       return (

+         <TabPanel

+           active={this.state.activeTab === index}

+           key={index.toString()}>

+           {panel}

+         </TabPanel>

+         );

+     }.bind(this));

+ 

+     return (

+       <div>

+         <ul role="tablist"

+             className={"nav nav-tabs " + this.props.tabListClass || ""}

+             >

+           {tabLinks}

+         </ul>

+         <div

+           className={"tab-content " + this.props.tabContentClass || ""}

+           >

+           {tabPanels}

+         </div>

+       </div>

+     );

+   }

+ 

+ }

+ 

+ 

+ // vim: set ts=2 sw=2 et:

@@ -0,0 +1,224 @@ 

+ import React from 'react';

+ import {

+   IntlProvider,

+   defineMessages,

+   FormattedMessage,

+   } from 'react-intl';

+ import {

+   GeneralPanel,

+   ChatPanel,

+   NotImplementedPanel,

+   } from './HubConfigPanel.jsx';

+ import TabSet from '../components/TabSet.jsx';

+ 

+ 

+ const messages = defineMessages({

+   title: {

+     id: "hubs.core.config.title",

+     defaultMessage: "Hub settings",

+   },

+   general: {

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

+     defaultMessage: "General",

+   },

+   owners: {

+     id: "hubs.core.config.owners",

+     defaultMessage: "Owners",

+   },

+   members: {

+     id: "hubs.core.config.members",

+     defaultMessage: "Members",

+   },

+   chat: {

+     id: "hubs.core.config.chat",

+     defaultMessage: "Chat",

+   },

+   other: {

+     id: "hubs.core.config.other",

+     defaultMessage: "Other",

+   },

+   close: {

+     id: "hubs.core.config.close",

+     defaultMessage: "Close",

+   },

+   save_changes: {

+     id: "hubs.core.config.save_changes",

+     defaultMessage: "Save changes",

+   },

+ });

+ 

+ 

+ 

+ export default class HubConfig extends React.Component {

+ 

+   constructor(props) {

+     super(props);

+     this.state = {

+       hubConfig: {},

+       generalConfig: {},

+       dirty: false,

+       mustReload: false,  // the settings were changed at least once

+       error: null,

+       loading: false

+     }

+     this.serverRequest = null;

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

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

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

+   }

+ 

+   getRequestParams() {

+     return {

+       url: this.props.url,

+       dataType: 'json',

+       cache: false,

+       success: function(data) {

+         this.setState({

+           hubConfig: data.result.hub,

+           generalConfig: data.result.general,

+           dirty: false,

+           error: null,

+           loading: false

+         });

+       }.bind(this),

+       error: function(xhr, status, err) {

+         console.error(this.props.url, status, err.toString());

+         this.setState({

+           error: err.toString(),

+           loading: false

+         });

+       }.bind(this)

+     }

+   }

+ 

+   loadState() {

+     var params = this.getRequestParams();

+     params.method = "GET";

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

+     this.serverRequest = $.ajax(params);

+   }

+ 

+   pushState() {

+     var params = this.getRequestParams();

+     params.method = "POST";

+     params.data = this.state.hubConfig;

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

+     this.serverRequest = $.ajax(params);

+   }

+ 

+   handleChange(e) {

+     var config = this.state.hubConfig;

+     config[e.target.name] = e.target.value;

+     this.setState({

+       hubConfig: config,

+       dirty: true,

+       mustReload: true,

+     }, function() {

+       window.clearTimeout(this.pushTimer);

+       this.pushTimer = window.setTimeout(this.pushState, 1000);

+     });

+   }

+ 

+   componentDidMount() {

+     this.loadState();

+   }

+ 

+   componentWillUnmount() {

+     if (this.serverRequest) { this.serverRequest.abort(); }

+     if (this.state.mustReload) {

+       // The configuration was changed but the current display may not

+       // reflect it (for example, the displayed summary may be the old one).

+       // The only way to make the changes visible is to reload the page.

+       window.location = window.location;

+     }

+   }

+ 

+   render() {

+     return (

+       <IntlProvider locale={navigator.language}>

+         <div className="modal-dialog modal-lg" role="document">

+           <div className="modal-content">

+             <form method="post" action={this.props.url}>

+               <div className="modal-header">

+                 <button

+                   type="button"

+                   className="close"

+                   data-dismiss="modal"

+                   aria-label="Close"

+                   disabled={this.state.dirty}

+                   >

+                   <span aria-hidden="true">&times;</span>

+                 </button>

+                 <h4 className="modal-title">

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

+                 </h4>

+               </div>

+               <div className="modal-body">

+                 <div className="row">

+                   <TabSet

+                     tabListClass="col-md-3"

+                     tabContentClass="col-md-9"

+                     >

+                     <GeneralPanel

+                       hubConfig={this.state.hubConfig}

+                       generalConfig={this.state.generalConfig}

+                       tabTitle={<FormattedMessage {...messages.general} />}

+                       handleChange={this.handleChange}

+                       />

+                     <NotImplementedPanel

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

+                       />

+                     <NotImplementedPanel

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

+                       />

+                     <ChatPanel

+                       hubConfig={this.state.hubConfig}

+                       generalConfig={this.state.generalConfig}

+                       tabTitle={<FormattedMessage {...messages.chat} />}

+                       handleChange={this.handleChange}

+                       />

+                     <NotImplementedPanel

+                       tabTitle={<FormattedMessage {...messages.other} />}

+                       />

+                   </TabSet>

+                 </div>

+               </div>

+               <div className="modal-footer">

+                 <span

+                   className="loading"

+                   style={this.state.loading ? {display: "inline-block"} : {display: "none"}}

+                   ></span>

+                 <button

+                   type="button"

+                   className="btn btn-default"

+                   data-dismiss="modal"

+                   disabled={this.state.dirty}

+                   >

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

+                 </button>

+                 { this.state.error &&

+                   <div

+                     className="alert alert-danger" role="alert"

+                     style={{

+                       margin: "0em 1em",

+                       //textAlign: "center",

+                       display: "inline-block",

+                       padding: "0.375rem",

+                       }}

+                     >

+                     { this.state.error }

+                   </div>

+                 }

+               </div>

+             </form>

+           </div>

+         </div>

+       </IntlProvider>

+     );

+   }

+ 

+ }

+ 

+ 

+ 

+ // vim: set ts=2 sw=2 et:

@@ -0,0 +1,190 @@ 

+ import React from 'react';

+ import {

+   IntlProvider,

+   defineMessages,

+   FormattedMessage,

+   } from 'react-intl';

+ import TabPanel from '../components/TabPanel.jsx';

+ 

+ 

+ const messages = defineMessages({

+   general_title: {

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

+     defaultMessage: "General Settings",

+   },

+   general_intro: {

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

+     defaultMessage: "Change your global hub parameters here.",

+   },

+   general_summary: {

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

+     defaultMessage: "Summary",

+   },

+   general_left_width: {

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

+     defaultMessage: "Left width",

+   },

+   general_avatar: {

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

+     defaultMessage: "Avatar",

+   },

+   chat_title: {

+     id: "hubs.core.config.chat.title",

+     defaultMessage: "Chat Settings",

+   },

+   chat_intro_1: {

+     id: "hubs.core.config.chat.intro1",

+     defaultMessage: "If your team or project has an IRC channel associated with it, you can connect this hub to it via the Hubs chat widget.",

+   },

+   chat_intro_2: {

+     id: "hubs.core.config.chat.intro2",

+     defaultMessage: "Please indicate your team's IRC channel below:",

+   },

+   chat_channel_name: {

+     id: "hubs.core.config.chat.channel_name",

+     defaultMessage: "Channel name",

+   },

+   chat_example: {

+     id: "hubs.core.config.chat.example",

+     defaultMessage: "Example:",

+   },

+   chat_network: {

+     id: "hubs.core.config.chat.network",

+     defaultMessage: "IRC network",

+   },

+ });

+ 

+ 

+ export class NotImplementedPanel extends React.Component {

+ 

+   render() {

+     return (

+       <div>

+         Not implemented yet.

+       </div>

+     );

+   }

+ 

+ }

+ 

+ 

+ export class GeneralPanel extends React.Component {

+ 

+   render() {

+     var stillLoading = (typeof this.props.hubConfig.summary === "undefined");

+     return (

+       <div>

+         <FormattedMessage {...messages.general_title} tagName="h3" />

+         <FormattedMessage {...messages.general_intro} tagName="p" />

+         <div className="form-group">

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

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

+           </label>

+           <input

+             type="text" className="form-control" name="summary"

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

+             disabled={stillLoading}

+             onChange={this.props.handleChange}

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

+             />

+         </div>

+         <div className="form-group">

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

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

+           </label>

+           <input

+             type="number" className="form-control" name="left_width"

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

+             disabled={stillLoading}

+             min="1" max="11"

+             onChange={this.props.handleChange}

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

+             />

+         </div>

+         <div className="form-group">

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

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

+           </label>

+           <input

+             type="text" className="form-control" name="avatar"

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

+             disabled={stillLoading}

+             onChange={this.props.handleChange}

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

+             />

+         </div>

+       </div>

+     );

+   }

+ 

+ }

+ 

+ 

+ export class ChatPanel extends React.Component {

+ 

+   render() {

+     var networks = [],

+         channel = "#",

+         stillLoading = (

+           typeof this.props.hubConfig.chat_channel === "undefined"

+         );

+ 

+     if (this.props.hubConfig.chat_channel) {

+       channel = "#" + this.props.hubConfig.chat_channel.replace(/^#*/, "");

+     }

+ 

+     if (this.props.generalConfig.chat_networks) {

+       networks = this.props.generalConfig.chat_networks.map(

+         function(network, index) {

+           return (

+             <option value={network.domain} key={network.domain}>

+               {network.name} - {network.domain}

+             </option>

+           );

+         }.bind(this)

+       );

+     }

+ 

+     return (

+       <div>

+         <FormattedMessage {...messages.chat_title} tagName="h3" />

+         <FormattedMessage {...messages.chat_intro_1} tagName="p" />

+         <FormattedMessage {...messages.chat_intro_2} tagName="p" />

+         <div className="form-group">

+           <label htmlFor="hub-settings-chat-channel">

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

+           </label>

+           <input

+             type="text" className="form-control" name="chat_channel"

+             id="hub-settings-chat-channel"

+             disabled={stillLoading}

+             onChange={this.props.handleChange}

+             value={channel}

+             />

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

+             <FormattedMessage {...messages.chat_example} tagName="strong" />

+             <span style={{marginLeft: "0.5em"}}>#fedora-devel</span>

+           </p>

+         </div>

+         <div className="form-group">

+           <label htmlFor="hub-settings-chat-domain">

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

+           </label>

+           <select

+             name="chat_domain" className="form-control"

+             id="hub-settings-chat-domain"

+             disabled={stillLoading}

+             onChange={this.props.handleChange}

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

+             >

+             {networks}

+           </select>

+         </div>

+       </div>

+     );

+   }

+ 

+ }

+ 

+ 

+ // vim: set ts=2 sw=2 et:

@@ -0,0 +1,3 @@ 

+ import HubConfig from './HubConfig.jsx';

+ 

+ module.exports = {HubConfig}

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

  

  const config = {

      entry: {

+         Hubs: path.join(PATHS.app, 'core', 'Hubs.js'),

          Feed: path.join(PATHS.app, 'widgets', 'feed', 'Feed.jsx'),

      },

      output: {

file modified
+64 -4
@@ -96,10 +96,6 @@ 

          padding-top: .5rem;

      }

  

-     header a, header a:hover, .header a:focus {

-       color: #ffffff;

-     }

- 

      header span.edit {

        margin: 5px 5px 0 0;

      }
@@ -135,6 +131,70 @@ 

        margin-top: 0px;

        margin-bottom: 0px;

      }

+ 

+ 

+   .loading {

+       width: 50px;

+       height: 10px;

+       background: url("../img/spinner.gif") no-repeat center center;

+       vertical-align: bottom;

+       display: none;  /* hidden by default */

+   }

+ 

+ /*

+  * Settings

+  */

+ 

+ #settings-modal .modal-title {

+   line-height: 1;

+ }

+ #settings-modal .modal-body {

+   padding: 0;

+ }

+ #settings-modal .modal-body > .row {

+   padding-top: 0;

+ }

+ #settings-modal .nav {

+   border: 0;

+   background-color: #eee;

+   padding: 1.5em 0 2.5em 2em;

+   min-height: 0;

+ }

+ #settings-modal .nav .nav-link {

+   border: 0;

+   border-radius: 7px 0 0 7px;

+   padding: .4em 1em;

+   margin: 0.5em 0;

+   outline: 0;

+ }

+ #settings-modal .modal-body h3 {

+   margin-bottom: 1em;

+ }

+ #settings-modal .modal-body label {

+   font-size: 115%;

+ }

+ #settings-modal .modal-body .tab-content {

+   padding: 1em;

+   padding-bottom: 2em;

+ }

+ 

+ /* Fixed vertical size to give the appearance of panels. Hackish. Find a CSS expert to do this properly. */

+ #settings-modal .nav {

+   min-height: 400px;

+   max-height: 400px;

+ }

+ #settings-modal .tab-content {

+   min-height: 400px;

+   max-height: 400px;

+   overflow-y: auto;

+ }

+ 

+ 

+ 

+ /*

+  * Widgets

+  */

+ 

      .widget-buttons {

        padding: .5rem;

      }

file modified
+45 -39
@@ -34,50 +34,39 @@ 

        <div class="row">

          

          <!-- Left side of header -->

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

+         <div class="col-md-{{ hub.config.left_width }}">

            {% if hub.user_hub %}

-           <img src="{{ hub.avatar }}" class="avatar" />

+           <img src="{{ hub.config.avatar }}" class="avatar" />

            <h2 class="user m-b-0">{{ hub.name }}</h2>

-           <h5 class="m-t-1 m-b-1">{{ hub.summary }}</h5>

+           <h5 class="m-t-1 m-b-1">{{ hub.config.summary }}</h5>

            <div class="clearfix"></div>

            {% else %}

            <h2 class="team">{{ hub.name }}</h2>

-           <h5 class="m-t-1 m-b-1">{{ hub.summary }}</h5>

+           <h5 class="m-t-1 m-b-1">{{ hub.config.summary }}</h5>

            {% endif %}

          </div>

          

          <!-- right side of header -->

-         <div class="col-md-3 header-right">

+         <div class="col-md-{{ hub.config.right_width }} header-right">

            {% if g.auth.logged_in and hub.is_admin(g.auth.user) %}

-           {% if not edit %}

-           <div class="dropdown edit pull-xs-right showpointer">

-             <span class="dropdown-toggle edit-this-page" type="button"

-                 id="dropdownMenu2" data-toggle="dropdown" aria-haspopup="true"

-                 aria-expanded="true">

-               <span><i class="fa fa-pencil" aria-hidden="true"></i></span> edit this page

-             </span>

-             <div class="dropdown-menu" aria-labelledby="dropdownMenu2">

-               <a class="dropdown-item" href="#">Customize Header</a>

-               <div class="dropdown-divider"></div>

-               <a class="dropdown-item"

-                 href="{{ url_for('hub_edit', name=hub.name) }}">

-                 Configure Hub

-               </a>

-               {% if not hub.user_hub %}

-               <div class="dropdown-divider"></div>

-                 <a class="dropdown-item" href="#">Manage Members</a>

-                 <div class="dropdown-divider"></div>

-                 <a class="dropdown-item" href="#">Archive Hub</a>

-               {% endif %}

-             </div>

-           </div>

-           {% else %}

-           <div id="save_edits_btn" class="pull-xs-right">

-             <span><i class="fa fa-download" aria-hidden="true"></i></span>

-             Save changes

+           <div style="text-align:center">

+             <button class="btn btn-secondary" data-toggle="modal" data-target="#settings-modal">

+               <i class="fa fa-cog" aria-hidden="true"></i>

+               hub settings

+             </button>

+             {% if not edit %}

+             <a href="{{ url_for('hub_edit', name=hub.name) }}" class="btn btn-secondary">

+               <i class="fa fa-pencil" aria-hidden="true"></i>

+               edit page layout

+             </a>

+             {% else %}

+             <button class="btn btn-primary" href="#" id="save_edits_btn">

+               <i class="fa fa-download" aria-hidden="true"></i>

+               Save changes

+             </button>

+             {% endif %}

            </div>

            {% endif %}

-           {% endif %}

          </div>

          

        </div>
@@ -112,7 +101,7 @@ 

    <div class="row">

      <div class="col-md-offset-2">

        <div class="row">

-         <div class="col-md-{{ hub.left_width }}">

+         <div class="col-md-{{ hub.config.left_width }}">

            <!--

              First, before the proper widgets.. a cobweb built-in widget.

              https://pagure.io/fedora-hubs/issue/133
@@ -158,7 +147,7 @@ 

            {% include "includes/left_widgets.html" %}

          </div>

  

-         <div class="col-md-{{ hub.right_width }}">

+         <div class="col-md-{{ hub.config.right_width }}">

            {% if edit %}

            <div id='add-widget-right' class='widget row'>

              <div class="card">
@@ -182,10 +171,10 @@ 

        

      

  </div>

- 

        

  

- 

+ <div id="settings-modal" class="modal fade" tabindex="-1" role="dialog">

+ </div>

  

  

  <div id="edit_modal" class="modal fade" role="dialog">
@@ -201,6 +190,8 @@ 

    url_for('static', filename='js/utils.js') }}"></script>

  <script type="text/javascript"

    src ="{{url_for('static', filename='js/build/common.js')}}"></script>

+ <script type="text/javascript"

+   src ="{{url_for('static', filename='js/build/Hubs.js')}}"></script>

  

  {% if edit %}

  <script type="text/javascript" src="{{
@@ -290,7 +281,8 @@ 

    });

  }

  

- $('#save_edits_btn').click(function() {

+ $('#save_edits_btn').click(function(e) {

+   e.preventDefault();

    var _r_indexes = [];

    var _r_widgets = [];

    $.each($('#right_widgets .widget'), function(i, el) {
@@ -367,12 +359,26 @@ 

    }

  }

  

+ function setup_settings(props) {

+   var modal = $('#settings-modal');

+   var settingsPanel = React.createElement(Hubs.HubConfig, props);

+   modal.on('show.bs.modal', function () {

+     ReactDOM.render(settingsPanel, modal.get(0));

+   })

+   modal.on('hidden.bs.modal', function () {

+     ReactDOM.unmountComponentAtNode(modal.get(0));

+   })

+ }

+ 

  $(function() {

    visit_counter()

-   

+ 

    setup_widgets();

    setup_edit_btns();

-   

+   setup_settings({

+     url: {{ url_for("hub_config", name=hub.name)|tojson }},

+   });

+ 

    {% if edit -%}

    make_widget_sortable();

    setup_add_btns();

file modified
+2 -2
@@ -61,8 +61,8 @@ 

    url_for('static', filename='js/jquery-1.10.2.min.js') }}"></script>

  <script type="text/javascript" src="{{

    url_for('static',filename='fedora-bootstrap/fedora-bootstrap.js')}}"></script>

- <script src="https://unpkg.com/react@15/dist/react.min.js"></script>

- <script src="https://unpkg.com/react-dom@15/dist/react-dom.min.js"></script>

+ <script src="https://unpkg.com/react@15/dist/react{% if not config.DEBUG %}.min{% endif %}.js"></script>

+ <script src="https://unpkg.com/react-dom@15/dist/react-dom{% if not config.DEBUG %}.min{% endif %}.js"></script>

  {% endblock %}

  

  </body>

file modified
+3 -1
@@ -65,12 +65,14 @@ 

          hub.widgets.append(widget)

  

          for team in ['i18n', 'infra', 'old']:

-             hub = hubs.models.Hub(name=team, summary="the %s team" % team)

+             hub = hubs.models.Hub(name=team)

              if hub.name == 'old':

                  hub.last_refreshed = datetime.utcnow() - timedelta(days=100)

                  hub.last_updated = datetime.utcnow() - timedelta(days=200)

  

              hubs.app.session.add(hub)

+             hubs.app.session.add(

+                 hubs.models.HubConfig(hub=hub, summary="the %s team" % team))

              widget = hubs.models.Widget(plugin='meetings', index=11,

                                          _config=json.dumps({'calendar': team}))

              hub.widgets.append(widget)

@@ -70,15 +70,19 @@ 

          # assert the status code of the response

          self.assertEqual(result.status_code, 200)

          data = {

-             "avatar": "https://seccdn.libravatar.org/avatar/"

-                       "9c9f7784935381befc302fe3c814f9136e7a339"

-                       "53d0318761669b8643f4df55c?s=312&d=retro",

-             "left_width": 8,

+             "config": {

+                 "avatar": "https://seccdn.libravatar.org/avatar/"

+                           "9c9f7784935381befc302fe3c814f9136e7a339"

+                           "53d0318761669b8643f4df55c?s=312&d=retro",

+                 'chat_channel': None,

+                 'chat_domain': None,

+                 "left_width": 8,

+                 "summary": "Ralph",

+             },

              "members": ["ralph"],

              "name": "ralph",

              "owners": ["ralph"],

              "subscribers": [],

-             "summary": "Ralph",

              "widgets": [34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 56]

          }

          self.assertDictEqual(data, json.loads(result.get_data(as_text=True)))
@@ -392,3 +396,85 @@ 

              url = '/source/notexistent'

              result = self.app.get(url)

              self.assertEqual(result.status_code, 404)

+ 

+     def test_hub_config_get(self):

+         expected = {

+             "hub": {

+                 "summary": "Ralph",

+                 "left_width": 8,

+                 "avatar": (

+                     "https://seccdn.libravatar.org/avatar/9c9f7784935381befc30"

+                     "2fe3c814f9136e7a33953d0318761669b8643f4df55c?s=312&d=retro"

+                     ),

+                 "chat_channel": None,

+                 "chat_domain": None,

+             },

+             "general": {

+                 "chat_networks": app.config["CHAT_NETWORKS"],

+             },

+         }

+         user = tests.FakeAuthorization('ralph')

+         with tests.auth_set(app, user):

+             url = '/ralph/config'

+             result = self.app.get(url)

+         self.assertEqual(result.status_code, 200)

+         result_data = json.loads(result.get_data(as_text=True))

+         self.assertEqual(result_data["status"], "OK")

+         self.assertDictEqual(result_data["result"], expected)

+ 

+     def test_hub_config_post(self):

+         user = tests.FakeAuthorization('ralph')

+         with tests.auth_set(app, user):

+             url = '/ralph/config'

+             result = self.app.post(url, data={

+                 "summary": "changed value",

+                 "chat_domain": "",

+             })

+         self.assertEqual(result.status_code, 200)

+         result_data = json.loads(result.get_data(as_text=True))

+         self.assertEqual(result_data["status"], "OK")

+         self.assertEqual(

+             result_data["result"]["hub"]["summary"],

+             "changed value")

+         self.assertEqual(

+             result_data["result"]["hub"]["chat_domain"],

+             app.config["CHAT_NETWORKS"][0]["domain"])

+ 

+     def test_hub_config_post_unknown_post_data(self):

+         # Unknown POST data is silently ignored

+         user = tests.FakeAuthorization('ralph')

+         with tests.auth_set(app, user):

+             url = '/ralph/config'

+             result = self.app.post(url, data={"non_existant": "dummy"})

+         self.assertEqual(result.status_code, 400)

+         result_data = json.loads(result.get_data(as_text=True))

+         self.assertEqual(result_data["status"], "ERROR")

+         self.assertEqual(result_data["message"], "Invalid value(s)")

+         self.assertIn("non_existant", result_data["fields"])

+         self.assertEqual(

+             result_data["fields"]["non_existant"],

+             "Unexpected parameter."

+             )

+ 

+     def test_hub_config_post_invalid_chat_domain(self):

+         user = tests.FakeAuthorization('ralph')

+         with tests.auth_set(app, user):

+             url = '/ralph/config'

+             result = self.app.post(url, data={"chat_domain": "dummy"})

+         self.assertEqual(result.status_code, 400)

+         result_data = json.loads(result.get_data(as_text=True))

+         self.assertEqual(result_data["status"], "ERROR")

+         self.assertEqual(result_data["message"], "Invalid value(s)")

+         self.assertIn("chat_domain", result_data["fields"])

+         self.assertEqual(

+             result_data["fields"]["chat_domain"],

+             "Unsupported chat domain."

+             )

+ 

+     @unittest.skip("Authorization layer not present yet")

+     def test_hub_config_unauthorized(self):

+         user = tests.FakeAuthorization('ralph')

+         with tests.auth_set(app, user):

+             url = '/decause/config'

+             result = self.app.post(url, data={"summary": "Defaced!"})

+         self.assertEqual(result.status_code, 403)

file modified
+50 -3
@@ -5,13 +5,13 @@ 

  import hubs.models

  

  from hubs.app import app, session

- from .utils import get_hub, login_required

+ from .utils import get_hub, login_required, RequestValidator

  

  

  @app.route('/<name>')

  @app.route('/<name>/')

  def hub(name):

-     hub = get_hub(name)

+     hub = get_hub(name, load_config=True)

      return flask.render_template(

          'hubs.html', hub=hub, edit=False)

  
@@ -35,7 +35,7 @@ 

  

  

  def hub_edit_get(name):

-     hub = get_hub(name)

+     hub = get_hub(name, load_config=True)

      return flask.render_template(

          'hubs.html', hub=hub, edit=True)

  
@@ -139,3 +139,50 @@ 

              return ('ok', 200)

      else:

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

+ 

+ 

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

+ @login_required

+ def hub_config(name):

+     hub = get_hub(name)

+     config = {

+         "status": "OK",

+         "result": {

+             "hub": {},

+             "general": {

+                 "chat_networks": app.config["CHAT_NETWORKS"],

+             },

+         },

+     }

+     if flask.request.method == 'POST':

+         # Validate values

+         def _validate_chat_domain(value):

+             valid_chat_domains = [

+                 network["domain"] for network in app.config["CHAT_NETWORKS"]

+                 ]

+             if not value and len(valid_chat_domains) > 0:

+                 value = valid_chat_domains[0]

+             if value not in valid_chat_domains:

+                 raise ValueError("Unsupported chat domain.")

+             return value

+         validator = RequestValidator(dict(

+             # Only allow the parameters listed in __json__().

+             (key, None) for key in hub.config.__json__().keys()

+         ))

+         validator.converters["chat_domain"] = _validate_chat_domain

+         try:

+             values = validator(flask.request.form)

+         except ValueError as e:

+             config = {"status": "ERROR", "message": "Invalid value(s)"}

+             config["fields"] = e.args[0]

+             return flask.jsonify(config), 400

+         # Now set the configuration values

+         for key, value in values.items():

+             setattr(hub.config, key, value)

+         try:

+             flask.g.db.commit()

+         except Exception as err:

+             config = {"status": "ERROR", "message": str(err)}

+             return flask.jsonify(config), 400

+     config["result"]["hub"].update(hub.config.__json__())

+     return flask.jsonify(config)

file modified
+89 -10
@@ -3,23 +3,26 @@ 

  import datetime

  import flask

  import functools

- import hubs.models

  import logging

  

+ from hubs.models import Hub, Widget

  from six.moves.urllib import parse as urlparse

+ from sqlalchemy.orm import joinedload

  from sqlalchemy.orm.exc import NoResultFound

  

  

  log = logging.getLogger(__name__)

  

  

- def get_hub(name, session=None):

+ def get_hub(name, load_config=False, session=None):

      """ Utility shorthand to get a hub and 404 if not found. """

      if session is None:

          session = flask.g.db

+     query = session.query(Hub).filter(Hub.name == name)

+     if load_config:

+         query = query.options(joinedload(Hub.config))

      try:

-         return session.query(hubs.models.Hub).filter(

-             hubs.models.Hub.name == name).one()

+         return query.one()

      except NoResultFound:

          flask.abort(404)

  
@@ -33,11 +36,9 @@ 

      except (TypeError, ValueError):

          flask.abort(400)

      try:

-         return session.query(

-                 hubs.models.Widget

-             ).join(hubs.models.Hub).filter(

-                 hubs.models.Hub.name == hub,

-                 hubs.models.Widget.idx == idx,

+         return session.query(Widget).join(Hub).filter(

+                 Hub.name == hub,

+                 Widget.idx == idx,

              ).one()

      except NoResultFound:

          flask.abort(404)
@@ -55,7 +56,7 @@ 

          widget (hubs.widgets.base.Widget): Widget to instanciate.

          position (str): either ``left`` or ``right``.

      """

-     widget_instance = hubs.models.Widget(

+     widget_instance = Widget(

          hub=hub, plugin=widget.name, index=-1,

          left=(position == 'left'))

      flask.g.db.add(widget_instance)
@@ -144,3 +145,81 @@ 

      if position not in ['right', 'left']:

          flask.abort(400, 'Invalid position provided')

      return position

+ 

+ 

+ class RequestValidator(object):

+     """Validate a request's POST data.

+ 

+     Instantiate this class with a dictionnary mapping POST data keys (found in

+     ``flask.request.form``) to a function that will validate and convert them.

+ 

+     POST data can be validated by calling the instance and passing the data as

+     an argument.  The instance will return a mapping of keys to converted

+     values if all validations are successful.

+ 

+     A validation function can raise a :py:exc:`ValueError` if the value is not

+     valid. In this case, the :py:class:`RequestValidator` instance will collect

+     the error messages in a dictionary mapping failed request keys to messages,

+     and raise a :py:exc:`ValueError` with this dictionary.

+ 

+     If the function is None, the value is returned unchanged. If a posted value

+     is not present in the conversion mapping the class was instantiated with,

+     an error will be raised.

+ 

+     A typical usage of this class is::

+ 

+         validator = RequestValidator({"number": int})

+         try:

+             values = validator(flask.request.form)

+         except ValueError as e:

+             failed_fields = e.args[0]

+             return flask.jsonify(failed_fields), 400

+         # Do something with the validated values.

+         for key, value in values.items():

+             setattr(model_instance, key, value)

+ 

+     In this example:

+ 

+     - if the posted data was ``number=1``, the resulting ``values`` variable

+       would be ``{"number": 1}``.

+     - if the posted data was ``number=foobar``, a :py:exc:`ValueError` would be

+       raised, ``failed_fields`` would be ``{"number": "error message"}``.

+     - if the posted data was ``foo=bar``, a :py:exc:`ValueError` would be

+       raised, ``failed_fields`` would be

+       ``{"foo": "message about unexpected key"}``.

+ 

+     Args:

+         converters (dict): a mapping from POST data keys to

+             conversion functions.

+     """

+ 

+     def __init__(self, converters):

+         self.converters = converters.copy()

+ 

+     def __call__(self, request_data):

+         """Convert the values in the given mapping.

+ 

+         Args:

+             request_data (dict): the values to validate and convert,

+                 usually ``flask.request.form``.

+ 

+         Raises:

+             ValueError: At least one value is invalid.

+         """

+         values = {}

+         errors = {}

+         for key, value in request_data.items():

+             if key not in self.converters:

+                 errors[key] = "Unexpected parameter."

+                 continue

+             if self.converters[key] is None:

+                 # Identity

+                 values[key] = value

+                 continue

+             try:

+                 values[key] = self.converters[key](value)

+             except (TypeError, ValueError) as e:

+                 errors[key] = e.args[0]

+         if errors:

+             raise ValueError(errors)

+         return values

file modified
+15 -6
@@ -36,9 +36,10 @@ 

  session.commit()

  

  ############## Internationalizationteam

- hub = hubs.models.Hub(

-     name='i18n', summary='The Internationalization Team', archived=True)

+ hub = hubs.models.Hub(name='i18n', archived=True)

  session.add(hub)

+ session.add(hubs.models.HubConfig(

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

  

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

  hub.widgets.append(widget)
@@ -82,8 +83,10 @@ 

  session.commit()

  

  ############## CommOps

- hub = hubs.models.Hub(name='commops', summary='The Fedora Community Operations Team')

+ hub = hubs.models.Hub(name='commops')

  session.add(hub)

+ 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)
@@ -129,8 +132,10 @@ 

  session.commit()

  

  ############## Marketing team

- hub = hubs.models.Hub(name='marketing', summary='The Fedora Marketing Team')

+ hub = hubs.models.Hub(name='marketing')

  session.add(hub)

+ session.add(hubs.models.HubConfig(

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

  

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

  hub.widgets.append(widget)
@@ -177,8 +182,10 @@ 

  session.commit()

  

  ############## Design team

- hub = hubs.models.Hub(name='designteam', summary='The Fedora Design Team')

+ hub = hubs.models.Hub(name='designteam')

  session.add(hub)

+ session.add(hubs.models.HubConfig(

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

  

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

  hub.widgets.append(widget)
@@ -226,8 +233,10 @@ 

  

  

  ############## Infra team

- hub = hubs.models.Hub(name='infrastructure', summary='The Fedora Infra Team')

+ hub = hubs.models.Hub(name='infrastructure')

  session.add(hub)

+ session.add(hubs.models.HubConfig(

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

  

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

  hub.widgets.append(widget)

These commits add the following features:

  • a table to hold hub-specific configuration values
  • a configuration panel to edit them
  • a configuration setting for the channel / network a team may be using

See issue #331 for the mockup. Again, commits are best read in order.

This is a pretty big pull request!

I don't know JS too well, but it all looks reasonable to me. I didn't see any tests, so the only suggestion I have is to make sure the new Python code has test coverage.

LGTM.

Oh, right, my view tests weren't committed and pushed, I'll do that ASAP.

Sorry for the big PR but it touches the core hub framework so I can't make it much smaller... I tried to keep "atomic" commits that can be understood by themselves.

About the new Alembic batch operation, it's Barry (core dev of Mailman 3) who contributed it so I've heard about it from there :)

Thanks for reviewing!

2 new commits added

  • Create a validator class for POST data
  • Test the config view
7 years ago

16 new commits added

  • Create a validator class for POST data
  • Test the config view
  • Don't use the minified version in DEBUG mode
  • Improve the display of the edit links
  • Reload the page when the config changed
  • Move the right_width property along with the config
  • Pass the props when instantiating the panel
  • Be more careful about what's stored in the DB
  • Auto-save the settings
  • Implement the General Settings panel
  • Add the chat configuration parameters
  • Implement the config panel backend
  • Implement the config panel UI
  • Prepare a React component for the hub config panel
  • Use eager loading for the hub config
  • Create a table for hub configuration
7 years ago

If just added tests and validation for the configuration view (last 2 commits). It's only Python this time, no JS ;-)

Pull-Request has been merged by abompard

7 years ago