From c3f1cbd5730264b9d95358364daef71ae10f0931 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Nov 09 2017 15:45:05 +0000 Subject: [PATCH 1/3] Integrate the stats widget in the hub header Fixes #428. --- diff --git a/hubs/models.py b/hubs/models.py index 55730c3..1b7645c 100644 --- a/hubs/models.py +++ b/hubs/models.py @@ -109,7 +109,7 @@ def init(db_url, debug=False, create=False): return session -roles = ['subscriber', 'member', 'owner', 'stargazer'] +ROLES = ['subscriber', 'member', 'owner', 'stargazer'] class Association(BASE): @@ -122,7 +122,7 @@ class Association(BASE): 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 @@ class Hub(ObjectAuthzMixin, BASE): 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 @@ class Hub(ObjectAuthzMixin, BASE): 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 @@ class Hub(ObjectAuthzMixin, BASE): 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): diff --git a/hubs/static/client/app/components/HubConfig/HubConfig.css b/hubs/static/client/app/components/HubConfig/HubConfig.css index d676968..07acdd8 100644 --- a/hubs/static/client/app/components/HubConfig/HubConfig.css +++ b/hubs/static/client/app/components/HubConfig/HubConfig.css @@ -1,5 +1,5 @@ .HubConfig { - display: inline; + display: inline-block; margin: 0.25rem 0.5rem; text-align: left; } diff --git a/hubs/static/client/app/components/HubHeader.js b/hubs/static/client/app/components/HubHeader.js index 1576200..9f52dfb 100644 --- a/hubs/static/client/app/components/HubHeader.js +++ b/hubs/static/client/app/components/HubHeader.js @@ -4,17 +4,13 @@ import { connect } from 'react-redux'; import Spinner from "./Spinner"; import HubConfig from './HubConfig'; import EditModeButton from './EditModeButton'; +import HubStats from './HubStats'; +import HubMembership from './HubMembership'; import "./HubHeader.css"; class HubHeader extends React.Component { - propTypes: { - hub: PropTypes.object.isRequired, - isLoading: PropTypes.bool, - currentUser: PropTypes.object, - } - render() { return (
@@ -24,6 +20,9 @@ class HubHeader extends React.Component { { this.props.hub.name &&
+ { this.props.hub.user_hub ?
}
- { this.props.currentUser.can_config_hub && -
- - -
- } +
+ + { this.props.currentUser.perms.config_hub && +
+ + +
+ } +
}
); } } +HubHeader.propTypes = { + hub: PropTypes.object.isRequired, + isLoading: PropTypes.bool, + currentUser: PropTypes.object, +} + const mapStateToProps = (state) => { diff --git a/hubs/static/client/app/components/HubMembership.js b/hubs/static/client/app/components/HubMembership.js new file mode 100644 index 0000000..9f18581 --- /dev/null +++ b/hubs/static/client/app/components/HubMembership.js @@ -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 = ( + + ); + } else { + secondButton = ( + + ); + } + } + return ( +
+ + {secondButton} +
+ ); + } +} +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 ( + + ); + } +} + + +class HubJoinButton extends React.Component { + render() { + return ( + + ); + } +} + + +class HubGiveUpAdminButton extends React.Component { + render() { + return ( + + ); + } +} diff --git a/hubs/static/client/app/components/HubStats.css b/hubs/static/client/app/components/HubStats.css new file mode 100644 index 0000000..d7d4611 --- /dev/null +++ b/hubs/static/client/app/components/HubStats.css @@ -0,0 +1,10 @@ +.HubStats { +} + +.HubStatsCounter .title { + font-size: 80%; +} +.HubStatsCounter .value { + font-size: 150%; + line-height: 1em; +} diff --git a/hubs/static/client/app/components/HubStats.js b/hubs/static/client/app/components/HubStats.js new file mode 100644 index 0000000..b5804c8 --- /dev/null +++ b/hubs/static/client/app/components/HubStats.js @@ -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 ( +
+ { !this.props.hub.user_hub && + + } + + { this.props.hub.user_hub && + + } +
+ ); + } +} +HubStats.propTypes = { + hub: PropTypes.object.isRequired, +} + + +class HubStatsCounter extends React.Component { + + render() { + return ( +
+
+ {this.props.title} +
+
+ {this.props.value} +
+
+ ); + } +} +HubStatsCounter.propTypes = { + title: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, +} diff --git a/hubs/static/client/app/components/StateButton.css b/hubs/static/client/app/components/StateButton.css new file mode 100644 index 0000000..e631f4b --- /dev/null +++ b/hubs/static/client/app/components/StateButton.css @@ -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; +} diff --git a/hubs/static/client/app/components/StateButton.js b/hubs/static/client/app/components/StateButton.js new file mode 100644 index 0000000..90ae389 --- /dev/null +++ b/hubs/static/client/app/components/StateButton.js @@ -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 ( + + ); + } +} +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, +} diff --git a/hubs/static/client/app/core/actions/hub.js b/hubs/static/client/app/core/actions/hub.js index bdb6600..e2ae971 100644 --- a/hubs/static/client/app/core/actions/hub.js +++ b/hubs/static/client/app/core/actions/hub.js @@ -55,10 +55,9 @@ function putHub(config, users) { } } -function putHubFailure(message) { +function putHubFailure() { return { type: HUB_PUT_FAILURE, - message } } @@ -75,7 +74,68 @@ export function saveHub(config, users) { }, 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) + ); + } +} diff --git a/hubs/static/client/app/core/reducers/hub.js b/hubs/static/client/app/core/reducers/hub.js index ab788ed..e9669c3 100644 --- a/hubs/static/client/app/core/reducers/hub.js +++ b/hubs/static/client/app/core/reducers/hub.js @@ -4,6 +4,9 @@ import { 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 @@ export function hubReducer( ) { switch (action.type) { case HUB_FETCH_REQUEST: + case HUB_ASSOC_REQUEST: return { ...state, isLoading: true @@ -29,6 +33,7 @@ export function hubReducer( old: {} }; case HUB_FETCH_FAILURE: + case HUB_ASSOC_FAILURE: return { ...state, isLoading: false @@ -50,6 +55,12 @@ export function hubReducer( config: state.old.config, users: state.old.users, }; + case HUB_ASSOC_SUCCESS: + return { + ...state, + users: action.users, + isLoading: false, + }; default: return state } diff --git a/hubs/static/client/app/core/reducers/index.js b/hubs/static/client/app/core/reducers/index.js index 3d4e9df..65ae9d2 100644 --- a/hubs/static/client/app/core/reducers/index.js +++ b/hubs/static/client/app/core/reducers/index.js @@ -11,6 +11,7 @@ import { widgetConfigDialogOpen, availableWidgetsReducer, } from "./widgets"; +import { userReducer } from "./user"; const entities = combineReducers({ @@ -39,7 +40,7 @@ const rootReducer = combineReducers({ sse: sseReducer, globalConfig: noop, urls: noop, - currentUser: noop, + currentUser: userReducer, ui, entities, }); diff --git a/hubs/static/client/app/core/reducers/user.js b/hubs/static/client/app/core/reducers/user.js new file mode 100644 index 0000000..ded75b1 --- /dev/null +++ b/hubs/static/client/app/core/reducers/user.js @@ -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 + } +} diff --git a/hubs/static/client/app/widgets/irc/Widget.js b/hubs/static/client/app/widgets/irc/Widget.js index eac253c..da75bab 100644 --- a/hubs/static/client/app/widgets/irc/Widget.js +++ b/hubs/static/client/app/widgets/irc/Widget.js @@ -13,7 +13,7 @@ class IRCWidget extends React.Component { 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 = ( diff --git a/hubs/tests/test_models.py b/hubs/tests/test_models.py index be7be21..87aec6e 100644 --- a/hubs/tests/test_models.py +++ b/hubs/tests/test_models.py @@ -7,6 +7,7 @@ from hubs.authz import AccessLevel class ModelTest(hubs.tests.APPTest): + def test_delete_user(self): # verify user exists username = 'ralph' @@ -235,3 +236,14 @@ class ModelTest(hubs.tests.APPTest): 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) diff --git a/hubs/utils/views.py b/hubs/utils/views.py index d95d8d7..073fe1d 100644 --- a/hubs/utils/views.py +++ b/hubs/utils/views.py @@ -236,6 +236,17 @@ def require_hub_access(action, url_param="name", json=False): 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. diff --git a/hubs/views/api/__init__.py b/hubs/views/api/__init__.py index 5375623..3dc4ef2 100644 --- a/hubs/views/api/__init__.py +++ b/hubs/views/api/__init__.py @@ -2,6 +2,7 @@ from __future__ import absolute_import # flake8: noqa +from .hub import * from .hub_association import * from .hub_config import * from .hub_widget import * diff --git a/hubs/views/api/hub.py b/hubs/views/api/hub.py new file mode 100644 index 0000000..5020bf3 --- /dev/null +++ b/hubs/views/api/hub.py @@ -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//', methods=['GET']) +@require_hub_access("view", json=True) +def api_hub(name): + hub = get_hub(name) + result = {"status": "OK", "data": hub.get_props()} + return flask.jsonify(result) diff --git a/hubs/views/api/hub_association.py b/hubs/views/api/hub_association.py index 9b5e8ba..04298b6 100644 --- a/hubs/views/api/hub_association.py +++ b/hubs/views/api/hub_association.py @@ -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//subscribe', methods=['POST']) -@login_required -def api_hub_subscribe(hub): +@app.route('/api/hubs//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//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//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//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//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//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) diff --git a/hubs/views/api/hub_config.py b/hubs/views/api/hub_config.py index 284c837..7da572c 100644 --- a/hubs/views/api/hub_config.py +++ b/hubs/views/api/hub_config.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import +from __future__ import absolute_import, unicode_literals import logging diff --git a/hubs/views/hub.py b/hubs/views/hub.py index 9dcd3d5..a6dbb1a 100644 --- a/hubs/views/hub.py +++ b/hubs/views/hub.py @@ -6,6 +6,7 @@ import hubs.models 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 @@ def hub(name): "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 From d94b1fc82c4d71676cedd6ed4a58557bc2b421e3 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Nov 09 2017 15:45:05 +0000 Subject: [PATCH 2/3] Remove the stats and fedmsgstats widgets --- diff --git a/hubs/default_config.py b/hubs/default_config.py index b0e805d..3c0ef00 100644 --- a/hubs/default_config.py +++ b/hubs/default_config.py @@ -58,7 +58,6 @@ WIDGETS = [ '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 @@ WIDGETS = [ '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', diff --git a/hubs/defaults.py b/hubs/defaults.py index dcdc237..c4e3539 100644 --- a/hubs/defaults.py +++ b/hubs/defaults.py @@ -34,12 +34,6 @@ def add_user_widgets(session, hub, username, fullname): 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, diff --git a/hubs/static/css/style.css b/hubs/static/css/style.css index 9257a3b..f821195 100644 --- a/hubs/static/css/style.css +++ b/hubs/static/css/style.css @@ -144,22 +144,6 @@ header span.edit { 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; diff --git a/hubs/tests/views/test_api_hub_widget.py b/hubs/tests/views/test_api_hub_widget.py index c5e5689..23bcb43 100644 --- a/hubs/tests/views/test_api_hub_widget.py +++ b/hubs/tests/views/test_api_hub_widget.py @@ -11,7 +11,7 @@ from hubs.tests import APPTest, FakeAuthorization, auth_set 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 @@ class TestAPIHubWidget(APPTest): 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') diff --git a/hubs/tests/widgets/test_fedmsgstats.py b/hubs/tests/widgets/test_fedmsgstats.py deleted file mode 100644 index 91778b4..0000000 --- a/hubs/tests/widgets/test_fedmsgstats.py +++ /dev/null @@ -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() diff --git a/hubs/widgets/fedmsgstats/__init__.py b/hubs/widgets/fedmsgstats/__init__.py deleted file mode 100644 index 62fe099..0000000 --- a/hubs/widgets/fedmsgstats/__init__.py +++ /dev/null @@ -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) diff --git a/hubs/widgets/fedmsgstats/templates/root.html b/hubs/widgets/fedmsgstats/templates/root.html deleted file mode 100644 index 73664d0..0000000 --- a/hubs/widgets/fedmsgstats/templates/root.html +++ /dev/null @@ -1,23 +0,0 @@ -
-
- - - -
subscriberssubscribed to
{{subscribers_text}}{{subscribed_text}}
-
-
- {% if session['nickname'] != username %} -
    - {% if session['nickname'] in subscribers %} -
  • - -
  • - {% else %} -
  • - -
  • - {% endif %} -
- {% endif %} -
-
diff --git a/hubs/widgets/stats/__init__.py b/hubs/widgets/stats/__init__.py deleted file mode 100644 index 589d0ce..0000000 --- a/hubs/widgets/stats/__init__.py +++ /dev/null @@ -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 diff --git a/hubs/widgets/stats/templates/root.html b/hubs/widgets/stats/templates/root.html deleted file mode 100644 index 22aa78c..0000000 --- a/hubs/widgets/stats/templates/root.html +++ /dev/null @@ -1,74 +0,0 @@ -
-
- - - - - - -
MembersSubscribers
{{members_text}}{{subscribers_text}}
-
-
-
    - {% if g.auth.nickname in subscribers %} -
  • -
    - -
    -
  • - {% else %} -
  • -
    - -
    -
  • - {% endif %} - - {% if g.auth.nickname in stargazers %} -
  • -
    - -
    -
  • - {% else %} -
  • -
    - -
    -
  • - {% endif %} - - {% if g.auth.nickname in members %} -
  • -
    - -
    -
  • - {% else %} -
  • -
    - -
    -
  • - {% endif %} -
-
-
diff --git a/populate.py b/populate.py index 1aa1c8b..f3cea45 100755 --- a/populate.py +++ b/populate.py @@ -17,7 +17,6 @@ session = hubs.models.init(fedmsg_config['hubs.sqlalchemy.uri'], True, True) # 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(hub) 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(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) -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(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) -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(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) -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(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) -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({ From 56b29c6815049dae62df7c36ee98b0c918b481f0 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Nov 09 2017 15:45:05 +0000 Subject: [PATCH 3/3] Add an icon to 'star' hubs --- diff --git a/hubs/static/client/app/components/HubHeader.js b/hubs/static/client/app/components/HubHeader.js index 9f52dfb..ed49c38 100644 --- a/hubs/static/client/app/components/HubHeader.js +++ b/hubs/static/client/app/components/HubHeader.js @@ -6,6 +6,7 @@ import HubConfig from './HubConfig'; import EditModeButton from './EditModeButton'; import HubStats from './HubStats'; import HubMembership from './HubMembership'; +import HubStar from './HubStar'; import "./HubHeader.css"; @@ -39,6 +40,7 @@ class HubHeader extends React.Component {
:
+

{this.props.hub.name}

diff --git a/hubs/static/client/app/components/HubStar.css b/hubs/static/client/app/components/HubStar.css new file mode 100644 index 0000000..320ea4e --- /dev/null +++ b/hubs/static/client/app/components/HubStar.css @@ -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 */ +} diff --git a/hubs/static/client/app/components/HubStar.js b/hubs/static/client/app/components/HubStar.js new file mode 100644 index 0000000..1274727 --- /dev/null +++ b/hubs/static/client/app/components/HubStar.js @@ -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 ( + + ); + } +} +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); diff --git a/hubs/static/client/app/components/LeftMenu.css b/hubs/static/client/app/components/LeftMenu.css index 054c5ce..3962997 100644 --- a/hubs/static/client/app/components/LeftMenu.css +++ b/hubs/static/client/app/components/LeftMenu.css @@ -6,6 +6,7 @@ @media (min-width: 992px) { .LeftMenu { position: absolute; + padding: 0.5rem 30px 0 0; } } diff --git a/hubs/static/client/app/components/PageStructure.js b/hubs/static/client/app/components/PageStructure.js index 645a3c1..5210e8f 100644 --- a/hubs/static/client/app/components/PageStructure.js +++ b/hubs/static/client/app/components/PageStructure.js @@ -9,7 +9,7 @@ export default class PageStructure extends React.Component { render() { return (
-
+