From 3b5afedc42a17e5b70bab82e68c77666d4555e1c Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 03 2018 10:59:57 +0000
Subject: [PATCH 1/20] Read the default Fedmsg config in the consumer
---
diff --git a/hubs/backend/consumer.py b/hubs/backend/consumer.py
index fa84545..080f919 100644
--- a/hubs/backend/consumer.py
+++ b/hubs/backend/consumer.py
@@ -2,13 +2,15 @@
from __future__ import unicode_literals
+import logging
+
import fedmsg.consumers
import fedmsg.meta
-
import retask.task
import retask.queue
-import logging
+from hubs.utils import get_fedmsg_config
+
log = logging.getLogger("hubs")
@@ -17,11 +19,16 @@ class CacheInvalidatorExtraordinaire(fedmsg.consumers.FedmsgConsumer):
config_key = 'hubs.consumer.enabled'
validate_signatures = False
- def __init__(self, *args, **kwargs):
+ def __init__(self, hub):
+ # Monkey-patch the hub config. I'm not very proud of that one but I
+ # didn't find any other way to tell Fedmsg to read my default config.
+ fedmsg_config = get_fedmsg_config()
+ hub.config = fedmsg_config
+
log.debug("CacheInvalidatorExtraordinaire initializing")
- super(CacheInvalidatorExtraordinaire, self).__init__(*args, **kwargs)
+ super(CacheInvalidatorExtraordinaire, self).__init__(hub)
- queue_name = self.hub.config['hubs.redis.triage-queue-name']
+ queue_name = hub.config['hubs.redis.triage-queue-name']
self.queue = retask.queue.Queue(queue_name)
log.debug("CacheInvalidatorExtraordinaire initialized")
From b827687ef645239ae974e6b93cf7e179c0c3b9f7 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 03 2018 10:59:58 +0000
Subject: [PATCH 2/20] Convert the user_hub attribute to a string enum
---
diff --git a/hubs/feed.py b/hubs/feed.py
index cda0ada..03b4ab3 100644
--- a/hubs/feed.py
+++ b/hubs/feed.py
@@ -82,7 +82,7 @@ def get_hubs_for_msg(msg):
def _get_group_hub_names_by_config(*args):
query = Hub.query.filter(
- Hub.user_hub == False, # noqa:E712
+ Hub.hub_type == "team",
).join(HubConfig).filter(*args)
return [result[0] for result in query.values(Hub.name)]
diff --git a/hubs/models/constants.py b/hubs/models/constants.py
index ace2cb4..890f732 100644
--- a/hubs/models/constants.py
+++ b/hubs/models/constants.py
@@ -23,6 +23,8 @@
from __future__ import unicode_literals
+HUB_TYPES = ("user", "team")
+
ROLES = ('subscriber', 'member', 'owner', 'stargazer')
VISIBILITIES = ("public", "preview", "private")
diff --git a/hubs/models/hub.py b/hubs/models/hub.py
index ea0d49b..8097f60 100644
--- a/hubs/models/hub.py
+++ b/hubs/models/hub.py
@@ -36,7 +36,7 @@ from hubs.database import BASE, Session
from hubs.utils import username2avatar
from hubs.signals import hub_created
from .association import Association
-from .constants import ROLES
+from .constants import ROLES, HUB_TYPES
from .hubconfig import HubConfigProxy
from .user import User
@@ -49,10 +49,11 @@ class Hub(ObjectAuthzMixin, BASE):
__tablename__ = 'hubs'
name = sa.Column(sa.String(50), primary_key=True)
+ hub_type = sa.Column(
+ sa.Enum(*HUB_TYPES, name="hub_types"), nullable=False)
created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)
widgets = relation('Widget', cascade='all,delete', backref='hub',
order_by="Widget.index")
- user_hub = sa.Column(sa.Boolean, default=False)
# 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)
@@ -150,16 +151,16 @@ class Hub(ObjectAuthzMixin, BASE):
@classmethod
def all_group_hubs(cls):
- return cls.query.filter_by(user_hub=False).all()
+ return cls.query.filter_by(hub_type="team").all()
@classmethod
def all_user_hubs(cls):
- return cls.query.filter_by(user_hub=True).all()
+ return cls.query.filter_by(hub_type="user").all()
@classmethod
def create_user_hub(cls, username, fullname):
session = Session()
- hub = cls(name=username, user_hub=True)
+ hub = cls(name=username, hub_type="user")
session.add(hub)
hub.config["summary"] = fullname
hub.config["avatar"] = username2avatar(username)
@@ -170,7 +171,7 @@ class Hub(ObjectAuthzMixin, BASE):
@classmethod
def create_group_hub(cls, name, summary, **extra):
session = Session()
- hub = cls(name=name, user_hub=False)
+ hub = cls(name=name, hub_type="team")
session.add(hub)
hub.config["summary"] = summary
if extra.get("irc_channel") and extra.get("irc_network"):
@@ -183,11 +184,11 @@ class Hub(ObjectAuthzMixin, BASE):
return hub
def on_created(self, **extra):
- if self.user_hub:
+ if self.hub_type == "user":
hubs.defaults.add_user_widgets(self)
user = User.query.get(self.name)
self.subscribe(user, role='owner')
- else:
+ elif self.hub_type == "team":
hubs.defaults.add_group_widgets(self, **extra)
def on_updated(self, old_config):
@@ -220,7 +221,7 @@ class Hub(ObjectAuthzMixin, BASE):
def _get_auth_user_access_level(self, user):
# overridden to handle user hubs.
- if self.user_hub and user.username == self.name:
+ if self.hub_type == "user" and user.username == self.name:
return AccessLevel.owner
return super(Hub, self)._get_auth_user_access_level(user)
@@ -259,13 +260,13 @@ class Hub(ObjectAuthzMixin, BASE):
"config": self.config.to_dict(),
"users": {role: [] for role in ROLES},
"mtime": self.last_refreshed,
- "user_hub": self.user_hub,
+ "type": self.hub_type,
}
for assoc in sorted(self.associations, key=lambda a: a.user.username):
if assoc.role not in ROLES:
continue
result["users"][assoc.role].append(assoc.user.__json__())
- if self.user_hub:
+ if self.hub_type == "user":
user = User.query.get(self.name)
if user is None:
result["subscribed_to"] = []
diff --git a/hubs/static/client/app/components/HubConfig/HubConfigDialog.js b/hubs/static/client/app/components/HubConfig/HubConfigDialog.js
index fb9e0a8..498a0cf 100644
--- a/hubs/static/client/app/components/HubConfig/HubConfigDialog.js
+++ b/hubs/static/client/app/components/HubConfig/HubConfigDialog.js
@@ -99,6 +99,78 @@ export default class HubConfigDialog extends React.Component {
);
+ let tabs = [
+ }
+ handleChange={this.props.onConfigChange}
+ hub={this.props.hub}
+ />
+ ]
+ if (this.props.hub.type === "team") {
+ tabs.push(
+ }
+ urls={this.props.urls}
+ currentUser={this.props.currentUser}
+ />
+ ,
+ }
+ urls={this.props.urls}
+ currentUser={this.props.currentUser}
+ />
+ ,
+ }
+ handleChange={this.props.onConfigChange}
+ error={this.props.hub.error}
+ />
+ ,
+ }
+ handleChange={this.props.onConfigChange}
+ error={this.props.hub.error}
+ />
+ ,
+ }
+ handleChange={this.props.onConfigChange}
+ error={this.props.hub.error}
+ />
+ ,
+ }
+ handleChange={this.props.onConfigListChange}
+ error={this.props.hub.error}
+ />
+ );
+ }
+
return (
- }
- handleChange={this.props.onConfigChange}
- hub={this.props.hub}
- />
- {!this.props.hub.user_hub &&
- }
- urls={this.props.urls}
- currentUser={this.props.currentUser}
- />
- }
- {!this.props.hub.user_hub &&
- }
- urls={this.props.urls}
- currentUser={this.props.currentUser}
- />
- }
- {!this.props.hub.user_hub &&
- }
- handleChange={this.props.onConfigChange}
- error={this.props.hub.error}
- />
- }
- {!this.props.hub.user_hub &&
- }
- handleChange={this.props.onConfigChange}
- error={this.props.hub.error}
- />
- }
- {!this.props.hub.user_hub &&
- }
- handleChange={this.props.onConfigChange}
- error={this.props.hub.error}
- />
- }
- {!this.props.hub.user_hub &&
- }
- handleChange={this.props.onConfigListChange}
- error={this.props.hub.error}
- />
- }
+ {tabs}
{/*}
/>*/}
diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js
index 76232e8..9bcd6df 100644
--- a/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js
+++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js
@@ -133,7 +133,7 @@ export default class GeneralPanel extends React.Component {
/>
- {!this.props.hub.user_hub &&
+ {(this.props.hub.type === "team") &&
}
- {!this.props.hub.user_hub &&
+ {(this.props.hub.type === "team") &&
diff --git a/hubs/widgets/view.py b/hubs/widgets/view.py
index 86dd615..6c72296 100644
--- a/hubs/widgets/view.py
+++ b/hubs/widgets/view.py
@@ -29,9 +29,8 @@ class WidgetView(View):
name as ``_``, for example ``meetings_root``.
Remember that when you want to reverse the URL with :py:meth:`url_for`.
- When reversing the URL, you need to pass the ``hub`` and ``idx`` kwargs,
- which are respectively the hub name (:py:attr:`hubs.models.Hub.name`) and
- the widget instance (the database record) primary key
+ When reversing the URL, you need to pass the ``idx`` kwarg,
+ which is the widget instance (the database record) primary key
(:py:attr:`hubs.models.Widget.idx`).
Attributes:
@@ -95,9 +94,8 @@ class WidgetView(View):
def _get_instance(self, *args, **kwargs):
from hubs.utils.views import get_widget_instance
- hubname = kwargs.pop("hub")
widgetidx = kwargs.pop("idx")
- return get_widget_instance(hubname, widgetidx)
+ return get_widget_instance(widgetidx)
def dispatch_request(self, *args, **kwargs):
"""
diff --git a/populate-from-fas.py b/populate-from-fas.py
index be75f6f..f375030 100755
--- a/populate-from-fas.py
+++ b/populate-from-fas.py
@@ -80,7 +80,7 @@ for letter in reversed(sorted(list(set(string.letters.lower())))):
if any([name.endswith(suffix) for suffix in suffix_blacklist]):
continue
- hub = hubs.models.Hub.get(name)
+ hub = hubs.models.Hub.by_name(name, "team")
if hub is None:
hub = hubs.models.Hub.create_group_hub(
name=name,
diff --git a/populate.py b/populate.py
index e1eae97..ba8e517 100755
--- a/populate.py
+++ b/populate.py
@@ -39,7 +39,7 @@ for username in users:
db.commit()
# ############# Internationalizationteam
-hub = hubs.models.Hub(name='i18n')
+hub = hubs.models.Hub(name='i18n', hub_type="team")
db.add(hub)
hub.config.update(dict(
summary='The Internationalization Team',
@@ -95,7 +95,7 @@ hub.subscribe(hubs.models.User.by_username('ralph'), 'subscriber')
db.commit()
# ############# CommOps
-hub = hubs.models.Hub(name='commops')
+hub = hubs.models.Hub(name='commops', hub_type="team")
db.add(hub)
hub.config["summary"] = 'The Fedora Community Operations Team'
@@ -144,7 +144,7 @@ hub.subscribe(hubs.models.User.by_username('linuxmodder'), 'member')
db.commit()
# ############# Marketing team
-hub = hubs.models.Hub(name='marketing')
+hub = hubs.models.Hub(name='marketing', hub_type="team")
db.add(hub)
hub.config["summary"] = 'The Fedora Marketing Team'
@@ -200,7 +200,7 @@ hub.subscribe(hubs.models.User.by_username('ralph'), 'subscriber')
db.commit()
# ############# Design team
-hub = hubs.models.Hub(name='designteam')
+hub = hubs.models.Hub(name='designteam', hub_type="team")
db.add(hub)
hub.config["summary"] = 'The Fedora Design Team'
@@ -253,7 +253,7 @@ db.commit()
# ############# Infra team
-hub = hubs.models.Hub(name='infrastructure')
+hub = hubs.models.Hub(name='infrastructure', hub_type="team")
db.add(hub)
hub.config["summary"] = 'The Fedora Infra Team'
From dead78b8e8a5527c6fb97336f7c55e740da98bcb Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 03 2018 10:59:58 +0000
Subject: [PATCH 4/20] Use the hub id in the backend and the feed
---
diff --git a/hubs/backend/triage.py b/hubs/backend/triage.py
index b9dca54..56d9442 100755
--- a/hubs/backend/triage.py
+++ b/hubs/backend/triage.py
@@ -76,7 +76,7 @@ def triage(msg):
yield retask.task.Task(json.dumps({
'type': 'widget-cache',
'idx': widget.idx,
- 'hub': widget.hub.name,
+ 'hub': widget.hub.id,
'fn_name': fn_name,
'msg_id': msg['msg_id'],
}))
diff --git a/hubs/feed.py b/hubs/feed.py
index 83a2252..8539feb 100644
--- a/hubs/feed.py
+++ b/hubs/feed.py
@@ -26,15 +26,16 @@ def get_hubs_for_msg(msg):
if user is None:
log.debug("Message concerning an unknown user: %s", username)
continue
- if Hub.by_name(username, "user") is None:
+ user_hub = Hub.by_name(username, "user")
+ if user_hub is None:
log.debug("User exists but has no personal hub: %s", username)
continue
- hubs.append(username)
+ hubs.append(user_hub.id)
# Group hubs
if ".meetbot.meeting." in msg["topic"]:
# Chat
- hubs.extend(_get_group_hub_names_by_config(
+ hubs.extend(_get_group_hub_ids_by_config(
HubConfig.key == "chat_channel",
HubConfig.value == msg["msg"]["channel"],
))
@@ -45,13 +46,13 @@ def get_hubs_for_msg(msg):
# this metadata:
# list_address = msg["msg"]["mlist"]["fqdn_listname"]
# in the meantime, we have to use a LIKE (which is very slow)
- hubs.extend(_get_group_hub_names_by_config(
+ hubs.extend(_get_group_hub_ids_by_config(
HubConfig.key == "mailing_list",
HubConfig.value.like("{}@%".format(list_name)),
))
elif ".fedocal." in msg["topic"]:
# Calendar
- hubs.extend(_get_group_hub_names_by_config(
+ hubs.extend(_get_group_hub_ids_by_config(
HubConfig.key == "calendar",
HubConfig.value == msg["msg"]["calendar"]["calendar_name"],
))
@@ -62,7 +63,7 @@ def get_hubs_for_msg(msg):
except KeyError:
pass
else:
- hubs.extend(_get_group_hub_names_by_config(
+ hubs.extend(_get_group_hub_ids_by_config(
HubConfig.key == "pagure",
HubConfig.value == project_name,
))
@@ -73,18 +74,18 @@ def get_hubs_for_msg(msg):
except KeyError:
pass
else:
- hubs.extend(_get_group_hub_names_by_config(
+ hubs.extend(_get_group_hub_ids_by_config(
HubConfig.key == "github",
HubConfig.value == project_name,
))
return hubs
-def _get_group_hub_names_by_config(*args):
+def _get_group_hub_ids_by_config(*args):
query = Hub.query.filter(
Hub.hub_type == "team",
).join(HubConfig).filter(*args)
- return [result[0] for result in query.values(Hub.name)]
+ return [result[0] for result in query.values(Hub.id)]
def on_new_notification(msg):
@@ -95,19 +96,18 @@ def on_new_notification(msg):
if user is None:
log.debug("Notification for an unknown user: %s", username)
return
- # Users may exists without their hub if they have never logged
- # in but are just added to the members list. Don't check that
- # the user Hub actually exists, it will be created when the user
- # logs in, and this way the feed will be already populated.
+ stream = Hub.by_name(username, "stream")
+ if stream is None:
+ return
log.debug("Received a notification concerning %s", username)
- feed = Notifications(username)
+ feed = Notifications(stream.id)
feed.add(msg)
def on_new_message(msg):
- for hub_name in msg["_hubs"]:
- log.debug("Received a feed item for hub %s", hub_name)
- feed = Activity(hub_name)
+ for hub_id in msg["_hubs"]:
+ log.debug("Received a feed item for hub %s", hub_id)
+ feed = Activity(hub_id)
feed.add(msg)
@@ -184,15 +184,15 @@ class Feed(object):
max_items = 100
msgtype = None
- def __init__(self, owner):
+ def __init__(self, hub_id):
"""
Args:
- owner (str): User name or Hub name.
+ hub_id (int): Hub primary key in the SQL database.
"""
if self.msgtype is None:
raise NotImplementedError(
"You must subclass Feed and set self.msgtype.")
- self.owner = owner
+ self.hub_id = hub_id
self.db = None
fedmsg_config = get_fedmsg_config()
self.db_config = {
@@ -229,7 +229,9 @@ class Feed(object):
try:
client = pymongo.MongoClient(self.db_config["url"])
database = client[self.db_config["db"]]
- collection_name = "|".join(["feed", self.msgtype, self.owner])
+ collection_name = "|".join([
+ "feed", self.msgtype, str(self.hub_id)
+ ])
existing = database.collection_names(
include_system_collections=False)
if collection_name in existing:
diff --git a/hubs/tests/test_feed.py b/hubs/tests/test_feed.py
index e739786..532c56a 100644
--- a/hubs/tests/test_feed.py
+++ b/hubs/tests/test_feed.py
@@ -34,8 +34,8 @@ class FeedTest(APPTest):
database.collection_names.return_value = []
database.create_collection.return_value = db = object()
client.__getitem__.return_value = database
- db_name = "feed|%s|testuser" % msgtype
- feed = feed_class("testuser")
+ db_name = "feed|%s|42" % msgtype
+ feed = feed_class(42)
feed.connect()
pymongo_mock.MongoClient.assert_called_with(None)
client.__getitem__.assert_called_with("hubs")
@@ -46,7 +46,7 @@ class FeedTest(APPTest):
def test_get(self):
for feed_class, msgtype in self.feed_classes:
- feed = feed_class("testuser")
+ feed = feed_class(42)
feed.db = Mock()
feed.db.find.return_value = []
feed.get()
@@ -61,7 +61,7 @@ class FeedTest(APPTest):
"testkey": "testvalue",
}
for feed_class, msgtype in self.feed_classes:
- feed = feed_class("testuser")
+ feed = feed_class(42)
feed.db = Mock()
feed.add(msg)
feed.db.insert_one.assert_called_once()
@@ -74,15 +74,15 @@ class FeedTest(APPTest):
def test_length(self):
for feed_class, msgtype in self.feed_classes:
- feed = feed_class("testuser")
+ feed = feed_class(42)
feed.db = Mock()
- feed.db.count.return_value = 42
- self.assertEqual(feed.length(), 42)
+ feed.db.count.return_value = 4200
+ self.assertEqual(feed.length(), 4200)
feed.db.count.assert_called_once_with()
def test_close(self):
for feed_class, msgtype in self.feed_classes:
- feed = feed_class("testuser")
+ feed = feed_class(42)
db_mock = feed.db = Mock()
feed.close()
db_mock.database.client.close.assert_called_once()
@@ -90,12 +90,12 @@ class FeedTest(APPTest):
@patch("hubs.feed.Activity")
def test_on_new_message(self, mock_activity):
- msg = {"_hubs": ["testhub1", "testhub2"]}
+ msg = {"_hubs": [42, 43]}
feed = Mock()
mock_activity.return_value = feed
on_new_message(msg)
call_args = [c[0] for c in mock_activity.call_args_list]
- self.assertEqual(call_args, [("testhub1", ), ("testhub2", )])
+ self.assertEqual(call_args, [(42, ), (43, )])
self.assertEqual(feed.add.call_count, 2)
feed.add.assert_called_with(msg)
@@ -178,8 +178,9 @@ class GetHubsForMsgTestCase(APPTest):
def test_user_hub_owner(self):
self.msg2usernames.return_value = ["ralph"]
+ hub = Hub.by_name("ralph", "hub")
self.assertListEqual(
- get_hubs_for_msg(self.dummy_msg), ["ralph"])
+ get_hubs_for_msg(self.dummy_msg), [hub.id])
def test_group_hub_owner(self):
# Don't send a message to a group hub just because the user is the
@@ -188,17 +189,17 @@ class GetHubsForMsgTestCase(APPTest):
infra = Hub.by_name("infra", "team")
self.session.add(Association(hub=infra, user=ralph, role="owner"))
self.msg2usernames.return_value = ["ralph"]
- self.assertListEqual(
- get_hubs_for_msg(self.dummy_msg), ["ralph"])
+ self.assertNotIn(infra.id, get_hubs_for_msg(self.dummy_msg))
def test_group_hub_member(self):
+ # Don't send a message to a group hub just because the user is a
+ # member.
ralph = User.query.get("ralph")
test_hub = Hub(name="testhub", hub_type="team")
self.session.add(test_hub)
self.session.add(Association(hub=test_hub, user=ralph, role="member"))
self.msg2usernames.return_value = ["ralph"]
- self.assertListEqual(
- get_hubs_for_msg(self.dummy_msg), ["ralph"])
+ self.assertNotIn(test_hub.id, get_hubs_for_msg(self.dummy_msg))
def test_group_hub_irc(self):
test_hub = Hub(name="testhub", hub_type="team")
@@ -237,7 +238,7 @@ class GetHubsForMsgTestCase(APPTest):
},
}]
for msg in messages:
- self.assertListEqual(get_hubs_for_msg(msg), ["testhub"])
+ self.assertListEqual(get_hubs_for_msg(msg), [test_hub.id])
def test_group_hub_mailinglist(self):
test_hub = Hub(name="testhub", hub_type="team")
@@ -250,7 +251,7 @@ class GetHubsForMsgTestCase(APPTest):
"mlist": {"list_name": "testlist"},
},
}
- self.assertListEqual(get_hubs_for_msg(msg), ["testhub"])
+ self.assertListEqual(get_hubs_for_msg(msg), [test_hub.id])
def test_group_hub_calendar(self):
test_hub = Hub(name="testhub", hub_type="team")
@@ -269,7 +270,7 @@ class GetHubsForMsgTestCase(APPTest):
"calendar": {"calendar_name": "testcal"},
},
}
- self.assertListEqual(get_hubs_for_msg(msg), ["testhub"])
+ self.assertListEqual(get_hubs_for_msg(msg), [test_hub.id])
def test_group_hub_pagure(self):
test_hub = Hub(name="testhub", hub_type="team")
@@ -312,7 +313,7 @@ class GetHubsForMsgTestCase(APPTest):
}
self.assertListEqual(get_hubs_for_msg(msg), expected)
for project in projects_ok:
- _do_test_project(project, ["testhub"])
+ _do_test_project(project, [test_hub.id])
for project in projects_fail:
_do_test_project(project, [])
# Handle new and/or differently formatted messages
@@ -346,7 +347,7 @@ class GetHubsForMsgTestCase(APPTest):
}
self.assertListEqual(get_hubs_for_msg(msg), expected)
for project in projects_ok:
- _do_test_project(project, ["testhub"])
+ _do_test_project(project, [test_hub.id])
for project in projects_fail:
_do_test_project(project, [])
msg = {
diff --git a/hubs/views/hub.py b/hubs/views/hub.py
index 8e582fb..b8ae080 100644
--- a/hubs/views/hub.py
+++ b/hubs/views/hub.py
@@ -23,7 +23,7 @@ def hub(hub_type, hub_name):
urls = {
"widgets": flask.url_for("api_hub_widgets", hub_id=hub.id),
"availableWidgets": flask.url_for("api_widgets", hub_id=hub.id),
- "sse": get_sse_url("hub/{}".format(hub.name)),
+ "sse": get_sse_url("hub/{}".format(hub.id)),
"hub": flask.url_for("api_hub", hub_id=hub.id),
"hubConfig": flask.url_for("api_hub_config", hub_id=hub.id),
"hubConfigSuggestUsers": flask.url_for(
diff --git a/hubs/widgets/feed/functions.py b/hubs/widgets/feed/functions.py
index b323ca8..1fa457f 100644
--- a/hubs/widgets/feed/functions.py
+++ b/hubs/widgets/feed/functions.py
@@ -11,8 +11,8 @@ class GetData(CachedFunction):
"""Get the feed data from Redis and aggregate it."""
def execute(self):
- hub_name = self.instance.hub.name
- feed = Activity(hub_name)
+ hub_id = self.instance.hub.id
+ feed = Activity(hub_id)
raw_msgs = feed.get() # TODO: paging?
msgs = fedmsg.meta.conglomerate(raw_msgs)
limit = self.instance.config["message_limit"]
@@ -21,5 +21,5 @@ class GetData(CachedFunction):
def should_invalidate(self, message):
if "_hubs" not in message:
return False
- hub_name = self.instance.hub.name
- return (hub_name in message["_hubs"])
+ hub_id = self.instance.hub.id
+ return (hub_id in message["_hubs"])
From c40bbdc508c2c63126438317d48d21bd5318d12c Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 03 2018 10:59:58 +0000
Subject: [PATCH 5/20] Create a new "stream" hub type
---
diff --git a/hubs/defaults.py b/hubs/defaults.py
index 15b5f74..9042f78 100644
--- a/hubs/defaults.py
+++ b/hubs/defaults.py
@@ -127,3 +127,24 @@ def add_group_widgets(hub,
# other preset.
return hub
+
+
+def add_stream_widgets(hub):
+ """ Some defaults for an user's stream page. """
+ # Feed
+ hub.widgets.append(hubs.models.Widget(
+ plugin='feed', index=0, left=True,
+ _config=json.dumps({
+ 'message_limit': 20
+ })))
+ # Library
+ hub.widgets.append(hubs.models.Widget(
+ plugin='library', index=0,
+ _config=json.dumps({})))
+ # Help requests
+ hub.widgets.append(hubs.models.Widget(
+ plugin='halp', index=1,
+ _config=json.dumps({})))
+ # TODO: "my assigned issues" widget
+ # TODO: "Newest Opened Pull Requests" widget
+ # TODO: "Newest Opened Issues" widget
diff --git a/hubs/models/constants.py b/hubs/models/constants.py
index 890f732..d939672 100644
--- a/hubs/models/constants.py
+++ b/hubs/models/constants.py
@@ -23,7 +23,7 @@
from __future__ import unicode_literals
-HUB_TYPES = ("user", "team")
+HUB_TYPES = ("user", "team", "stream")
ROLES = ('subscriber', 'member', 'owner', 'stargazer')
diff --git a/hubs/models/hub.py b/hubs/models/hub.py
index 4f445e5..f09dbfd 100644
--- a/hubs/models/hub.py
+++ b/hubs/models/hub.py
@@ -195,6 +195,14 @@ class Hub(ObjectAuthzMixin, BASE):
hub_created.send(hub, **extra)
return hub
+ @classmethod
+ def create_stream_hub(cls, username):
+ session = Session()
+ hub = cls(name=username, hub_type="stream")
+ session.add(hub)
+ hub_created.send(hub)
+ return hub
+
def on_created(self, **extra):
if self.hub_type == "user":
hubs.defaults.add_user_widgets(self)
@@ -202,6 +210,8 @@ class Hub(ObjectAuthzMixin, BASE):
self.subscribe(user, role='owner')
elif self.hub_type == "team":
hubs.defaults.add_group_widgets(self, **extra)
+ elif self.hub_type == "stream":
+ hubs.defaults.add_stream_widgets(self)
def on_updated(self, old_config):
for widget_instance in self.widgets:
@@ -232,8 +242,8 @@ class Hub(ObjectAuthzMixin, BASE):
)
def _get_auth_user_access_level(self, user):
- # overridden to handle user hubs.
- if self.hub_type == "user" and user.username == self.name:
+ # overridden to handle user and stream hubs.
+ if self.hub_type in ("user", "stream") and user.username == self.name:
return AccessLevel.owner
return super(Hub, self)._get_auth_user_access_level(user)
diff --git a/hubs/models/user.py b/hubs/models/user.py
index 2abdde7..afd1081 100644
--- a/hubs/models/user.py
+++ b/hubs/models/user.py
@@ -37,7 +37,9 @@ log = logging.getLogger(__name__)
class User(BASE):
+
__tablename__ = 'users'
+
username = sa.Column(sa.Text, primary_key=True)
fullname = sa.Column(sa.Text)
created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)
@@ -137,3 +139,5 @@ class User(BASE):
from .hub import Hub
if Hub.by_name(self.username, "user") is None:
Hub.create_user_hub(self.username, self.fullname)
+ if Hub.by_name(self.username, "stream") is None:
+ Hub.create_stream_hub(self.username)
diff --git a/hubs/tests/backend/test_triage.py b/hubs/tests/backend/test_triage.py
index a3eb503..ff4ada4 100644
--- a/hubs/tests/backend/test_triage.py
+++ b/hubs/tests/backend/test_triage.py
@@ -23,4 +23,4 @@ class TriageTest(APPTest):
hub.widgets.append(widget)
module_names = [w.plugin for w in triage.get_widgets()]
self.assertNotIn("non-existant", module_names)
- self.assertEqual(len(module_names), 54)
+ self.assertEqual(len(module_names), 69)
diff --git a/hubs/tests/test_feed.py b/hubs/tests/test_feed.py
index 532c56a..222d3b7 100644
--- a/hubs/tests/test_feed.py
+++ b/hubs/tests/test_feed.py
@@ -108,7 +108,8 @@ class FeedTest(APPTest):
msg2agent.return_value = "ralph"
on_new_notification(msg)
msg2agent.assert_called()
- mock_notifications.assert_called_with("ralph")
+ stream = Hub.by_name("ralph", "stream")
+ mock_notifications.assert_called_with(stream.id)
feed.add.assert_called_with(msg)
@patch("hubs.feed.Notifications")
diff --git a/hubs/tests/test_widget_base.py b/hubs/tests/test_widget_base.py
index 494f091..bc3d8ad 100644
--- a/hubs/tests/test_widget_base.py
+++ b/hubs/tests/test_widget_base.py
@@ -4,6 +4,7 @@ import six
from mock import Mock
from hubs.app import app
+from hubs.models.constants import HUB_TYPES
from hubs.tests import APPTest, widget_instance
from hubs.widgets.base import Widget
from hubs.widgets.caching import CachedFunction
@@ -159,7 +160,7 @@ class WidgetTest(APPTest):
'contentUrl': '/widgets/about/{}/'.format(widget.idx),
'cssClass': None,
'hiddenIfEmpty': False,
- 'hub_types': ('user', 'team'),
+ 'hub_types': HUB_TYPES,
'idx': widget.idx,
'index': 500,
'isReact': False,
diff --git a/hubs/tests/views/test_api_hub_widget.py b/hubs/tests/views/test_api_hub_widget.py
index 99aece2..1422c6f 100644
--- a/hubs/tests/views/test_api_hub_widget.py
+++ b/hubs/tests/views/test_api_hub_widget.py
@@ -6,14 +6,14 @@ from mock import patch, Mock
from hubs.app import app
from hubs.models import Hub, User, Widget
from hubs.widgets import registry
-from hubs.tests import APPTest, FakeAuthorization, auth_set
+from hubs.tests import APPTest, FakeAuthorization, auth_set, widget_instance
class TestAPIHubWidgets(APPTest):
def test_get_widgets(self):
hub = Hub.by_name('ralph', "user")
- expected_ids = [32, 33, 31, 34, 35, 36, 37, 38, 39, 40, 51]
+ expected_ids = [41, 42, 40, 43, 44, 45, 46, 47, 48, 49, 66]
response = self.check_url("/api/hubs/%s/widgets/" % hub.id)
response_data = json.loads(response.get_data(as_text=True))
self.assertEqual(response_data["status"], "OK")
@@ -176,10 +176,7 @@ class TestAPIHubWidgets(APPTest):
def test_post_valid_widget_name_with_config(self):
hub = Hub.by_name("ralph", "user")
self.assertEqual(
- Widget.query.filter(
- Hub.name == "ralph",
- Widget.plugin == "about",
- ).count(), 1)
+ Widget.query.filter_by(hub=hub, plugin="about").count(), 1)
data = {
"name": "about",
"config": {'text': 'text of widget'},
@@ -196,18 +193,13 @@ class TestAPIHubWidgets(APPTest):
json.loads(result.get_data(as_text=True)),
{"status": "OK"})
self.assertEqual(
- Widget.query.filter(
- Hub.name == "ralph",
- Widget.plugin == "about",
- ).count(), 2)
+ Widget.query.filter_by(hub=hub, plugin="about").count(), 2)
def test_post_invalid_config(self):
hub = Hub.by_name("ralph", "user")
self.assertEqual(
- Widget.query.join(Hub).filter(
- Hub.name == "ralph",
- Widget.plugin == "meetings",
- ).count(), 1)
+ Widget.query.filter_by(
+ hub=hub, plugin="meetings").count(), 1)
data = {
"name": "meetings",
"config": {
@@ -240,21 +232,25 @@ class TestAPIHubWidgets(APPTest):
class TestAPIHubWidget(APPTest):
+ def setUp(self):
+ super(TestAPIHubWidget, self).setUp()
+ self.hub = Hub.by_name("ralph", "user")
+ self.widget = widget_instance("ralph", "pagure_pr")
+ self.url = "/api/hubs/%s/widgets/%s/" % (self.hub.id, self.widget.idx)
+
def test_get_logged_in(self):
- hub = Hub.by_name("ralph", "user")
user = FakeAuthorization('ralph')
- response = self.check_url(
- "/api/hubs/%s/widgets/37/" % hub.id, user=user)
+ response = self.check_url(self.url, user=user)
response_data = json.loads(response.get_data(as_text=True))
self.assertEqual(response_data["status"], "OK")
self.assertEqual(response_data["data"]["name"], "pagure_pr")
def test_get_logged_out(self):
- hub = Hub.by_name('ralph', "user")
- hub.config["visibility"] = "private"
+ self.hub.config["visibility"] = "private"
+ widget = widget_instance("ralph", "pagure_pr")
self.session.commit()
response = self.check_url(
- "/api/hubs/%s/widgets/31/" % hub.id, code=403)
+ "/api/hubs/%s/widgets/%s/" % (self.hub.id, widget.idx), code=403)
response_data = json.loads(response.get_data(as_text=True))
self.assertEqual(response_data["status"], "ERROR")
@@ -262,11 +258,10 @@ class TestAPIHubWidget(APPTest):
def test_put_empty_data_logged_in(self, Queue):
queue = Mock()
Queue.return_value = queue
- hub = Hub.by_name('ralph', "user")
user = FakeAuthorization('ralph')
with auth_set(app, user):
result = self.app.put(
- '/api/hubs/%s/widgets/37/' % hub.id,
+ self.url,
content_type="application/json",
data=json.dumps({}))
self.assertEqual(result.status_code, 200)
@@ -276,43 +271,41 @@ class TestAPIHubWidget(APPTest):
self.assertTrue(queue.enqueue.called)
task = queue.enqueue.call_args_list[0][0][0]
expected = {
- "hub": "ralph", "idx": 37,
+ "hub": "ralph", "idx": self.widget.idx,
"type": "widget-cache",
"fn_name": "GetPRs",
}
self.assertEqual(json.loads(task.data), expected)
def test_put_unauthorized(self):
- hub = Hub.by_name('ralph', "user")
user = FakeAuthorization('decause')
with auth_set(app, user):
result = self.app.put(
- '/api/hubs/%s/widgets/37/' % hub.id,
+ self.url,
content_type="application/json",
data=json.dumps({"config": {"text": "Defaced!"}}))
self.assertEqual(result.status_code, 403)
def test_delete(self):
- hub = Hub.by_name('ralph', "user")
user = FakeAuthorization('ralph')
with auth_set(app, user):
- result = self.app.delete('/api/hubs/%s/widgets/37/' % hub.id)
+ result = self.app.delete(self.url)
self.assertEqual(result.status_code, 200)
self.assertEqual(
json.loads(result.get_data(as_text=True)),
{"status": "OK"})
- response = self.check_url("/api/hubs/%s/widgets/" % hub.id)
+ response = self.check_url("/api/hubs/%s/widgets/" % self.hub.id)
response_data = json.loads(response.get_data(as_text=True))
self.assertNotIn(37, [w["idx"] for w in response_data["data"]])
def test_delete_unauthorized(self):
- hub = Hub.by_name('ralph', "user")
user = FakeAuthorization('decause')
with auth_set(app, user):
- response = self.app.delete('/api/hubs/%s/widgets/37/' % hub.id)
+ response = self.app.delete(self.url)
self.assertEqual(response.status_code, 403)
response_data = json.loads(response.get_data(as_text=True))
self.assertEqual(response_data["status"], "ERROR")
- response = self.check_url("/api/hubs/%s/widgets/" % hub.id)
+ response = self.check_url("/api/hubs/%s/widgets/" % self.hub.id)
response_data = json.loads(response.get_data(as_text=True))
- self.assertIn(37, [w["idx"] for w in response_data["data"]])
+ self.assertIn(
+ self.widget.idx, [w["idx"] for w in response_data["data"]])
diff --git a/hubs/tests/widgets/test_halp.py b/hubs/tests/widgets/test_halp.py
index 9b34c17..0cdda6c 100644
--- a/hubs/tests/widgets/test_halp.py
+++ b/hubs/tests/widgets/test_halp.py
@@ -4,6 +4,7 @@ import json
from hubs.app import app
from hubs.models import Hub
+from hubs.models.constants import HUB_TYPES
from hubs.utils.views import configure_widget_instance, WidgetConfigError
from hubs.tests import widget_instance
from hubs.widgets import registry
@@ -139,7 +140,7 @@ class HalpViewsTestCase(WidgetTest):
'config': {'hubs': ['fedora-devel'], 'per_page': 3},
'cssClass': None,
'hiddenIfEmpty': False,
- 'hub_types': ('user', 'team'),
+ 'hub_types': HUB_TYPES,
'idx': self.widget.idx,
'index': 9,
'isReact': True,
@@ -387,7 +388,7 @@ class HalpFunctionsTestCase(WidgetTest):
def test_should_invalidate_wrong_hub(self):
# The decause hub works in the fedora-commops channel.
- decause_hub = Hub.query.filter_by(name="decause").one()
+ decause_hub = Hub.by_name("decause", "user")
decause_hub.config["chat_channel"] = "#fedora-commops"
self.session.commit()
msg = {'topic': 'org.fedoraproject.prod.meetbot.meeting.item.help',
diff --git a/hubs/tests/widgets/test_library.py b/hubs/tests/widgets/test_library.py
index 6577c14..4c6030a 100644
--- a/hubs/tests/widgets/test_library.py
+++ b/hubs/tests/widgets/test_library.py
@@ -3,6 +3,7 @@ from __future__ import unicode_literals
import json
from hubs.app import app
+from hubs.models.constants import HUB_TYPES
from hubs.tests import FakeAuthorization, widget_instance
from . import WidgetTest
@@ -45,7 +46,7 @@ class TestLibrary(WidgetTest):
'isLarge': False,
'label': 'Library',
'name': 'library',
- 'hub_types': ('user', 'team'),
+ 'hub_types': HUB_TYPES,
'params': [
{'default': [],
'help': 'A JSON list of dicts with `url`, `title`, '
diff --git a/hubs/widgets/halp/views.py b/hubs/widgets/halp/views.py
index 2ef464c..e013be7 100644
--- a/hubs/widgets/halp/views.py
+++ b/hubs/widgets/halp/views.py
@@ -138,5 +138,6 @@ def hubs_suggest_view():
if flask.request.args.get("q"):
results = results.filter(Hub.name.ilike(
"%s%%" % flask.request.args.get("q")))
- results = results.order_by(Hub.name)[:MAX_SUGGESTS]
- return flask.jsonify({"status": "OK", "data": [h.name for h in results]})
+ results = results.order_by(Hub.name).distinct().limit(
+ MAX_SUGGESTS).values(Hub.name)
+ return flask.jsonify({"status": "OK", "data": [h[0] for h in results]})
From 805934a9863d3b902fe1b469d840591861671efa Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 03 2018 10:59:58 +0000
Subject: [PATCH 6/20] Send messages from subscribed hubs to the stream
---
diff --git a/hubs/feed.py b/hubs/feed.py
index 8539feb..4f60905 100644
--- a/hubs/feed.py
+++ b/hubs/feed.py
@@ -10,7 +10,7 @@ import flask
import pymongo
from fedmsg.encoding import loads, dumps
-from hubs.models import Hub, User, HubConfig
+from hubs.models import Hub, User, HubConfig, Association
from hubs.utils import get_fedmsg_config, pagure
@@ -78,6 +78,17 @@ def get_hubs_for_msg(msg):
HubConfig.key == "github",
HubConfig.value == project_name,
))
+ # Copy the message to each subscribed user's stream page
+ for hub_id in hubs[:]:
+ subscribers = Association.query.join(Hub).filter(
+ Hub.id == hub_id,
+ Association.role == "subscriber",
+ )
+ for assoc in subscribers:
+ stream = Hub.by_name(assoc.user.username, "stream")
+ if stream is None:
+ continue
+ hubs.append(stream.id)
return hubs
diff --git a/hubs/tests/test_feed.py b/hubs/tests/test_feed.py
index 222d3b7..89c32bc 100644
--- a/hubs/tests/test_feed.py
+++ b/hubs/tests/test_feed.py
@@ -178,10 +178,15 @@ class GetHubsForMsgTestCase(APPTest):
self.assertListEqual(get_hubs_for_msg(self.dummy_msg), [])
def test_user_hub_owner(self):
+ # Ralph's actions don't necessarily end up in its stream.
self.msg2usernames.return_value = ["ralph"]
- hub = Hub.by_name("ralph", "hub")
+ hub = Hub.by_name("ralph", "user")
+ # stream = Hub.by_name("ralph", "stream")
self.assertListEqual(
- get_hubs_for_msg(self.dummy_msg), [hub.id])
+ get_hubs_for_msg(self.dummy_msg),
+ # [hub.id, stream.id]
+ [hub.id]
+ )
def test_group_hub_owner(self):
# Don't send a message to a group hub just because the user is the
From 702f38107ea5126eb8901f9ed367d49b00e13eda Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 03 2018 10:59:58 +0000
Subject: [PATCH 7/20] Rework the SavedNotifications DB schema
---
diff --git a/hubs/feed.py b/hubs/feed.py
index 4f60905..a7b6476 100644
--- a/hubs/feed.py
+++ b/hubs/feed.py
@@ -122,15 +122,15 @@ def on_new_message(msg):
feed.add(msg)
-def add_dom_id(msg):
- """Compute a deterministic dom_id.
+def add_notif_id(msg):
+ """Compute a deterministic notification id.
Since this is the identifier stored when the message is saved by the user
in the SQL DB, it has to be invariant through conglomerate() calls.
"""
if "msg_ids" not in msg:
msg = fedmsg.meta.conglomerate([msg])[0]
- msg["dom_id"] = hashlib.sha1(
+ msg["notif_id"] = hashlib.sha1(
b":".join(
[mid.encode("utf-8") for mid in sorted(msg["msg_ids"])]
)).hexdigest()
@@ -270,7 +270,7 @@ class Notifications(Feed):
msgtype = "notif"
def _preprocess_msg(self, msg):
- return add_dom_id(msg)
+ return add_notif_id(msg)
class Activity(Feed):
diff --git a/hubs/models/savednotification.py b/hubs/models/savednotification.py
index d5412b6..76a7257 100644
--- a/hubs/models/savednotification.py
+++ b/hubs/models/savednotification.py
@@ -25,7 +25,6 @@ from __future__ import unicode_literals
import datetime
import logging
-import bleach
import sqlalchemy as sa
from hubs.database import BASE
@@ -37,39 +36,26 @@ log = logging.getLogger(__name__)
class SavedNotification(BASE):
__tablename__ = 'savednotifications'
-
- user = sa.Column(sa.Text, sa.ForeignKey('users.username'))
- created = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)
- dom_id = sa.Column(sa.Text)
- idx = sa.Column(sa.Integer, primary_key=True)
+ __table_args__ = (
+ sa.schema.UniqueConstraint('username', 'notif_id'),
+ )
+
+ id = sa.Column(sa.Integer, primary_key=True)
+ username = sa.Column(sa.Text, sa.ForeignKey('users.username'))
+ created = sa.Column(
+ sa.DateTime, default=datetime.datetime.utcnow, nullable=False)
+ notif_id = sa.Column(sa.Text, nullable=False)
link = sa.Column(sa.Text)
- markup = sa.Column(sa.Text)
- secondary_icon = sa.Column(sa.Text)
-
- def __init__(self, username=None, markup='', link='', secondary_icon='',
- dom_id=''):
- self.user = username
- self.markup = markup
- self.link = link
- self.secondary_icon = secondary_icon
- self.dom_id = dom_id
+ markup = sa.Column(sa.Text, nullable=False)
+ icon = sa.Column(sa.Text)
- def __json__(self):
+ def to_dict(self):
return {
- 'created': str(self.created),
- 'date_time': str(self.created),
- 'dom_id': self.dom_id,
- 'idx': self.idx,
- 'link': bleach.linkify(self.link),
- 'markup': bleach.linkify(self.markup),
+ 'id': self.id,
+ 'created': self.created,
+ 'notif_id': self.notif_id,
+ 'link': self.link,
+ 'markup': self.markup,
+ 'secondary_icon': self.icon,
'saved': True,
- 'secondary_icon': self.secondary_icon
}
-
- @classmethod
- def by_username(cls, username):
- return cls.query.filter_by(user=username).all()
-
- @classmethod
- def all(cls):
- return cls.query.all()
diff --git a/hubs/models/user.py b/hubs/models/user.py
index afd1081..cdd13da 100644
--- a/hubs/models/user.py
+++ b/hubs/models/user.py
@@ -43,7 +43,7 @@ class User(BASE):
username = sa.Column(sa.Text, primary_key=True)
fullname = sa.Column(sa.Text)
created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)
- saved_notifications = relation('SavedNotification', backref='users',
+ saved_notifications = relation('SavedNotification', backref='user',
lazy='dynamic')
def __json__(self):
diff --git a/hubs/static/client/app/components/feed/Actions.js b/hubs/static/client/app/components/feed/Actions.js
index 7219821..dd7db6e 100644
--- a/hubs/static/client/app/components/feed/Actions.js
+++ b/hubs/static/client/app/components/feed/Actions.js
@@ -5,7 +5,7 @@ export default class Actions extends React.Component {
render() {
var buttonProps = {
- id: `save-${this.props.item.dom_id}`,
+ id: `save-${this.props.item.notif_id}`,
className: "btn btn-sm ",
};
var buttonText;
diff --git a/hubs/static/client/app/components/feed/Feed.js b/hubs/static/client/app/components/feed/Feed.js
index e92d55d..a485878 100644
--- a/hubs/static/client/app/components/feed/Feed.js
+++ b/hubs/static/client/app/components/feed/Feed.js
@@ -19,10 +19,10 @@ const messages = defineMessages({
export default class Feed extends React.Component {
render() {
- var items = this.props.items || [];
+ let items = this.props.items || [];
items = items.map((item, idx) => {
return (
-
+
);
});
return (
diff --git a/hubs/static/client/app/components/feed/__tests__/Feed.test.js b/hubs/static/client/app/components/feed/__tests__/Feed.test.js
index 501da7f..da1b793 100644
--- a/hubs/static/client/app/components/feed/__tests__/Feed.test.js
+++ b/hubs/static/client/app/components/feed/__tests__/Feed.test.js
@@ -18,7 +18,7 @@ describe('Feed', () => {
}
var items = [item, item, item];
items = items.map((obj, idx) => {
- return Object.assign({dom_id: "item-" + idx}, obj);
+ return Object.assign({notif_id: "item-" + idx}, obj);
});
it('should create the children', () => {
diff --git a/hubs/tests/__init__.py b/hubs/tests/__init__.py
index 43850a2..e532c7a 100644
--- a/hubs/tests/__init__.py
+++ b/hubs/tests/__init__.py
@@ -61,7 +61,7 @@ class APPTest(unittest.TestCase):
hubs.models.User.get_or_create(
username=user, fullname=fullname)
saved_notif = hubs.models.SavedNotification(
- username=user, markup='foo', link='bar'
+ username=user, markup='foo', link='bar', notif_id='baz',
)
self.session.add(saved_notif)
diff --git a/hubs/tests/test_feed.py b/hubs/tests/test_feed.py
index 89c32bc..69043b5 100644
--- a/hubs/tests/test_feed.py
+++ b/hubs/tests/test_feed.py
@@ -8,7 +8,7 @@ from mock import MagicMock, Mock, patch
from hubs.app import app
from hubs.models import User, Hub, Association
from hubs.feed import (
- Notifications, Activity, add_dom_id, format_msgs, get_hubs_for_msg,
+ Notifications, Activity, add_notif_id, format_msgs, get_hubs_for_msg,
on_new_message, on_new_notification)
from hubs.tests import APPTest
@@ -121,7 +121,7 @@ class FeedTest(APPTest):
# User does not exist, no feed instance should have been created.
mock_notifications.assert_not_called()
- def test_add_dom_id(self):
+ def test_add_notif_id(self):
msg = {
"msg_ids": {
"testid1": {"msg_id": "testid1"},
@@ -133,9 +133,9 @@ class FeedTest(APPTest):
"subjective": "your ticket was commented by decause",
}
with app.test_request_context():
- result = add_dom_id(msg)
+ result = add_notif_id(msg)
self.assertEqual(
- result["dom_id"],
+ result["notif_id"],
"067306d0b091ec48d9e2dded1ca9b1f8b1b2ac93")
def test_format_msgs(self):
diff --git a/hubs/tests/views/test_user.py b/hubs/tests/views/test_user.py
index 46a2828..06ef209 100644
--- a/hubs/tests/views/test_user.py
+++ b/hubs/tests/views/test_user.py
@@ -66,7 +66,7 @@ class TestPostNotifications(hubs.tests.APPTest):
'markup': 'foobar',
'link': 'baz',
'secondary_icon': 'http://placekitten.com/g/200/300',
- 'dom_id': 'reallyuniqueuid'
+ 'notif_id': 'reallyuniqueuid'
}
invalid_payload = {
@@ -97,8 +97,7 @@ class TestPostNotifications(hubs.tests.APPTest):
self.assertEqual(notification['markup'], 'foobar')
self.assertEqual(notification['link'], 'baz')
- all_saved = hubs.models.SavedNotification.by_username(
- self.user.nickname)
+ all_saved = self.user.saved_notifications
self.assertEqual(len(all_saved), 2)
all_saved = [s.__json__() for s in all_saved]
self.assertTrue(any(str(s['markup']) == self.valid_payload['markup']
@@ -114,7 +113,7 @@ class TestDeleteNotifications(hubs.tests.APPTest):
markup='foo',
link='bar',
secondary_icon='baz',
- dom_id='qux'
+ notif_id='qux'
)
def test_delete_notification(self):
diff --git a/hubs/widgets/feed/functions.py b/hubs/widgets/feed/functions.py
index 1fa457f..779c7d5 100644
--- a/hubs/widgets/feed/functions.py
+++ b/hubs/widgets/feed/functions.py
@@ -3,12 +3,12 @@ from __future__ import unicode_literals, absolute_import
import fedmsg.meta
-from hubs.feed import Activity, add_dom_id
+from hubs.feed import Activity, add_notif_id
from hubs.widgets.caching import CachedFunction
class GetData(CachedFunction):
- """Get the feed data from Redis and aggregate it."""
+ """Get the feed data from MongoDB and aggregate it."""
def execute(self):
hub_id = self.instance.hub.id
@@ -16,7 +16,7 @@ class GetData(CachedFunction):
raw_msgs = feed.get() # TODO: paging?
msgs = fedmsg.meta.conglomerate(raw_msgs)
limit = self.instance.config["message_limit"]
- return [add_dom_id(msg) for msg in msgs[:limit]]
+ return [add_notif_id(msg) for msg in msgs[:limit]]
def should_invalidate(self, message):
if "_hubs" not in message:
From 0d23e609a9c61ecab7b1f1c3ac7c3f01f448b75d Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 03 2018 10:59:58 +0000
Subject: [PATCH 8/20] Use the stream hub type for the Stream page
Fixes #158
---
diff --git a/hubs/static/client/app/components/HubPage.css b/hubs/static/client/app/components/HubPage.css
deleted file mode 100644
index e69de29..0000000
--- a/hubs/static/client/app/components/HubPage.css
+++ /dev/null
diff --git a/hubs/static/client/app/components/HubPage.js b/hubs/static/client/app/components/HubPage.js
index 57ce5d0..0b0b8ab 100644
--- a/hubs/static/client/app/components/HubPage.js
+++ b/hubs/static/client/app/components/HubPage.js
@@ -9,7 +9,6 @@ import PageStructure from './PageStructure';
import WidgetsArea from './WidgetsArea';
import HubHeader from './HubHeader';
import { fetchHub } from '../core/actions/hub';
-import "./HubPage.css";
class HubPage extends React.Component {
diff --git a/hubs/static/client/app/components/StreamHeader.js b/hubs/static/client/app/components/StreamHeader.js
new file mode 100644
index 0000000..7a6900f
--- /dev/null
+++ b/hubs/static/client/app/components/StreamHeader.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+export default class StreamsHeader extends React.Component {
+
+ render() {
+ return (
+
+
My Stream
+
Notifications, actions, and other things of interest to me
+
+ );
+ }
+}
diff --git a/hubs/static/client/app/components/StreamPage.js b/hubs/static/client/app/components/StreamPage.js
new file mode 100644
index 0000000..b4b7900
--- /dev/null
+++ b/hubs/static/client/app/components/StreamPage.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import {
+ defineMessages,
+ FormattedMessage,
+ } from 'react-intl';
+import PageStructure from './PageStructure';
+import StreamHeader from './StreamHeader';
+import WidgetsArea from './WidgetsArea';
+import { fetchHub } from '../core/actions/hub';
+
+
+class StreamPage extends React.Component {
+
+ componentDidMount() {
+ this.props.dispatch(fetchHub());
+ }
+
+ render() {
+ return (
+
+
+
+
+
+ }
+ content={
+
+ }
+ />
+ );
+ }
+}
+
+
+export default connect()(StreamPage);
diff --git a/hubs/static/client/app/components/StreamsHeader.js b/hubs/static/client/app/components/StreamsHeader.js
deleted file mode 100644
index 7a6900f..0000000
--- a/hubs/static/client/app/components/StreamsHeader.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-
-export default class StreamsHeader extends React.Component {
-
- render() {
- return (
-
-
My Stream
-
Notifications, actions, and other things of interest to me
-
- );
- }
-}
diff --git a/hubs/static/client/app/components/StreamsPage.js b/hubs/static/client/app/components/StreamsPage.js
deleted file mode 100644
index 122c7dd..0000000
--- a/hubs/static/client/app/components/StreamsPage.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import {
- defineMessages,
- FormattedMessage,
- } from 'react-intl';
-import PageStructure from './PageStructure';
-import StreamsHeader from './StreamsHeader';
-import Streams from './Streams';
-
-
-export default class StreamsPage extends React.Component {
-
- render() {
- return (
-
-
-
-
-
- }
- content={
-
- }
- />
- );
- }
-}
diff --git a/hubs/static/client/app/core/Pages.js b/hubs/static/client/app/core/Pages.js
index bfe19dc..739028c 100644
--- a/hubs/static/client/app/core/Pages.js
+++ b/hubs/static/client/app/core/Pages.js
@@ -9,8 +9,8 @@ const Hub = makeLoadable(
"Loading...",
"Sorry, there was a problem loading the page."
);
-const Streams = makeLoadable(
- () => import(/* webpackChunkName: "page-streams" */ '../components/StreamsPage'),
+const Stream = makeLoadable(
+ () => import(/* webpackChunkName: "page-stream" */ '../components/StreamPage'),
"Loading...",
"Sorry, there was a problem loading the page."
);
@@ -20,4 +20,4 @@ const Groups = makeLoadable(
"Sorry, there was a problem loading the page."
);
-export {Hub, Streams, Groups};
+export {Hub, Stream, Groups};
diff --git a/hubs/views/user.py b/hubs/views/user.py
index bfd2b32..84fd8f6 100644
--- a/hubs/views/user.py
+++ b/hubs/views/user.py
@@ -1,12 +1,10 @@
from __future__ import unicode_literals, absolute_import
import flask
-import hubs.models
-import hubs.feed
from hubs.app import app
from hubs.utils.views import (
- login_required, get_sse_url, get_user_details)
+ login_required, get_hub, get_sse_url, get_user_details)
@app.route('/stream')
@@ -14,10 +12,12 @@ from hubs.utils.views import (
@login_required
def stream():
current_user = get_user_details()
+ stream = get_hub(flask.g.user.username, "stream")
urls = {
- "sse": get_sse_url("user/{}".format(current_user["nickname"])),
- "notifications": flask.url_for("stream_existing"),
- "saved": flask.url_for("saved_notifs"),
+ "sse": get_sse_url("hub/{}".format(stream.id)),
+ "hub": flask.url_for("api_hub", hub_id=stream.id),
+ "hubConfig": flask.url_for("api_hub_config", hub_id=stream.id),
+ "widgets": flask.url_for("api_hub_widgets", hub_id=stream.id),
"allGroups": flask.url_for("groups"),
}
flash_messages = [
@@ -29,69 +29,10 @@ def stream():
page_title="My Stream",
initial_state=dict(
ui=dict(
- page="Streams",
+ page="Stream",
flashMessages=flash_messages,
),
urls=urls,
currentUser=current_user,
),
)
-
-
-@app.route('/stream/existing')
-@login_required
-def stream_existing():
- username = flask.g.user.username
- feed = hubs.feed.Notifications(username)
- existing = hubs.feed.format_msgs(feed.get()) # TODO: paging?
- # Right now, stream and actions are the same.
- # Once mentions is implemented, then each will be its own.
- return flask.jsonify(dict(
- status="OK", data=existing,
- ))
-
-
-@app.route('/stream/saved', methods=['GET', 'POST'])
-@app.route('/stream/saved/', methods=['GET', 'POST'])
-@login_required
-def saved_notifs():
- user = flask.g.user
- if flask.request.method == "GET":
- saved = hubs.models.SavedNotification.by_username(user.username)
- return flask.jsonify(dict(
- status="OK", data=[n.__json__() for n in saved],
- ))
- elif flask.request.method == "POST":
- data = flask.request.get_json()
- try:
- markup = data['markup']
- link = data['link']
- icon = data['secondary_icon']
- dom_id = data['dom_id']
- except Exception:
- return flask.abort(400)
- notification = hubs.models.SavedNotification(
- username=user.username,
- markup=markup,
- link=link,
- secondary_icon=icon,
- dom_id=dom_id
- )
- flask.g.db.add(notification)
- flask.g.db.commit()
- return flask.jsonify(dict(
- status="OK", data=notification.__json__(),
- ))
-
-
-@app.route('/stream/saved/', methods=['DELETE'])
-@app.route('/stream/saved//', methods=['DELETE'])
-@login_required
-def delete_notifs(idx):
- notification = flask.g.db.query(
- hubs.models.SavedNotification).filter_by(idx=idx).first()
- if not notification:
- return flask.abort(400)
- flask.g.db.delete(notification)
- flask.g.db.commit()
- return flask.jsonify(dict(status="OK"))
diff --git a/hubs/widgets/feed/__init__.py b/hubs/widgets/feed/__init__.py
index 41d5171..ff395db 100644
--- a/hubs/widgets/feed/__init__.py
+++ b/hubs/widgets/feed/__init__.py
@@ -1,19 +1,10 @@
from __future__ import unicode_literals, absolute_import
-
-import logging
-
import flask
-from hubs.feed import format_msgs
+from hubs import models
from hubs.utils import validators
from hubs.widgets.base import Widget
-from hubs.widgets.view import WidgetView
-
-from .functions import GetData
-
-
-log = logging.getLogger('hubs.widgets')
class Feed(Widget):
@@ -30,27 +21,23 @@ class Feed(Widget):
"help": "Max number of feed messages to display.",
}]
cached_functions_module = ".functions"
+ views_module = ".views"
is_react = True
def get_props(self, instance, *args, **kwargs):
props = super(Feed, self).get_props(instance, *args, **kwargs)
if instance is not None:
props.update(dict(
- url=flask.url_for("feed_existing", idx=instance.idx),
+ url_existing=flask.url_for("feed_existing", idx=instance.idx),
+ url_saved=flask.url_for("feed_saved", idx=instance.idx),
))
+ if instance.hub.hub_type == "stream":
+ user_feed_widget = models.Widget.query.join(models.Hub).filter(
+ models.Hub.name == instance.hub.name,
+ models.Hub.hub_type == "user",
+ models.Widget.plugin == "feed",
+ ).first()
+ if user_feed_widget is not None:
+ props["url_actions"] = flask.url_for(
+ "feed_existing", idx=user_feed_widget.idx),
return props
-
-
-class ExistingView(WidgetView):
-
- name = "existing"
- url_rules = ["/existing"]
- json = True
-
- def get_context(self, instance, *args, **kwargs):
- get_data = GetData(instance)
- existing = format_msgs(get_data())
- return {
- "status": "OK",
- "data": existing,
- }
diff --git a/hubs/widgets/feed/views.py b/hubs/widgets/feed/views.py
new file mode 100644
index 0000000..7f8a5ff
--- /dev/null
+++ b/hubs/widgets/feed/views.py
@@ -0,0 +1,86 @@
+from __future__ import unicode_literals, absolute_import
+
+import flask
+
+from hubs.feed import format_msgs
+from hubs.models import SavedNotification
+from hubs.widgets.view import WidgetView
+
+from .functions import GetData
+
+
+class ExistingView(WidgetView):
+
+ name = "existing"
+ url_rules = ["/existing"]
+ json = True
+
+ def get_context(self, instance, *args, **kwargs):
+ get_data = GetData(instance)
+ existing = format_msgs(get_data())
+ return {
+ "status": "OK",
+ "data": existing,
+ }
+
+
+class SavedView(WidgetView):
+
+ name = "saved"
+ url_rules = ["/saved/"]
+ methods = ['GET', 'POST']
+ json = True
+
+ def get_context(self, instance, *args, **kwargs):
+ user = flask.g.user
+ if flask.request.method == "POST":
+ data = flask.request.get_json()
+ try:
+ markup = data['markup']
+ link = data['link']
+ icon = data['icon']
+ notif_id = data['notif_id']
+ except Exception:
+ return flask.abort(400)
+ existing = SavedNotification.query.filter_by(
+ user=user, notif_id=notif_id)
+ if existing.count() > 0:
+ return dict(
+ status="OK", data=existing.first().to_dict(),
+ )
+ notification = SavedNotification(
+ user=user,
+ markup=markup,
+ link=link,
+ icon=icon,
+ notif_id=notif_id
+ )
+ flask.g.db.add(notification)
+ flask.g.db.commit()
+ return dict(
+ status="OK", data=notification.to_dict(),
+ )
+ elif flask.request.method == "GET":
+ return dict(
+ status="OK", data=[
+ n.to_dict() for n in user.saved_notifications
+ ],
+ )
+
+
+class DeleteNotifView(WidgetView):
+
+ name = "delete_notif"
+ url_rules = ["/saved/"]
+ methods = ['DELETE']
+ json = True
+
+ def get_context(self, instance, *args, **kwargs):
+ user = flask.g.user
+ notif = SavedNotification.query.filter_by(
+ user=user, notif_id=kwargs["notif_id"]).first()
+ if not notif:
+ return flask.abort(404)
+ flask.g.db.delete(notif)
+ flask.g.db.commit()
+ return dict(status="OK")
From 42862077def6e45e70b1c2e33f09796ed05305f9 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 03 2018 10:59:58 +0000
Subject: [PATCH 9/20] Make the feed widget behave differently on stream hubs
---
diff --git a/hubs/static/client/app/components/Streams.js b/hubs/static/client/app/components/Streams.js
deleted file mode 100644
index ebc0b98..0000000
--- a/hubs/static/client/app/components/Streams.js
+++ /dev/null
@@ -1,193 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import {
- defineMessages,
- FormattedMessage,
- } from 'react-intl';
-import {
- streamWillUpdate,
- streamDidUpdate,
- } from "../core/actions/stream";
-import SSESource from "./SSESource";
-import TabSet from '../components/TabSet';
-import ItemsGetter from '../components/feed/ItemsGetter';
-import Feed from '../components/feed/Feed';
-
-
-class Streams extends React.Component {
-
- constructor(props) {
- super(props);
- this.state = {
- notifItems: [],
- savedItems: [],
- };
- this.handleStreamData = this.handleStreamData.bind(this);
- this.handleStreamRequestStart = this.handleStreamRequestStart.bind(this);
- this.handleSavedData = this.handleSavedData.bind(this);
- this.handleSave = this.handleSave.bind(this);
- this.handleUnsave = this.handleUnsave.bind(this);
- }
-
- handleStreamData(data) {
- this.setState({
- notifItems: data
- },
- () => (
- this.props.dispatch(streamDidUpdate())
- )
- );
- }
-
- handleStreamRequestStart() {
- this.props.dispatch(streamWillUpdate());
- }
-
- handleSavedData(data) {
- this.setState({savedItems: data});
- }
-
- handleSave(item) {
- if (!this.props.savedUrl) { return; }
- const payload = {
- link: item.link,
- markup: item.markup,
- secondary_icon: item.secondary_icon,
- dom_id: item.dom_id,
- };
- $.ajax({
- type: 'POST',
- url: this.props.savedUrl,
- data: JSON.stringify(payload),
- contentType: 'application/json',
- success: (data) => {
- this.setState((prevState, props) => {
- prevState.savedItems.push(data.data);
- return {savedItems: prevState.savedItems};
- });
- },
- });
- }
-
- handleUnsave(item) {
- if (!this.props.savedUrl) { return; }
- var updateSavedItems = (item) => {
- this.setState((prevState, props) => {
- var items = prevState.savedItems.filter(
- (currentItem) => (currentItem.dom_id !== item.dom_id)
- );
- return {savedItems: items};
- });
- };
- if (item.idx) {
- // Already saved
- $.ajax({
- type: 'DELETE',
- url: `${this.props.savedUrl}${item.idx}/`,
- success: () => { updateSavedItems(item) },
- });
- } else {
- updateSavedItems(item);
- }
- }
-
- render() {
- // Add saved state.
- var savedItemsIds = this.state.savedItems.map((item) => {
- return item.dom_id;
- });
- var notifs = this.state.notifItems.map((item) => {
- item.saved = (savedItemsIds.indexOf(item.dom_id) !== -1);
- return item;
- });
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-
-const mapStateToProps = (state) => {
- return {
- savedUrl: state.urls.saved,
- notificationsUrl: state.urls.notifications,
- username: state.currentUser.nickname,
- }
-};
-
-export default connect(mapStateToProps)(Streams);
-
-
-
-class FeedPanel extends React.Component {
-
- render() {
- const filters_url = `https://apps.fedoraproject.org/notifications/${this.props.username}.id.fedoraproject.org/`;
-
- return (
-
-
-
-
-
-
-
-
-
- {this.props.children}
-
- );
- }
-
-}
diff --git a/hubs/static/client/app/widgets/feed/ActionsFeed.js b/hubs/static/client/app/widgets/feed/ActionsFeed.js
new file mode 100644
index 0000000..917378d
--- /dev/null
+++ b/hubs/static/client/app/widgets/feed/ActionsFeed.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import WidgetChrome from '../../components/WidgetChrome';
+import ItemsGetter from '../../components/feed/ItemsGetter';
+import Feed from '../../components/feed/Feed';
+
+
+export default class ActionsFeed extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ items: [],
+ loaded: false,
+ };
+ this.handleServerData = this.handleServerData.bind(this);
+ }
+
+ handleServerData(data) {
+ this.setState({
+ items: data,
+ loaded: true,
+ },
+ this.props.onServerRequestStop
+ );
+ }
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+
+}
+ActionsFeed.propTypes = {
+ widget: PropTypes.object.isRequired,
+ needsUpdate: PropTypes.bool,
+}
diff --git a/hubs/static/client/app/widgets/feed/StreamFeed.js b/hubs/static/client/app/widgets/feed/StreamFeed.js
new file mode 100644
index 0000000..1f106ae
--- /dev/null
+++ b/hubs/static/client/app/widgets/feed/StreamFeed.js
@@ -0,0 +1,226 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ defineMessages,
+ FormattedMessage,
+ } from 'react-intl';
+import { apiCall } from '../../core/utils';
+import TabSet from '../../components/TabSet';
+import ItemsGetter from '../../components/feed/ItemsGetter';
+import Feed from '../../components/feed/Feed';
+
+
+export default class StreamFeed extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ notifItems: [],
+ //notifItemsById: {},
+ actionItems: [],
+ savedItems: [],
+ //savedItemsById: {},
+ savedItemsNeedUpdate: false,
+ };
+ this.handleStreamData = this.handleStreamData.bind(this);
+ this.handleActionData = this.handleActionData.bind(this);
+ this.handleSavedData = this.handleSavedData.bind(this);
+ this.handleSave = this.handleSave.bind(this);
+ this.handleUnsave = this.handleUnsave.bind(this);
+ }
+
+ handleStreamData(data) {
+ this.setState({
+ notifItems: data
+ },
+ this.props.onServerRequestStop
+ );
+ }
+
+ handleActionData(data) {
+ this.setState({
+ actionItems: data,
+ },
+ this.props.onServerRequestStop
+ );
+ }
+
+ handleSavedData(data) {
+ this.setState({
+ savedItems: data,
+ savedItemsNeedUpdate: false,
+ },
+ this.props.onServerRequestStop
+ );
+ }
+
+ handleSave(item) {
+ if (!this.props.widget.url_saved) { return; }
+ const newItem = {
+ link: item.link,
+ markup: item.markup,
+ icon: item.secondary_icon,
+ notif_id: item.notif_id,
+ };
+ // Optimistic update
+ this.setState((prevState, props) => ({
+ savedItems: [
+ ...prevState.savedItems,
+ newItem,
+ ],
+ })
+ );
+ // Backend call
+ const body = JSON.stringify(newItem);
+ apiCall(this.props.widget.url_saved, {method: "POST", body}).then(
+ (result) => {
+ this.setState((prevState, props) => ({
+ //savedItems: [
+ // ...prevState.savedItems,
+ // result,
+ //],
+ savedItemsNeedUpdate: true,
+ })
+ );
+ },
+ (error) => {
+ console.log(error);
+ this.setState({
+ savedItemsNeedUpdate: true,
+ });
+ }
+ );
+ }
+
+ handleUnsave(item) {
+ if (!this.props.widget.url_saved) { return; }
+ // Optimistic update
+ this.setState((prevState, props) => {
+ const items = prevState.savedItems.filter(
+ (currentItem) => (currentItem.notif_id !== item.notif_id)
+ );
+ return {savedItems: items};
+ });
+ // Backend call
+ apiCall(`${this.props.widget.url_saved}${item.notif_id}`, {method: "DELETE"}).then(
+ (result) => {
+ this.setState((prevState, props) => ({
+ savedItemsNeedUpdate: true,
+ })
+ );
+ },
+ (error) => {
+ console.log(error);
+ this.setState({
+ savedItemsNeedUpdate: true,
+ });
+ }
+ );
+ }
+
+ render() {
+ // Add saved state.
+ const savedItemsIds = this.state.savedItems.map((item) => (item.notif_id));
+ const notifs = this.state.notifItems.map((item) => {
+ item.saved = (savedItemsIds.indexOf(item.notif_id) !== -1);
+ return item;
+ });
+ const actions = this.state.actionItems.map((item) => {
+ item.saved = (savedItemsIds.indexOf(item.notif_id) !== -1);
+ return item;
+ });
+ const username = this.props.currentUser.nickname;
+
+ return (
+
+
+
+
+
+
+
+ { this.props.widget.url_actions &&
+
+
+
+
+
+ }
+
+
+
+
+
+
+
+ );
+ }
+}
+
+
+class FeedPanel extends React.Component {
+
+ render() {
+ const filters_url = `https://apps.fedoraproject.org/notifications/${this.props.username}.id.fedoraproject.org/`;
+
+ return (
+
+ { /*
+
+
+
+
+
+
+
+
+ */ }
+ {this.props.children}
+
+ );
+ }
+
+}
diff --git a/hubs/static/client/app/widgets/feed/Widget.js b/hubs/static/client/app/widgets/feed/Widget.js
index b4c0933..333e7c6 100644
--- a/hubs/static/client/app/widgets/feed/Widget.js
+++ b/hubs/static/client/app/widgets/feed/Widget.js
@@ -1,66 +1,54 @@
import React from 'react';
import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
import {
widgetDidUpdate,
widgetWillUpdate
} from "../../core/actions/widget";
-import ItemsGetter from '../../components/feed/ItemsGetter';
-import Feed from '../../components/feed/Feed';
-import WidgetChrome from '../../components/WidgetChrome';
+import ActionsFeed from "./ActionsFeed";
+import StreamFeed from "./StreamFeed";
-export default class FeedWidget extends React.PureComponent {
+class FeedWidget extends React.PureComponent {
constructor(props) {
super(props);
- this.state = {
- items: [],
- loaded: false,
- };
- this.handleServerData = this.handleServerData.bind(this);
this.handleServerRequestStart = this.handleServerRequestStart.bind(this);
- }
-
- handleServerData(data) {
- this.setState({
- items: data,
- loaded: true,
- },
- () => (
- this.props.dispatch(widgetDidUpdate(this.props.widget.idx))
- )
- );
+ this.handleServerRequestStop = this.handleServerRequestStop.bind(this);
}
handleServerRequestStart() {
this.props.dispatch(widgetWillUpdate(this.props.widget.idx));
}
+ handleServerRequestStop() {
+ this.props.dispatch(widgetDidUpdate(this.props.widget.idx));
+ }
+
render() {
+ const FeedComponent = this.props.hub.type === "stream" ? StreamFeed : ActionsFeed;
return (
-
-
-
-
-
+
);
}
}
FeedWidget.propTypes = {
+ hub: PropTypes.object.isRequired,
widget: PropTypes.object.isRequired,
editMode: PropTypes.bool,
needsUpdate: PropTypes.bool,
}
+
+const mapStateToProps = (state) => {
+ return {
+ hub: state.entities.hub,
+ currentUser: state.currentUser,
+ }
+};
+
+export default connect(mapStateToProps)(FeedWidget);
diff --git a/hubs/tests/views/test_user.py b/hubs/tests/views/test_user.py
deleted file mode 100644
index 06ef209..0000000
--- a/hubs/tests/views/test_user.py
+++ /dev/null
@@ -1,143 +0,0 @@
-from __future__ import unicode_literals
-
-import json
-
-from mock import Mock, patch
-
-import hubs.tests
-import hubs.models
-from hubs.app import app
-
-
-class TestStreamExisting(hubs.tests.APPTest):
- user = hubs.tests.FakeAuthorization('ralph')
-
- @patch("hubs.feed.Notifications")
- def test_get(self, Notifications):
- feed = Mock()
- Notifications.return_value = feed
- feed.get.return_value = [{
- "subtitle": "foo", "link": "bar",
- }]
- with hubs.tests.auth_set(app, self.user):
- resp = self.app.get('/stream/existing')
-
- self.assertEqual(resp.status_code, 200)
- data = json.loads(resp.get_data(as_text=True))
- self.assertEqual(data["status"], "OK")
- self.assertEqual(len(data["data"]), 1)
- self.assertEqual(data["data"][0]['subtitle'], 'foo')
- self.assertEqual(data["data"][0]['markup'], 'foo')
- self.assertEqual(data["data"][0]['link'], 'bar')
-
-
-class TestGetNotifications(hubs.tests.APPTest):
- user = hubs.tests.FakeAuthorization('ralph')
-
- def test_get_notifications_invalid_name(self):
- name = 'notarealfasuser'
-
- with hubs.tests.auth_set(app, self.user):
- resp = self.app.get('/stream/saved/'.format(name))
- self.assertEqual(resp.status_code, 200)
- data = json.loads(resp.get_data(as_text=True))
- self.assertEqual(data["status"], "OK")
- self.assertEqual(len(data["data"]), 1)
- self.assertEqual(data["data"][0]['markup'], 'foo')
- self.assertEqual(data["data"][0]['link'], 'bar')
-
- def test_get_notifications_valid_name(self):
- with hubs.tests.auth_set(app, self.user):
- resp = self.app.get('/stream/saved/'.format(
- self.user.nickname))
-
- self.assertEqual(resp.status_code, 200)
- data = json.loads(resp.get_data(as_text=True))
- self.assertEqual(data["status"], "OK")
- self.assertEqual(len(data["data"]), 1)
- self.assertEqual(data["data"][0]['markup'], 'foo')
- self.assertEqual(data["data"][0]['link'], 'bar')
-
-
-class TestPostNotifications(hubs.tests.APPTest):
- user = hubs.tests.FakeAuthorization('ralph')
- valid_payload = {
- 'username': user.nickname,
- 'markup': 'foobar',
- 'link': 'baz',
- 'secondary_icon': 'http://placekitten.com/g/200/300',
- 'notif_id': 'reallyuniqueuid'
- }
-
- invalid_payload = {
- 'username': user.nickname,
- }
-
- def test_post_notification_invalid_payload(self):
- with hubs.tests.auth_set(app, self.user):
- resp = self.app.post(
- '/stream/saved/',
- data=json.dumps(self.invalid_payload),
- content_type='application/json')
- self.assertEqual(resp.status_code, 400)
-
- def test_post_notification_valid_payload(self):
- with hubs.tests.auth_set(app, self.user):
- resp = self.app.post(
- '/stream/saved/',
- data=json.dumps(self.valid_payload),
- content_type='application/json')
-
- self.assertEqual(resp.status_code, 200)
- data = json.loads(resp.get_data(as_text=True))
- self.assertTrue(isinstance(data, dict))
- self.assertEqual(data["status"], "OK")
-
- notification = data['data']
- self.assertEqual(notification['markup'], 'foobar')
- self.assertEqual(notification['link'], 'baz')
-
- all_saved = self.user.saved_notifications
- self.assertEqual(len(all_saved), 2)
- all_saved = [s.__json__() for s in all_saved]
- self.assertTrue(any(str(s['markup']) == self.valid_payload['markup']
- for s in all_saved))
- self.assertTrue(any(str(s['link']) == self.valid_payload['link']
- for s in all_saved))
-
-
-class TestDeleteNotifications(hubs.tests.APPTest):
- user = hubs.tests.FakeAuthorization('ralph')
- notification = hubs.models.SavedNotification(
- username='ralph',
- markup='foo',
- link='bar',
- secondary_icon='baz',
- notif_id='qux'
- )
-
- def test_delete_notification(self):
- self.session.add(self.notification)
- self.session.commit()
- idx = self.notification.idx
-
- self.assertIsNotNone(self.notification)
- with hubs.tests.auth_set(app, self.user):
- resp = self.app.delete(
- '/stream/saved/{}/'.format(idx)
- )
-
- self.assertEqual(resp.status_code, 200)
- notification = self.session.query(
- hubs.models.SavedNotification).filter_by(idx=idx).first()
-
- self.assertIsNone(notification)
-
- def test_404_on_bad_idx(self):
- idx = 'thisisastringnotanint'
-
- with hubs.tests.auth_set(app, self.user):
- resp = self.app.delete(
- '/stream/saved/{}/'.format(self.user.nickname, idx)
- )
- self.assertEqual(resp.status_code, 404)
diff --git a/hubs/tests/widgets/test_feed.py b/hubs/tests/widgets/test_feed.py
new file mode 100644
index 0000000..8915ba0
--- /dev/null
+++ b/hubs/tests/widgets/test_feed.py
@@ -0,0 +1,103 @@
+from __future__ import unicode_literals
+
+import json
+
+from mock import Mock, patch
+
+from hubs.app import app
+from hubs.models import SavedNotification, User
+from hubs.tests import FakeAuthorization, widget_instance, auth_set
+from . import WidgetTest
+
+
+class FeedWidgetTestCase(WidgetTest):
+
+ plugin = 'feed' # The name in hubs.widgets.registry
+
+ def setUp(self):
+ super(FeedWidgetTestCase, self).setUp()
+ self.widget = widget_instance('ralph', self.plugin)
+ self.user = FakeAuthorization('ralph')
+
+ @patch("hubs.widgets.feed.views.GetData")
+ def test_existing(self, GetData):
+ func = Mock()
+ GetData.return_value = func
+ func.return_value = [{
+ "foo": "bar",
+ }]
+ response = self.check_url(
+ '/widgets/%s/%i/existing' % (self.plugin, self.widget.idx),
+ self.user)
+ expected_dict = {
+ 'data': [{'foo': 'bar', 'markup': '', 'markup_subjective': ''}],
+ 'status': 'OK',
+ }
+ data = json.loads(response.get_data(as_text=True))
+ self.assertDictEqual(data, expected_dict)
+
+ def test_save_notif(self):
+ payload = {
+ 'username': self.user.nickname,
+ 'markup': 'foobar',
+ 'link': 'baz',
+ 'icon': 'http://placekitten.com/g/200/300',
+ 'notif_id': 'reallyuniqueuid',
+ 'timestamp': 123456789,
+ }
+ with auth_set(app, self.user):
+ resp = self.app.post(
+ '/widgets/%s/%i/saved/' % (self.plugin, self.widget.idx),
+ data=json.dumps(payload),
+ content_type='application/json')
+ self.assertEqual(resp.status_code, 200)
+ data = json.loads(resp.get_data(as_text=True))
+ self.assertTrue(isinstance(data, dict))
+ self.assertEqual(data["status"], "OK")
+
+ notification = data['data']
+ self.assertEqual(notification['markup'], 'foobar')
+ self.assertEqual(notification['link'], 'baz')
+
+ user = User.query.get(self.user.nickname)
+ all_saved = user.saved_notifications
+ self.assertEqual(all_saved.count(), 2)
+ all_saved = [s.to_dict() for s in all_saved]
+ self.assertTrue(any(str(s['markup']) == payload['markup']
+ for s in all_saved))
+ self.assertTrue(any(str(s['link']) == payload['link']
+ for s in all_saved))
+
+ def test_save_notif_invalid(self):
+ with auth_set(app, self.user):
+ resp = self.app.post(
+ '/widgets/%s/%i/saved/' % (self.plugin, self.widget.idx),
+ data=json.dumps({"foo": "bar"}),
+ content_type='application/json')
+ self.assertEqual(resp.status_code, 400)
+
+ def test_delete_notif(self):
+ notification = SavedNotification(
+ username='ralph',
+ markup='foo',
+ link='bar',
+ icon='baz',
+ notif_id='qux',
+ timestamp=123456789,
+ )
+ self.session.add(notification)
+ self.session.commit()
+ with auth_set(app, self.user):
+ resp = self.app.delete(
+ '/widgets/%s/%i/saved/qux' % (self.plugin, self.widget.idx)
+ )
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(
+ SavedNotification.query.filter_by(notif_id="qux").count(), 0)
+
+ def test_delete_bad_id(self):
+ with auth_set(app, self.user):
+ resp = self.app.delete(
+ '/widgets/%s/%i/saved/invalid' % (self.plugin, self.widget.idx)
+ )
+ self.assertEqual(resp.status_code, 404)
From befa006cb3529557df35e534e9e7c317a5b838a1 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 03 2018 10:59:58 +0000
Subject: [PATCH 10/20] Better protection of request errors in the bugzilla widget
---
diff --git a/hubs/widgets/bugzilla/__init__.py b/hubs/widgets/bugzilla/__init__.py
index be52a83..79cc6e0 100644
--- a/hubs/widgets/bugzilla/__init__.py
+++ b/hubs/widgets/bugzilla/__init__.py
@@ -40,7 +40,7 @@ class BaseView(RootWidgetView):
get_issues = GetIssues(instance)
return dict(
username=instance.config["username"],
- issues=get_issues()
+ issues=get_issues() or []
)
@@ -58,7 +58,11 @@ class GetIssues(CachedFunction):
for pkg_name in owned:
if len(issues) == max_num:
break
- pkg_details = pkgwat.api.bugs(pkg_name)
+ try:
+ pkg_details = pkgwat.api.bugs(pkg_name)
+ except ValueError:
+ # Usually a JSON decoding error. Fail now and don't cache.
+ return None
for row in pkg_details['rows']:
if len(issues) == max_num:
break
From 28534fa8baa751b7065b03c582758bac737c0025 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 03 2018 10:59:58 +0000
Subject: [PATCH 11/20] Save the action timestamp too
---
diff --git a/hubs/models/savednotification.py b/hubs/models/savednotification.py
index 76a7257..8b08fb4 100644
--- a/hubs/models/savednotification.py
+++ b/hubs/models/savednotification.py
@@ -48,11 +48,13 @@ class SavedNotification(BASE):
link = sa.Column(sa.Text)
markup = sa.Column(sa.Text, nullable=False)
icon = sa.Column(sa.Text)
+ timestamp = sa.Column(sa.Integer, nullable=False)
def to_dict(self):
return {
'id': self.id,
'created': self.created,
+ 'timestamp': self.timestamp,
'notif_id': self.notif_id,
'link': self.link,
'markup': self.markup,
diff --git a/hubs/models/user.py b/hubs/models/user.py
index cdd13da..c307ee0 100644
--- a/hubs/models/user.py
+++ b/hubs/models/user.py
@@ -43,8 +43,9 @@ class User(BASE):
username = sa.Column(sa.Text, primary_key=True)
fullname = sa.Column(sa.Text)
created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)
- saved_notifications = relation('SavedNotification', backref='user',
- lazy='dynamic')
+ saved_notifications = relation(
+ 'SavedNotification', backref='user', lazy='dynamic',
+ order_by="SavedNotification.created")
def __json__(self):
return {
diff --git a/hubs/static/client/app/widgets/feed/StreamFeed.js b/hubs/static/client/app/widgets/feed/StreamFeed.js
index 1f106ae..8f50bbc 100644
--- a/hubs/static/client/app/widgets/feed/StreamFeed.js
+++ b/hubs/static/client/app/widgets/feed/StreamFeed.js
@@ -61,6 +61,7 @@ export default class StreamFeed extends React.Component {
markup: item.markup,
icon: item.secondary_icon,
notif_id: item.notif_id,
+ timestamp: item.end_time || item.timestamp,
};
// Optimistic update
this.setState((prevState, props) => ({
diff --git a/hubs/tests/__init__.py b/hubs/tests/__init__.py
index e532c7a..b6b9036 100644
--- a/hubs/tests/__init__.py
+++ b/hubs/tests/__init__.py
@@ -62,6 +62,7 @@ class APPTest(unittest.TestCase):
username=user, fullname=fullname)
saved_notif = hubs.models.SavedNotification(
username=user, markup='foo', link='bar', notif_id='baz',
+ timestamp=123456789,
)
self.session.add(saved_notif)
diff --git a/hubs/widgets/feed/views.py b/hubs/widgets/feed/views.py
index 7f8a5ff..a2a7b4c 100644
--- a/hubs/widgets/feed/views.py
+++ b/hubs/widgets/feed/views.py
@@ -40,6 +40,7 @@ class SavedView(WidgetView):
link = data['link']
icon = data['icon']
notif_id = data['notif_id']
+ timestamp = data['timestamp']
except Exception:
return flask.abort(400)
existing = SavedNotification.query.filter_by(
@@ -53,7 +54,8 @@ class SavedView(WidgetView):
markup=markup,
link=link,
icon=icon,
- notif_id=notif_id
+ notif_id=notif_id,
+ timestamp=timestamp,
)
flask.g.db.add(notification)
flask.g.db.commit()
From 9bad5cb462845eaef8d3890343dbfe303ff8a9c3 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 04 2018 11:02:25 +0000
Subject: [PATCH 12/20] Add sensible default to populate.py for commops
---
diff --git a/populate.py b/populate.py
index ba8e517..3c75c79 100755
--- a/populate.py
+++ b/populate.py
@@ -98,6 +98,10 @@ db.commit()
hub = hubs.models.Hub(name='commops', hub_type="team")
db.add(hub)
hub.config["summary"] = 'The Fedora Community Operations Team'
+hub.config["chat_domain"] = 'irc.freenode.net'
+hub.config["chat_channel"] = '#fedora-hubs'
+hub.config["pagure"] = 'fedora-hubs'
+hub.config["calendar"] = 'commops'
widget = hubs.models.Widget(
plugin='rules', index=1, _config=json.dumps({
From 04d310f122f09b0d15d18c78203956f5ef80069b Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 04 2018 11:02:25 +0000
Subject: [PATCH 13/20] Add a button to edit the Stream page layout
---
diff --git a/hubs/static/client/app/components/StreamHeader.js b/hubs/static/client/app/components/StreamHeader.js
index 7a6900f..cfc9381 100644
--- a/hubs/static/client/app/components/StreamHeader.js
+++ b/hubs/static/client/app/components/StreamHeader.js
@@ -1,15 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import Spinner from "./Spinner";
+import EditModeButton from './EditModeButton';
-export default class StreamsHeader extends React.Component {
+class StreamHeader extends React.Component {
render() {
return (
-
My Stream
-
Notifications, actions, and other things of interest to me
+ { this.props.isLoading &&
+
+ }
+
+
+
My Stream
+
Notifications, actions, and other things of interest to me
+
+
+ { this.props.hub.name &&
+
+
+
+ }
+
+
);
}
}
+
+
+const mapStateToProps = (state) => {
+ return {
+ hub: state.entities.hub,
+ isLoading: state.entities.hub.isLoading,
+ }
+};
+
+export default connect(mapStateToProps)(StreamHeader);
diff --git a/hubs/views/api/hub_widget.py b/hubs/views/api/hub_widget.py
index 780ba2e..5943785 100644
--- a/hubs/views/api/hub_widget.py
+++ b/hubs/views/api/hub_widget.py
@@ -20,11 +20,11 @@ log = logging.getLogger(__name__)
def api_widgets(hub_id):
widgets = []
hub = get_hub_by_id(hub_id)
- for widget in registry.values():
- if hub.hub_type == "user" and "user" in widget.hub_types:
- widgets.append(widget.get_props(None))
- if hub.hub_type == "team" and "team" in widget.hub_types:
- widgets.append(widget.get_props(None))
+ widgets = [
+ widget.get_props(None)
+ for widget in registry.values()
+ if hub.hub_type in widget.hub_types
+ ]
return flask.jsonify({"status": "OK", "data": widgets})
diff --git a/hubs/views/user.py b/hubs/views/user.py
index 84fd8f6..5db0e49 100644
--- a/hubs/views/user.py
+++ b/hubs/views/user.py
@@ -18,6 +18,7 @@ def stream():
"hub": flask.url_for("api_hub", hub_id=stream.id),
"hubConfig": flask.url_for("api_hub_config", hub_id=stream.id),
"widgets": flask.url_for("api_hub_widgets", hub_id=stream.id),
+ "availableWidgets": flask.url_for("api_widgets", hub_id=stream.id),
"allGroups": flask.url_for("groups"),
}
flash_messages = [
From 89a98a7eda772db7408da14f5b2e89eae6fe8a9e Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 04 2018 11:02:25 +0000
Subject: [PATCH 14/20] Remove useless query string in redirect
---
diff --git a/hubs/views/root.py b/hubs/views/root.py
index 0134d9f..441372e 100644
--- a/hubs/views/root.py
+++ b/hubs/views/root.py
@@ -16,8 +16,7 @@ log = logging.getLogger("hubs")
@app.route('/')
def index():
if authenticated():
- return flask.redirect(flask.url_for('stream',
- name=flask.g.auth.nickname))
+ return flask.redirect(flask.url_for('stream'))
return flask.render_template(
'index.html'
)
From 9a28a92f7e401c89c8c69f9dcb39c0a7826b17d8 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 04 2018 11:02:26 +0000
Subject: [PATCH 15/20] Share more JS code and move the code around
---
diff --git a/hubs/static/client/app/components/EditModeButton.css b/hubs/static/client/app/components/EditModeButton.css
deleted file mode 100644
index 69930aa..0000000
--- a/hubs/static/client/app/components/EditModeButton.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.EditModeButton {
- margin: 0.25rem 0.5rem
-}
diff --git a/hubs/static/client/app/components/EditModeButton.js b/hubs/static/client/app/components/EditModeButton.js
deleted file mode 100644
index 70eb64d..0000000
--- a/hubs/static/client/app/components/EditModeButton.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import {
- defineMessages,
- FormattedMessage,
- } from 'react-intl';
-import { setEditMode } from "../core/actions/widgets";
-import "./EditModeButton.css";
-
-
-const messages = defineMessages({
- save: {
- id: "hubs.core.EditModeButton.save",
- defaultMessage: "Save changes",
- },
- edit: {
- id: "hubs.core.EditModeButton.edit",
- defaultMessage: "Edit page layout",
- },
-});
-
-
-class EditModeButton extends React.Component {
-
- constructor(props) {
- super(props);
- this.handleClicked = this.handleClicked.bind(this);
- }
-
- handleClicked(e) {
- e.preventDefault();
- this.props.dispatch(setEditMode(!this.props.editMode));
- }
-
- render() {
- if (this.props.editMode) {
- return (
-
- );
- } else {
- return (
-
- );
- }
- }
-}
-EditModeButton.propTypes = {
- editMode: PropTypes.bool,
-}
-EditModeButton.defaultProps = {
- editMode: false
-}
-
-
-const mapStateToProps = (state) => {
- return {
- editMode: state.ui.widgetsEditMode
- }
-};
-
-export default connect(mapStateToProps)(EditModeButton);
diff --git a/hubs/static/client/app/components/HubHeader.css b/hubs/static/client/app/components/HubHeader.css
deleted file mode 100644
index 73ba27c..0000000
--- a/hubs/static/client/app/components/HubHeader.css
+++ /dev/null
@@ -1,24 +0,0 @@
-.HubHeader {
- font-family: 'Open Sans', sans-serif;
- /*
- color: #373a3c;
- width: 100%;
- background: #f3f3f3 none repeat scroll 0 0;
- border-bottom: 1px solid #ddd;
- padding-bottom: .5rem;
- */
-}
-
-.HubHeader .avatar {
- width: 70px;
- height: 70px;
- float: left;
- margin-right: 15px;
- display: inline;
- border: 5px solid #fff;
-}
-
-.HubHeader > .Spinner {
- position: absolute;
- left: 1rem;
-}
diff --git a/hubs/static/client/app/components/HubHeader.js b/hubs/static/client/app/components/HubHeader.js
deleted file mode 100644
index c2edd14..0000000
--- a/hubs/static/client/app/components/HubHeader.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-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 HubStar from './HubStar';
-import { monogramColour } from '../core/utils';
-import "./HubHeader.css";
-
-
-class HubHeader extends React.Component {
- render() {
- const right_width = this.props.hub.config ? 12 - this.props.hub.config.left_width : 4;
- return (
-
- { this.props.isLoading &&
-
- }
- { this.props.hub.name &&
-
-
-
-
- { !this.props.hub.config.avatar ?
-
- {this.props.hub.name.charAt(0).toUpperCase()}
-
- :
-

- }
-
- {this.props.hub.name}
-
-
-
- {this.props.hub.config.summary}
-
-
-
-
-
-
- { this.props.hub.perms.config &&
-
-
-
-
- }
-
-
- }
-
- );
- }
-}
-HubHeader.propTypes = {
- hub: PropTypes.object.isRequired,
- isLoading: PropTypes.bool,
- currentUser: PropTypes.object,
-}
-
-
-
-const mapStateToProps = (state) => {
- return {
- hub: state.entities.hub,
- isLoading: state.entities.hub.isLoading,
- currentUser: state.currentUser,
- }
-};
-
-export default connect(mapStateToProps)(HubHeader);
diff --git a/hubs/static/client/app/components/HubHeader/EditModeButton.css b/hubs/static/client/app/components/HubHeader/EditModeButton.css
new file mode 100644
index 0000000..69930aa
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/EditModeButton.css
@@ -0,0 +1,3 @@
+.EditModeButton {
+ margin: 0.25rem 0.5rem
+}
diff --git a/hubs/static/client/app/components/HubHeader/EditModeButton.js b/hubs/static/client/app/components/HubHeader/EditModeButton.js
new file mode 100644
index 0000000..11a0426
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/EditModeButton.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import {
+ defineMessages,
+ FormattedMessage,
+ } from 'react-intl';
+import { setEditMode } from "../../core/actions/widgets";
+import "./EditModeButton.css";
+
+
+const messages = defineMessages({
+ save: {
+ id: "hubs.core.EditModeButton.save",
+ defaultMessage: "Save changes",
+ },
+ edit: {
+ id: "hubs.core.EditModeButton.edit",
+ defaultMessage: "Edit page layout",
+ },
+});
+
+
+class EditModeButton extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.handleClicked = this.handleClicked.bind(this);
+ }
+
+ handleClicked(e) {
+ e.preventDefault();
+ this.props.dispatch(setEditMode(!this.props.editMode));
+ }
+
+ render() {
+ if (this.props.editMode) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+}
+EditModeButton.propTypes = {
+ editMode: PropTypes.bool,
+}
+EditModeButton.defaultProps = {
+ editMode: false
+}
+
+
+const mapStateToProps = (state) => {
+ return {
+ editMode: state.ui.widgetsEditMode
+ }
+};
+
+export default connect(mapStateToProps)(EditModeButton);
diff --git a/hubs/static/client/app/components/HubHeader/HubAvatar.js b/hubs/static/client/app/components/HubHeader/HubAvatar.js
new file mode 100644
index 0000000..076e837
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/HubAvatar.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { monogramColour } from '../../core/utils';
+
+
+export default class HubAvatar extends React.Component {
+ render() {
+ if (this.props.avatar) {
+ return (
+
+ );
+ } else {
+ const mColour = monogramColour(this.props.name);
+ const className = `monogram-avatar avatar bg-fedora-${mColour} color-fedora-${mColour}-dark`;
+ return (
+
+ {this.props.name.charAt(0).toUpperCase()}
+
+ );
+ }
+ }
+}
diff --git a/hubs/static/client/app/components/HubHeader/HubHeader.css b/hubs/static/client/app/components/HubHeader/HubHeader.css
new file mode 100644
index 0000000..73ba27c
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/HubHeader.css
@@ -0,0 +1,24 @@
+.HubHeader {
+ font-family: 'Open Sans', sans-serif;
+ /*
+ color: #373a3c;
+ width: 100%;
+ background: #f3f3f3 none repeat scroll 0 0;
+ border-bottom: 1px solid #ddd;
+ padding-bottom: .5rem;
+ */
+}
+
+.HubHeader .avatar {
+ width: 70px;
+ height: 70px;
+ float: left;
+ margin-right: 15px;
+ display: inline;
+ border: 5px solid #fff;
+}
+
+.HubHeader > .Spinner {
+ position: absolute;
+ left: 1rem;
+}
diff --git a/hubs/static/client/app/components/HubHeader/HubHeaderLeft.js b/hubs/static/client/app/components/HubHeader/HubHeaderLeft.js
new file mode 100644
index 0000000..9e76ad6
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/HubHeaderLeft.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import HubStats from './HubStats';
+import HubStar from './HubStar';
+import HubAvatar from './HubAvatar';
+
+
+export default class HubHeaderLeft extends React.Component {
+
+ render() {
+ return (
+
+
+
+
+
+ {this.props.hub.name}
+
+
+
+ {this.props.hub.config.summary}
+
+
+
+
+ );
+ }
+}
+HubHeaderLeft.propTypes = {
+ hub: PropTypes.object.isRequired,
+}
diff --git a/hubs/static/client/app/components/HubHeader/HubHeaderRight.js b/hubs/static/client/app/components/HubHeader/HubHeaderRight.js
new file mode 100644
index 0000000..dccf7c3
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/HubHeaderRight.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import HubConfig from '../HubConfig';
+import EditModeButton from './EditModeButton';
+import HubMembership from './HubMembership';
+
+
+export default class HubHeaderRight extends React.Component {
+
+ render() {
+ return (
+
+
+ { this.props.hub.perms.config &&
+
+
+
+
+ }
+
+ );
+ }
+}
+HubHeaderRight.propTypes = {
+ hub: PropTypes.object.isRequired,
+}
diff --git a/hubs/static/client/app/components/HubHeader/HubMembership.js b/hubs/static/client/app/components/HubHeader/HubMembership.js
new file mode 100644
index 0000000..3f56ee1
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/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.type === "user" && this.props.hub.perms.config) {
+ 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.type === "team") {
+ 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/HubHeader/HubStar.css b/hubs/static/client/app/components/HubHeader/HubStar.css
new file mode 100644
index 0000000..63a8d08
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/HubStar.css
@@ -0,0 +1,7 @@
+.HubStar {
+ line-height: 2rem;
+ font-size: 1.2rem;
+ color: #aaa;
+ z-index: 1; /* Above the left menu */
+ vertical-align: middle;
+}
diff --git a/hubs/static/client/app/components/HubHeader/HubStar.js b/hubs/static/client/app/components/HubHeader/HubStar.js
new file mode 100644
index 0000000..d917bd4
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/HubStar.js
@@ -0,0 +1,73 @@
+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() {
+ 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/HubHeader/HubStats.css b/hubs/static/client/app/components/HubHeader/HubStats.css
new file mode 100644
index 0000000..d7d4611
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/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/HubHeader/HubStats.js b/hubs/static/client/app/components/HubHeader/HubStats.js
new file mode 100644
index 0000000..83dd6ce
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/HubStats.js
@@ -0,0 +1,68 @@
+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 "./HubStats.css";
+
+
+export default class HubStats extends React.Component {
+
+ render() {
+ if (!this.props.hub.name) {
+ return null;
+ }
+ return (
+
+ { (this.props.hub.type === "team") &&
+
+ }
+
+ { (this.props.hub.type === "user") &&
+
+ }
+
+ );
+ }
+}
+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/HubHeader/StreamHeaderLeft.js b/hubs/static/client/app/components/HubHeader/StreamHeaderLeft.js
new file mode 100644
index 0000000..7752228
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/StreamHeaderLeft.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+export default class StreamHeaderLeft extends React.Component {
+
+ render() {
+ return (
+
+
My Stream
+
Notifications, actions, and other things of interest to me
+
+
+ );
+ }
+}
diff --git a/hubs/static/client/app/components/HubHeader/StreamHeaderRight.js b/hubs/static/client/app/components/HubHeader/StreamHeaderRight.js
new file mode 100644
index 0000000..ab2a072
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/StreamHeaderRight.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import EditModeButton from './EditModeButton';
+
+
+export default class StreamHeaderRight extends React.Component {
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/hubs/static/client/app/components/HubHeader/index.js b/hubs/static/client/app/components/HubHeader/index.js
new file mode 100644
index 0000000..29ae354
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader/index.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import Spinner from "../Spinner";
+import HubHeaderLeft from "./HubHeaderLeft";
+import HubHeaderRight from "./HubHeaderRight";
+import StreamHeaderLeft from "./StreamHeaderLeft";
+import StreamHeaderRight from "./StreamHeaderRight";
+import "./HubHeader.css";
+
+
+class HubHeader extends React.Component {
+
+ render() {
+ let left_width = 8,
+ HeaderLeft = null,
+ HeaderRight = null;
+ const isLoaded = this.props.hub.name !== null;
+ if (isLoaded) {
+ // It's loaded now
+ if (this.props.hub.type === "stream") {
+ left_width = 8;
+ HeaderLeft = StreamHeaderLeft,
+ HeaderRight = StreamHeaderRight;
+ } else {
+ left_width = this.props.hub.config.left_width;
+ HeaderLeft = HubHeaderLeft,
+ HeaderRight = HubHeaderRight;
+ }
+ }
+ const right_width = 12 - left_width;
+ return (
+
+ { this.props.isLoading &&
+
+ }
+ { isLoaded &&
+
+ }
+
+ );
+ }
+}
+
+
+const mapStateToProps = (state) => {
+ return {
+ hub: state.entities.hub,
+ isLoading: state.entities.hub.isLoading,
+ currentUser: state.currentUser,
+ }
+};
+
+export default connect(mapStateToProps)(HubHeader);
diff --git a/hubs/static/client/app/components/HubMembership.js b/hubs/static/client/app/components/HubMembership.js
deleted file mode 100644
index ed157cd..0000000
--- a/hubs/static/client/app/components/HubMembership.js
+++ /dev/null
@@ -1,173 +0,0 @@
-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.type === "user" && this.props.hub.perms.config) {
- 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.type === "team") {
- 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/HubStar.css b/hubs/static/client/app/components/HubStar.css
deleted file mode 100644
index 63a8d08..0000000
--- a/hubs/static/client/app/components/HubStar.css
+++ /dev/null
@@ -1,7 +0,0 @@
-.HubStar {
- line-height: 2rem;
- font-size: 1.2rem;
- color: #aaa;
- z-index: 1; /* Above the left menu */
- vertical-align: middle;
-}
diff --git a/hubs/static/client/app/components/HubStar.js b/hubs/static/client/app/components/HubStar.js
deleted file mode 100644
index 6419a12..0000000
--- a/hubs/static/client/app/components/HubStar.js
+++ /dev/null
@@ -1,73 +0,0 @@
-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() {
- 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/HubStats.css b/hubs/static/client/app/components/HubStats.css
deleted file mode 100644
index d7d4611..0000000
--- a/hubs/static/client/app/components/HubStats.css
+++ /dev/null
@@ -1,10 +0,0 @@
-.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
deleted file mode 100644
index 4e8c15d..0000000
--- a/hubs/static/client/app/components/HubStats.js
+++ /dev/null
@@ -1,68 +0,0 @@
-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 "./HubStats.css";
-
-
-export default class HubStats extends React.Component {
-
- render() {
- if (!this.props.hub.name) {
- return null;
- }
- return (
-
- { (this.props.hub.type === "team") &&
-
- }
-
- { (this.props.hub.type === "user") &&
-
- }
-
- );
- }
-}
-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/StreamHeader.js b/hubs/static/client/app/components/StreamHeader.js
deleted file mode 100644
index cfc9381..0000000
--- a/hubs/static/client/app/components/StreamHeader.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import Spinner from "./Spinner";
-import EditModeButton from './EditModeButton';
-
-
-class StreamHeader extends React.Component {
-
- render() {
- return (
-
- { this.props.isLoading &&
-
- }
-
-
-
My Stream
-
Notifications, actions, and other things of interest to me
-
-
- { this.props.hub.name &&
-
-
-
- }
-
-
-
- );
- }
-}
-
-
-const mapStateToProps = (state) => {
- return {
- hub: state.entities.hub,
- isLoading: state.entities.hub.isLoading,
- }
-};
-
-export default connect(mapStateToProps)(StreamHeader);
diff --git a/hubs/static/client/app/components/StreamPage.js b/hubs/static/client/app/components/StreamPage.js
index b4b7900..1b54af6 100644
--- a/hubs/static/client/app/components/StreamPage.js
+++ b/hubs/static/client/app/components/StreamPage.js
@@ -6,7 +6,7 @@ import {
FormattedMessage,
} from 'react-intl';
import PageStructure from './PageStructure';
-import StreamHeader from './StreamHeader';
+import HubHeader from './HubHeader';
import WidgetsArea from './WidgetsArea';
import { fetchHub } from '../core/actions/hub';
@@ -22,11 +22,7 @@ class StreamPage extends React.Component {
-
-
-
-
+
}
content={
From 142f1b21af6a6efb72a7365bc1da266f6c32bcec Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 04 2018 11:02:26 +0000
Subject: [PATCH 16/20] Solve the issue of the stream's action feed not reloading
---
diff --git a/hubs/widgets/feed/functions.py b/hubs/widgets/feed/functions.py
index 779c7d5..7309be2 100644
--- a/hubs/widgets/feed/functions.py
+++ b/hubs/widgets/feed/functions.py
@@ -4,6 +4,7 @@ from __future__ import unicode_literals, absolute_import
import fedmsg.meta
from hubs.feed import Activity, add_notif_id
+from hubs.models import Hub
from hubs.widgets.caching import CachedFunction
@@ -23,3 +24,27 @@ class GetData(CachedFunction):
return False
hub_id = self.instance.hub.id
return (hub_id in message["_hubs"])
+
+
+class GetStreamExisting(CachedFunction):
+ """Dummy function to trigger a reload of the Stream's feed widget.
+
+ The user's stream page has a feed widget that gets the actions from the
+ users's public hub's feed widget, and not its own. As a result, no SSE
+ event will be emitted if an action is added to the public hub, and as a
+ result the widget won't be live-reloaded.
+
+ This dummy cached function caches nothing and triggers a reload when the
+ user's public hub gets a new action.
+ """
+
+ def execute(self):
+ return None
+
+ def should_invalidate(self, message):
+ if "_hubs" not in message:
+ return False
+ if self.instance.hub.hub_type != "stream":
+ return False # Only trigger for stream hubs.
+ user_hub = Hub.by_name(self.instance.hub.name, "user")
+ return (user_hub.id in message["_hubs"])
From f59da62a535b4003999af58259f30407b97544cf Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 04 2018 11:02:26 +0000
Subject: [PATCH 17/20] Sort a user's SavedNotifications by reverse creation order
---
diff --git a/hubs/models/user.py b/hubs/models/user.py
index c307ee0..8536167 100644
--- a/hubs/models/user.py
+++ b/hubs/models/user.py
@@ -33,6 +33,9 @@ from hubs.database import BASE, Session
from hubs.utils import username2avatar
from hubs.signals import user_created
+from .savednotification import SavedNotification
+
+
log = logging.getLogger(__name__)
@@ -45,7 +48,7 @@ class User(BASE):
created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)
saved_notifications = relation(
'SavedNotification', backref='user', lazy='dynamic',
- order_by="SavedNotification.created")
+ order_by=SavedNotification.created.desc())
def __json__(self):
return {
From 8385703f5ce7c8dd0766b4220af7e05887e12a6d Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 04 2018 11:02:26 +0000
Subject: [PATCH 18/20] Only report success when both subrequests are successful
---
diff --git a/hubs/static/client/app/components/feed/ItemsGetter.js b/hubs/static/client/app/components/feed/ItemsGetter.js
index 27f0975..2911f5c 100644
--- a/hubs/static/client/app/components/feed/ItemsGetter.js
+++ b/hubs/static/client/app/components/feed/ItemsGetter.js
@@ -44,29 +44,35 @@ class ItemsGetter extends React.Component {
}
loadFromServer() {
- if (this.props.onRequestStart) {
- this.props.onRequestStart();
- }
- this.setState({isLoading: true});
if (this.serverRequest &&
this.serverRequest.readyState !== XMLHttpRequest.DONE) {
this.serverRequest.abort();
}
- this.serverRequest = $.ajax({
- url: this.props.url,
- method: 'GET',
- dataType: 'json',
- cache: false,
- success: (data, textStatus, jqXHR) => {
- this.props.handleData(data.data);
- },
- error: (xhr, status, err) => {
- console.error(status, err.toString());
- },
- complete: (xhr, status) => {
- this.setState({isLoading: false});
- },
+ const promise = new Promise((resolve, reject) => {
+ this.setState({isLoading: true});
+ this.serverRequest = $.ajax({
+ url: this.props.url,
+ method: 'GET',
+ dataType: 'json',
+ cache: false,
+ success: (data, textStatus, jqXHR) => {
+ this.props.handleData(data.data);
+ resolve(data.data);
+ },
+ error: (xhr, status, err) => {
+ const errText = err.toString();
+ console.error(status, errText);
+ reject(errText);
+ },
+ complete: (xhr, status) => {
+ this.setState({isLoading: false});
+ },
+ });
});
+ if (this.props.onRequestStart) {
+ this.props.onRequestStart(promise);
+ }
+ return promise;
}
showErrorMessage() {
@@ -104,6 +110,8 @@ class ItemsGetter extends React.Component {
ItemsGetter.propTypes = {
useSSE: PropTypes.bool,
needsUpdate: PropTypes.bool,
+ onRequestStart: PropTypes.func,
+ handleData: PropTypes.func,
}
ItemsGetter.defaultProps = {
useSSE: false,
diff --git a/hubs/static/client/app/widgets/feed/StreamFeed.js b/hubs/static/client/app/widgets/feed/StreamFeed.js
index 8f50bbc..9d87e83 100644
--- a/hubs/static/client/app/widgets/feed/StreamFeed.js
+++ b/hubs/static/client/app/widgets/feed/StreamFeed.js
@@ -27,31 +27,60 @@ export default class StreamFeed extends React.Component {
this.handleSavedData = this.handleSavedData.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleUnsave = this.handleUnsave.bind(this);
+ this.handleStreamServerRequestStart = this.handleStreamServerRequestStart.bind(this);
+ this.handleActionServerRequestStart = this.handleActionServerRequestStart.bind(this);
+ this.handleServerRequestStart = this.handleServerRequestStart.bind(this);
+ this.makeServerRequestCompletePromise = this.makeServerRequestCompletePromise.bind(this);
+ this.serverRequestCompletePromises = [];
+ }
+
+ makeServerRequestCompletePromise() {
+ // Only call this.props.onServerRequestStop when both server requests
+ // are done, otherwise the first to reload will cancel the other's reload.
+ const promise = Promise.all([
+ ...this.serverRequestCompletePromises
+ ]).finally(
+ () => {
+ this.props.onServerRequestStop();
+ }
+ );
+ this.serverRequestCompletePromises = [];
+ }
+
+ handleStreamServerRequestStart(promise) {
+ this.serverRequestCompletePromises.push(promise);
+ this.handleServerRequestStart();
+ }
+ handleActionServerRequestStart(promise) {
+ this.serverRequestCompletePromises.push(promise);
+ this.handleServerRequestStart();
+ }
+ handleServerRequestStart() {
+ if (this.serverRequestCompletePromises.length >= 2) {
+ // Both requests have started, make the envelope promise that will fire
+ // when they both resolve.
+ this.props.onServerRequestStart()
+ this.makeServerRequestCompletePromise();
+ }
}
handleStreamData(data) {
this.setState({
notifItems: data
- },
- this.props.onServerRequestStop
- );
+ });
}
handleActionData(data) {
this.setState({
actionItems: data,
- },
- this.props.onServerRequestStop
- );
+ });
}
handleSavedData(data) {
this.setState({
savedItems: data,
savedItemsNeedUpdate: false,
- },
- this.props.onServerRequestStop
- );
+ });
}
handleSave(item) {
@@ -144,7 +173,7 @@ export default class StreamFeed extends React.Component {
useSSE={true}
handleData={this.handleStreamData}
needsUpdate={this.props.needsUpdate}
- onRequestStart={this.props.onServerRequestStart}
+ onRequestStart={this.handleStreamServerRequestStart}
>
Date: Jan 04 2018 11:02:26 +0000
Subject: [PATCH 19/20] Minor modernizing of the JS code
---
diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js
index 9de4e0b..fe88860 100644
--- a/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js
+++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js
@@ -49,11 +49,11 @@ export default class ChatPanel extends React.Component {
}
render() {
- var networks = [],
- channel = "#",
- stillLoading = (
- typeof this.props.hubConfig.chat_channel === "undefined"
- );
+ let networks = [],
+ channel = "#";
+ const stillLoading = (
+ typeof this.props.hubConfig.chat_channel === "undefined"
+ );
if (this.props.hubConfig.chat_channel) {
channel = "#" + this.props.hubConfig.chat_channel.replace(/^#*/, "");
@@ -61,13 +61,11 @@ export default class ChatPanel extends React.Component {
if (this.props.globalConfig.chat_networks) {
networks = this.props.globalConfig.chat_networks.map(
- function(network, index) {
- return (
-
- );
- }.bind(this)
+ (network, index) => (
+
+ )
);
}
From 62e1b5b85917e5111261f9fb9897e48f38a5e7ba Mon Sep 17 00:00:00 2001
From: Aurélien Bompard
Date: Jan 04 2018 11:02:37 +0000
Subject: [PATCH 20/20] Fix a config key name
---
diff --git a/hubs/tests/test_feed.py b/hubs/tests/test_feed.py
index 69043b5..23f3cc0 100644
--- a/hubs/tests/test_feed.py
+++ b/hubs/tests/test_feed.py
@@ -210,7 +210,7 @@ class GetHubsForMsgTestCase(APPTest):
def test_group_hub_irc(self):
test_hub = Hub(name="testhub", hub_type="team")
self.session.add(test_hub)
- test_hub.config["chat_network"] = "irc.freenode.net"
+ test_hub.config["chat_domain"] = "irc.freenode.net"
test_hub.config["chat_channel"] = "testchannel"
messages = [{
"msg_id": "testmsg",