#323 Redesign the widget system to be class-based
Merged 7 years ago by abompard. Opened 7 years ago by abompard.
abompard/fedora-hubs widget-class  into  develop

file modified
+18 -13
@@ -13,12 +13,14 @@ 

  Authors:    Ralph Bean <rbean@redhat.com>

  """

  

- from __future__ import unicode_literals

+ from __future__ import unicode_literals, print_function

  

  import sys

  import dogpile.cache

  

- import hubs.widgets.base

+ import hubs.app

+ import hubs.widgets

+ import hubs.widgets.caching

  import hubs.models

  

  import fedmsg.config
@@ -27,18 +29,21 @@ 

  

  empty, full = 0, 0

  session = hubs.models.init(config['hubs.sqlalchemy.uri'])

- for widget in session.query(hubs.models.Widget).all():

-     key = hubs.widgets.base.cache_key_generator(widget, **widget.config)

-     result = hubs.widgets.base.cache.get(key, ignore_expiration=True)

-     if isinstance(result, dogpile.cache.api.NoValue):

-         empty += 1

-     else:

-         full += 1

-     sys.stdout.write('.')

-     sys.stdout.flush()

+ 

+ # Register widgets

+ hubs.widgets.registry.register_list(hubs.app.app.config["WIDGETS"])

+ 

+ for w_instance in session.query(hubs.models.Widget).all():

+     for fn_name, fn_class in w_instance.module.get_cached_functions().items():

+         if fn_class(w_instance).is_cached():

+             full += 1

+         else:

+             empty += 1

+         sys.stdout.write('.')

+         sys.stdout.flush()

  sys.stdout.write('\n')

  sys.stdout.flush()

  

  total = empty + full

- print full, "cache values found. ", empty, "are missing."

- print full / float(total) * 100, "percent cache coverage."

+ print(full, "cache values found. ", empty, "are missing.")

+ print(full / float(total) * 100, "percent cache coverage.")

file added
+42
@@ -0,0 +1,42 @@ 

+ Developer Interfaces

+ ====================

+ 

+ This documents ways developers can interface with Fedora Hubs.

+ 

+ .. _widgets-api:

+ 

+ 

+ Widgets

+ -------

+ 

+ .. automodule:: hubs.widgets

+    :members:

+    :show-inheritance:

+ 

+ Widgets registry

+ ^^^^^^^^^^^^^^^^

+ 

+ .. automodule:: hubs.widgets.registry

+    :members:

+    :show-inheritance:

+ 

+ Widget class

+ ^^^^^^^^^^^^

+ 

+ .. automodule:: hubs.widgets.base

+    :members:

+    :show-inheritance:

+ 

+ Widget view

+ ^^^^^^^^^^^

+ 

+ .. automodule:: hubs.widgets.view

+    :members:

+    :show-inheritance:

+ 

+ Caching

+ ^^^^^^^

+ 

+ .. automodule:: hubs.widgets.caching

+    :members:

+    :show-inheritance:

file modified
+10 -5
@@ -16,20 +16,25 @@ 

  # add these directories to sys.path here. If the directory is relative to the

  # documentation root, use os.path.abspath to make it absolute, like shown here.

  #

- # import os

- # import sys

- # sys.path.insert(0, os.path.abspath('.'))

+ import os

+ import sys

+ sys.path.insert(0, os.path.abspath('..'))

  

  # -- General configuration ------------------------------------------------

  

  # If your documentation needs a minimal Sphinx version, state it here.

  #

- # needs_sphinx = '1.0'

+ needs_sphinx = '1.3'

  

  # Add any Sphinx extension module names here, as strings. They can be

  # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom

  # ones.

- extensions = []

+ extensions = [

+     'sphinx.ext.autodoc',

+     'sphinx.ext.doctest',

+     'sphinx.ext.napoleon',

+     'sphinx.ext.viewcode',

+ ]

  

  # Add any paths that contain templates here, relative to this directory.

  templates_path = ['_templates']

file modified
+3 -60
@@ -344,67 +344,10 @@ 

  Stubbing out a new Widget

  =========================

  

- You write a new widget in the ``hubs/widgets/`` directory and must declare it

- in the registry dict in ``hubs/widgets/__init__.py``.

+ Widgets can be added and removed from a Hub to provide a customized experience

+ for each user. To learn how to implement a new widget, consult the

+ :ref:`widgets-api` documentation.

  

- In order to be valid, a widget must have:

- 

- - A ``data(session, widgets, **kwargs)`` function that returns a

-   jsonifiable dict of data.  This will get cached -- more on that later.

- - A ``template`` object that is a jinja2 template for that widget.

- - Optionally, a ``chrome`` decorator.

- - A ``should_invalidate(message, session, widget)`` function that will be used to

-   *potentially* invalidate the widget's cache. That function will get called by

-   a backend daemon listening for fedmsg messages so when you update your group

-   memberships in FAS, a fedmsg message hits the fedora-hubs backend and returns

-   True if the lookup value should be nuked/refreshed in memcached (or some

-   other store).

- 

- If you want to try making a new widget:

- 

- - Copy an existing one in ``hubs/widgets/``

- - Add it to the registry in ``hubs/widgets/__init__.py``

- - If you want it to show up on a **user** page, add it to ``hubs/defaults.py``

-   in the ``add_user_widgets`` function.

- - If you want it to show up on **group** pages, add it to ``populate.py``.

- 

- Destroy your database, rebuild it, and re-run the app.  Your widget should show up.

- 

- Widget-specific views

- ---------------------

- A widget may also register additional routes by using the

- ``hubs.widgets.base.widget_route`` decorator. There are some differences from

- the usual ``route`` decorator or the ``add_url_rule`` function:

- 

- - The view function will be passed the database ``session`` and ``widget``

-   instances as first arguments, and then the URL kwargs.

- - The endpoint will be prefixed with ``<widget_name>_``, for example

-   ``meetings_``. Remember that when you want to reverse the URL with

-   ``url_for``.

- - When reversing the URL, you need to pass the ``hub`` and ``idx`` kwargs.

- 

- Example: consider this additional method that needs to be exported as a view

- in the ``meetings`` plugin::

- 

-     from hubs.widgets.base import widget_route

-     @widget_route(rule="search/<requester>/", methods=["GET", "POST"])

-     def search(session, widget, requester):

-         # Do something, probably using the "requester" variable.

-         return flask.jsonify({"hope_sources": ["Obi-Wan Kenobi"]})

- 

- Then, a valid call to ``url_for`` to reverse this URL would look like::

- 

-     url_for("meetings_search", hub=widget.hub.name, idx=widget.idx, requester="Leia")

- 

- Behind the scenes, the ``widget_route`` decorator adds a ``ROUTES`` global

- variable in the widget module, that contains dictionaries representing the

- arguments passed to Flask's `add_url_rule

- <http://flask.pocoo.org/docs/latest/api/#flask.Flask.add_url_rule>`_ function.

- 

- If you want more control, you can edit this ``ROUTES`` list directly, but there

- are a difference from the basic ``add_url_rule`` function: the view function

- name must be given with the dict key ``view_func_name``, and not ``view_func``

- as in the ``add_url_rule`` function.

  

  

  A proposal, client-side templates

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

  

     overview

     dev-guide

+    api

  

  

  Indices and tables

file modified
+7
@@ -104,5 +104,12 @@ 

      else:

          flask.g.auth = munch.Munch(logged_in=False)

  

+ # Register widgets

+ import hubs.widgets

+ hubs.widgets.registry.register_list(app.config["WIDGETS"])

  

+ # Register routes

  import hubs.views

+ 

+ # Add widget-specific routes

+ hubs.widgets.registry.register_routes(app)

file modified
+13 -17
@@ -58,25 +58,21 @@ 

      log.debug("Randomizing list of all widgets.")

      random.shuffle(widgets)

  

+     def is_widget_update(msg, widget):

+         """Always rebuild the cache when the widget config is updated."""

+         return msg['topic'].endswith('hubs.widget.update') \

+             and msg['msg']['widget']['id'] == widget.idx

+ 

      log.debug("Checking should_invalidate for all widgets.")

      for widget in widgets:

- 

-         check = widget.module.should_invalidate

- 

-         # Do some pre-flight checks before calling should_invalidate

-         if hasattr(check, 'hints'):

-             if check.hints['topics']:

-                 if topic not in check.hints['topics']:

-                     continue

-             if check.hints['categories']:

-                 if category not in check.hints['categories']:

-                     continue

- 

-         if check(msg, session, widget):

-             yield retask.task.Task(json.dumps({

-                 'idx': widget.idx,

-                 'msg_id': msg['msg_id'],

-             }))

+         for fn_name, fn_class in widget.module.get_cached_functions().items():

+             if is_widget_update(msg, widget) or \

+                     fn_class(widget).should_invalidate(msg):

+                 yield retask.task.Task(json.dumps({

+                     'idx': widget.idx,

+                     'fn_name': fn_name,

+                     'msg_id': msg['msg_id'],

+                 }))

  

  

  def main(args):

file modified
+7 -9
@@ -44,24 +44,22 @@ 

  log = logging.getLogger('hubs.backend.worker')

  

  

- def work(session, widget_idx):

+ def work(widget_idx, fn_name):

      # Get a real widget, because we update last_refreshed on it.

      widget = hubs.models.Widget.by_idx(widget_idx)

-     log.info("! Invalidating cache for %r" % widget)

+     fn_class = widget.module.get_cached_functions()[fn_name]

+     log.info("! Rebuilding cache for %r:%r" % (widget, cached_fn))

      # Invalidate the cache...

-     hubs.widgets.base.invalidate_cache(widget, **widget.config)

-     # Rebuild it.

-     log.info("! Rebuilding cache for %r" % widget)

-     widget.module.data(session, widget, **widget.config)

+     fn_class(widget).rebuild()

      # TODO -- fire off an EventSource notice that we updated stuff

  

  

- def handle(idx):

+ def handle(idx, fn_name):

      session = hubs.models.init(config['hubs.sqlalchemy.uri'])

  

      try:

          with hubs.app.app.app_context():  # so url_for works

-             work(session, idx)

+             work(idx, fn_name)

          session.commit()  # transaction is committed here

      except:

          session.rollback()  # rolls back the transaction
@@ -91,7 +89,7 @@ 

              log.info("Working on %r.  (backlog is %r)" % (task, queue.length))

              item = json.loads(task.data)

              idx = item['idx']  # A widget ID

-             handle(idx)

+             handle(item['idx'], item['fn_name'])

              log.debug("  Done.")

      except KeyboardInterrupt:

          pass

file modified
+26
@@ -32,3 +32,29 @@ 

  # TODO - instead of 'develop', use the version from pkg_resources to figure out

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

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

+ 

+ 

+ WIDGETS = [

+     'hubs.widgets.about:About',

+     'hubs.widgets.badges:Badges',

+     'hubs.widgets.bugzilla:Bugzilla',

+     'hubs.widgets.contact:Contact',

+     'hubs.widgets.dummy:Dummy',

+     'hubs.widgets.library:Library',

+     'hubs.widgets.linechart:Linechart',

+     'hubs.widgets.fedmsgstats:FedmsgStats',

+     'hubs.widgets.feed:Feed',

+     'hubs.widgets.fhosted:FedoraHosted',

+     'hubs.widgets.github_pr:GitHubPRs',

+     'hubs.widgets.githubissues:GitHubIssues',

+     'hubs.widgets.meetings:Meetings',

+     'hubs.widgets.memberships:Memberships',

+     'hubs.widgets.pagure_pr:PagurePRs',

+     'hubs.widgets.pagureissues:PagureIssues',

+     'hubs.widgets.rules:Rules',

+     'hubs.widgets.stats:Stats',

+     'hubs.widgets.sticky:Sticky',

+     'hubs.widgets.subscriptions:Subscriptions',

+     'hubs.widgets.workflow.pendingacls:PendingACLs',

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

+     ]

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

          plugin='meetings', index=-1,

          _config=json.dumps({

              'calendar': 'Fedora release',

+             'n_meetings': 4,

          }))

      hub.widgets.append(widget)

  

file removed
-37
@@ -1,37 +0,0 @@ 

- from __future__ import unicode_literals

- 

- import decorator

- 

- import fedmsg.config

- 

- import logging

- log = logging.getLogger('hubs.hinting')

- 

- 

- def hint(topics=None, categories=None, usernames=None, ubiquitous=False):

-     topics = topics or []

-     categories = categories or []

-     default_usernames = lambda x: []

-     usernames = usernames or default_usernames

-     ubiquitous = ubiquitous

- 

-     @decorator.decorator

-     def wrapper(fn, *args, **kwargs):

-         return fn(*args, **kwargs)

- 

-     def wrapper_wrapper(fn):

-         wrapped = wrapper(fn)

-         wrapped.hints = dict(

-             topics=topics,

-             categories=categories,

-             usernames_function=usernames,

-             ubiquitous=ubiquitous,

-         )

-         return wrapped

- 

-     return wrapper_wrapper

- 

- 

- def prefixed(topic, prefix='org.fedoraproject'):

-     config = fedmsg.config.load_config()  # This is memoized for us.

-     return '.'.join([prefix, config['environment'], topic])

file modified
+10 -10
@@ -30,6 +30,7 @@ 

  import random

  

  import bleach

+ import dogpile

  import sqlalchemy as sa

  from sqlalchemy import create_engine

  from sqlalchemy.ext.declarative import declarative_base
@@ -292,10 +293,11 @@ 

  

  

  def _config_default(context):

-     plugin_name = context.current_parameters['plugin']

-     plugin = hubs.widgets.registry[plugin_name]

-     arguments = getattr(plugin.data, 'widget_arguments', [])

-     return json.dumps(dict([(arg.name, arg.default) for arg in arguments]))

+     widget_name = context.current_parameters['plugin']

+     widget = hubs.widgets.registry[widget_name]

+     return json.dumps(dict([

+         (param.name, param.default) for param in widget.get_parameters()

+         ]))

  

  

  class Widget(BASE):
@@ -334,8 +336,11 @@ 

      def __json__(self):

          session = object_session(self)

          module = hubs.widgets.registry[self.plugin]

-         data = module.data(session, self, **self.config)

+         root_view = module.get_views()["root"](module)

+         data = root_view.get_context(self)

+         data.update(root_view.get_extra_context(self))

          data.pop('widget', None)

+         data.pop('widget_instance', None)

          return {

              'id': self.idx,

              # TODO -- use flask.url_for to get the url for this widget
@@ -355,11 +360,6 @@ 

      def module(self):

          return hubs.widgets.registry[self.plugin]

  

-     def render(self):

-         session = object_session(self)

-         render = hubs.widgets.render

-         return render(self.module, session, self, **self.config)

- 

  

  class User(BASE):

      __tablename__ = 'users'

file modified
+12 -16
@@ -8,7 +8,7 @@ 

        <div class="modal-header">

          <button type="button" class="close" data-dismiss="modal">&times;</button>

          <h4 class="modal-title">

-           Adding widget {% if widget %}"{{ widget_name }}"{% endif -%} to hub: {{ hub.name }}

+           Adding {% if widget %}widget "{{ widget.name }}"{% else %}a widget{% endif %} to hub: {{ hub.name }}

          </h4>

        </div>

        <div class="modal-body">
@@ -19,25 +19,21 @@ 

              {% endfor %}

          </select>

          {% elif widget %}

-         <input name="widget_name" type="hidden" value="{{ widget_name }}">

+         <input name="widget_name" type="hidden" value="{{ widget.name }}">

          <input name="position" type="hidden" value="{{ side }}">

-         {% if widget.data.widget_arguments %}

-           {% for arg in widget.data.widget_arguments %}

+           {% for param in widget.get_parameters() %}

              <fieldset class="form-group ">

-               <strong>{{ arg.name | capitalize }}</strong>

-               <input id="{{ arg.name }}" class="form-control" type="text"

-                 value="{{ arg.default if arg.default else '' }}"

-                 name="{{ arg.name }}" />

+               <strong>{{ param.label | capitalize }}</strong>

+               <input id="{{ param.name }}" class="form-control" type="text"

+                 value="{{ param.default if param.default else '' }}"

+                 name="{{ param.name }}" />

+               {% if param.help %}

+                 <small class="text-muted">{{ param.help }}</small>

+               {% endif %}

              </fieldset>

-             {% if arg.help %}

-               <div>

-                 <small class="text-muted">{{ arg.help }}</small>

-               </div>

-             {% endif %}

+           {% else %}

+             <p>Nothing to configure</p>

            {% endfor %}

-         {% else %}

-           <p>Nothing to configure</p>

-         {% endif %}

          {% endif %}

        </div>

        <div class="modal-footer">

file modified
+11 -15
@@ -8,29 +8,25 @@ 

          <h4 class="modal-title">Configure {{ name }}</h4>

        </div>

        <div class="modal-body">

-       {% if widget.module.data.widget_arguments %}

-         {% for arg in widget.module.data.widget_arguments %}

-           <fieldset class="form-group ">

-             <strong>{{ arg.name | capitalize }}</strong>

-             <input id="{{ arg.name }}" class="form-control" type="text"

-               value="{{ widget.config.get(arg.name, arg.default if arg.default else '') }}"

-               name="{{ arg.name }}" />

-           </fieldset>

-           {% if arg.help %}

-             <div>

-               <small class="text-muted">{{ arg.help }}</small>

-             </div>

+       {% for param in widget.module.get_parameters() %}

+         <fieldset class="form-group ">

+           <strong>{{ param.label | capitalize }}</strong>

+           <input id="{{ param.name }}" class="form-control" type="text"

+               value="{{ widget.config.get(param.name, param.default if param.default else '') }}"

+               name="{{ param.name }}" />

+           {% if param.help %}

+           <small class="text-muted">{{ param.help }}</small>

            {% endif %}

-         {% endfor %}

+         </fieldset>

        {% else %}

          <p>Nothing to configure</p>

-       {% endif %}

+       {% endfor %}

        </div>

        <div class="modal-footer">

          <button type="button" class="btn btn-default" data-dismiss="modal">

            Close

          </button>

-         {% if widget.module.data.widget_arguments %}

+         {% if widget.module.get_parameters() %}

          <button type="submit" class="btn btn-default">

            Save

          </button>

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

  <div id="left_widgets">

  {% for widget in hub.left_widgets %}

    <div id="widget-{{ widget.idx }}" class="widget row"

-        data-url="{{ url_for('widget_render', hub=hub.name, idx=widget.idx) }}"

+        data-url="{{ url_for('%s_root' % widget.plugin, hub=hub.name, idx=widget.idx) }}"

         ></div>

  {% endfor %}

  </div>

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

  <div id="right_widgets">

    {% for widget in hub.right_widgets %}

    <div id="widget-{{ widget.idx }}" class="widget row"

-        data-url="{{ url_for('widget_render', hub=hub.name, idx=widget.idx) }}"

+        data-url="{{ url_for('%s_root' % widget.plugin, hub=hub.name, idx=widget.idx) }}"

         ></div>

    {% endfor %}

  </div>

file modified
+1 -1
@@ -35,7 +35,7 @@ 

          self.app = hubs.app.app.test_client()

          self.app.testing = True

          self.session = hubs.app.session

-         from hubs.widgets.base import cache

+         from hubs.widgets.caching import cache

          cache.configure(backend='dogpile.cache.null',

                          replace_existing_backend=True)

          self.populate()

@@ -223,7 +223,7 @@ 

          result = self.app.get('/ralph/add?position=right',

                                follow_redirects=True)

          self.assertEqual(result.status_code, 200)

-         expected_str = 'Adding widget to hub: ralph'

+         expected_str = 'Adding a widget to hub: ralph'

          self.assertIn(expected_str, result.get_data(as_text=True))

          expected_str = "url: 'add/' + $('#widget').val() + '?position=right',"

          self.assertIn(expected_str, result.get_data(as_text=True))
@@ -337,7 +337,7 @@ 

          with tests.auth_set(app, user):

              url = '/ralph/add/about?position=right'

              result = self.app.get(url)

-             self.assertIn('Adding widget "about"to hub: ralph',

+             self.assertIn('Adding widget "about" to hub: ralph',

                            result.get_data(as_text=True))

  

      def test_hub_add_widget_invalid_side(self):
@@ -361,7 +361,7 @@ 

              result = self.app.get(url)

              self.assertEqual(result.status_code, 302)

              expected_str = 'https://pagure.io/fedora-hubs/' \

-                            'blob/develop/f/hubs/widgets/about.py'

+                            'blob/develop/f/hubs/widgets/about/__init__.py'

              self.assertIn(expected_str, result.get_data(as_text=True))

  

      def test_source_name_not_existent(self):

@@ -0,0 +1,140 @@ 

+ from __future__ import unicode_literals

+ 

+ import six

+ 

+ from hubs.widgets.base import Widget

+ from hubs.widgets.caching import CachedFunction

+ from hubs.widgets.view import WidgetView

+ 

+ from mock import Mock

+ from hubs.tests import APPTest

+ #import hubs.models

+ 

+ 

+ class TestingWidget(Widget):

+     name = "testing"

+ 

+ class TestingView(WidgetView):

+     name = "root"

+ 

+ class TestingFunction(CachedFunction):

+     pass

+ 

+ 

+ class WidgetTest(APPTest):

+ 

+     def setUp(self):

+         super(WidgetTest, self).setUp()

+         # Backup the URL map

+         self._old_url_map = (

+             self.app.application.url_map._rules[:],

+             self.app.application.url_map._rules_by_endpoint.copy()

+             )

+ 

+     def tearDown(self):

+         # Restore the URL map

+         self.app.application.url_map._rules = self._old_url_map[0]

+         self.app.application.url_map._rules_by_endpoint = self._old_url_map[1]

+         super(WidgetTest, self).tearDown()

+ 

+     def test_validate(self):

+         class LocalTestWidget(Widget):

+             views = {}

+             def get_views(self):

+                 return self.views

+         test_widget = LocalTestWidget()

+         self.assertRaisesRegexp(

+             AttributeError, '.* "name" .*', test_widget.validate)

+         test_widget.name = "invalid name \xe2\x98\xba"

+         self.assertRaisesRegexp(

+             AttributeError, '^invalid widget name: ', test_widget.validate)

+         test_widget.name = "localtest"

+         self.assertRaisesRegexp(

+             AttributeError, '"position" .*', test_widget.validate)

+         test_widget.position = "invalid"

+         self.assertRaisesRegexp(

+             AttributeError, '"position" .*', test_widget.validate)

+         test_widget.position = "both"

+         self.assertRaisesRegexp(

+             AttributeError, '.* "root" .*', test_widget.validate)

+         test_widget.views["root"] = Mock()

+         test_widget.views["root"].url_rules = ["/invalid"]

+         self.assertRaisesRegexp(

+             AttributeError, '.* "/" .*', test_widget.validate)

+         test_widget.views["root"].url_rules = ["/"]

+         try:

+             test_widget.validate()

+         except AttributeError as e:

+             self.fail(e)

+ 

+     def test_list_views(self):

+         testing_widget = TestingWidget()

+         self.assertEqual(

+             testing_widget.get_views(),

+             {"root": TestingView}

+             )

+ 

+     def test_list_views_relative(self):

+         testing_widget = TestingWidget()

+         testing_widget.views_module = ".nowhere"

+         self.assertRaises(AttributeError, testing_widget.get_views)

+         if six.PY2:

+             # On Python2, setting the import module to "." raises a KeyError,

+             # so we can't test.

+             return

+         testing_widget.views_module = "."

+         self.assertEqual(

+             testing_widget.get_views(),

+             {"root": TestingView}

+             )

+ 

+     def test_list_functions(self):

+         testing_widget = TestingWidget()

+         self.assertEqual(

+             testing_widget.get_cached_functions(),

+             {"TestingFunction": TestingFunction}

+             )

+ 

+     def test_list_functions_relative(self):

+         testing_widget = TestingWidget()

+         testing_widget.cached_functions_module = ".nowhere"

+         self.assertRaises(AttributeError, testing_widget.get_cached_functions)

+         if six.PY2:

+             # On Python2, setting the import module to "." raises a KeyError,

+             # so we can't test.

+             return

+         testing_widget.cached_functions_module = "."

+         self.assertEqual(

+             testing_widget.get_cached_functions(),

+             {"TestingFunction": TestingFunction}

+             )

+ 

+     def test_list_views(self):

+         class TestView1(WidgetView):

+             name = "root"

+             url_rules = ["/", "/test-1/"]

+         class TestView2(WidgetView):

+             name = "test2"

+             url_rules = ["/test-2"]

+         testing_widget = TestingWidget()

+         testing_widget.get_views = Mock()

+         testing_widget.get_views.return_value = {

+             "root": TestView1(testing_widget),

+             "test2": TestView2(testing_widget),

+         }

+         testing_widget.register_routes(self.app.application)

+         # TestView1

+         self.assertIn("testing_root",

+                       self.app.application.url_map._rules_by_endpoint)

+         self.assertIn("testing_root", self.app.application.view_functions)

+         rules = list(self.app.application.url_map.iter_rules(endpoint="testing_root"))

+         self.assertEqual(len(rules), 2)

+         self.assertEqual(rules[0].rule, "/<hub>/w/testing/<int:idx>/")

+         self.assertEqual(rules[1].rule, "/<hub>/w/testing/<int:idx>/test-1/")

+         # TestView2

+         self.assertIn("testing_test2",

+                       self.app.application.url_map._rules_by_endpoint)

+         self.assertIn("testing_test2", self.app.application.view_functions)

+         rules = list(self.app.application.url_map.iter_rules(endpoint="testing_test2"))

+         self.assertEqual(len(rules), 1)

+         self.assertEqual(rules[0].rule, "/<hub>/w/testing/<int:idx>/test-2")

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

+ from __future__ import unicode_literals

+ 

+ from hubs.widgets.caching import cache, CachedFunction

+ 

+ from mock import Mock

+ from hubs.tests import APPTest

+ from hubs.models import Hub, Widget

+ 

+ 

+ class DummyFunction(CachedFunction):

+ 

+     def __init__(self, *args, **kwargs):

+         super(DummyFunction, self).__init__(*args, **kwargs)

+         self.execute_mock = Mock()

+ 

+     def execute(self, *args, **kwargs):

+         return self.execute_mock(*args, **kwargs)

+ 

+ 

+ class CachedFunctionTest(APPTest):

+ 

+     def setUp(self):

+         super(CachedFunctionTest, self).setUp()

+         # Use a memory backend, not the default null backend, or we can't test

+         # anything.

+         cache.configure(backend='dogpile.cache.memory',

+                         replace_existing_backend=True)

+         self.w_instance = Widget.query.filter(

+             Hub.name == "ralph",

+             Widget.plugin == "about",

+             ).one()

+         self.fn = DummyFunction(self.w_instance)

+         cache.delete(self.fn.get_cache_key())

+ 

+     def tearDown(self):

+         cache.delete(self.fn.get_cache_key())

+         super(CachedFunctionTest, self).tearDown()

+ 

+     def test_get_cache_key(self):

+         value = "%d|DummyFunction" % self.w_instance.idx

+         self.assertEqual(

+             self.fn.get_cache_key(),

+             value.encode("ascii"),

+             )

+ 

+     def test_result_cached(self):

+         self.fn.execute_mock.return_value = "testing"

+         result = self.fn()

+         self.fn.execute_mock.assert_called_once_with()

+         self.assertEqual(result, "testing")

+         result = self.fn()

+         self.assertEqual(result, "testing")

+         # Check it hasn't been called a second time.

+         self.fn.execute_mock.assert_called_once()

+ 

+     def test_is_cached(self):

+         key = self.fn.get_cache_key()

+         self.assertFalse(self.fn.is_cached())

+         cache.set(key, "testing is_cached")

+         self.assertTrue(self.fn.is_cached())

+ 

+     def test_invalidate(self):

+         key = self.fn.get_cache_key()

+         cache.set(key, "testing invalidate")

+         self.fn.invalidate()

+         self.assertFalse(self.fn.is_cached())

+ 

+     def test_rebuild(self):

+         key = self.fn.get_cache_key()

+         cache.set(key, "testing rebuild")

+         self.fn.execute_mock.return_value = "testing"

+         self.fn.rebuild()

+         self.fn.execute_mock.assert_called_once()

+         self.assertEqual(cache.get(key), "testing")

@@ -1,70 +0,0 @@ 

- from __future__ import unicode_literals

- 

- import hubs.widgets

- 

- from mock import Mock

- from hubs.models import Hub, Widget

- from hubs.tests import APPTest

- 

- class WidgetRoutesTest(APPTest):

- 

-     def setUp(self):

-         super(WidgetRoutesTest, self).setUp()

-         # Backup the URL map

-         self._old_url_map = (

-             self.app.application.url_map._rules[:],

-             self.app.application.url_map._rules_by_endpoint.copy()

-             )

- 

-     def tearDown(self):

-         for module in hubs.widgets.registry.values():

-             if hasattr(module, 'ROUTES'):

-                 delattr(module, 'ROUTES')

-         # Restore the URL map

-         self.app.application.url_map._rules = self._old_url_map[0]

-         self.app.application.url_map._rules_by_endpoint = self._old_url_map[1]

-         super(WidgetRoutesTest, self).tearDown()

- 

-     def test_routes_variable(self):

-         calls = []

-         def mock_view(*args, **kw):

-             calls.append({'args': args, 'kw': kw})

-             return ''

-         mock_view.__module__ = 'hubs.widgets.contact'

-         hubs.widgets.contact.mock_view = mock_view

-         hubs.widgets.contact.ROUTES = [{

-             'rule': 'dummy-url',

-             'endpoint': 'dummy-endpoint',

-             'view_func_name': 'mock_view',

-         }]

-         hubs.views._load_widget_views()

-         added_rules = list(

-             self.app.application.url_map.iter_rules(

-                 endpoint='contact_dummy-endpoint')

-             )

-         self.assertEqual(len(added_rules), 1)

-         self.assertEqual(

-             added_rules[0].rule,

-             '/<hub>/<int:idx>/widget/dummy-url'

-             )

-         widget = self.session.query(Widget).filter_by(

-             plugin="contact").first()

-         self.app.get('/%s/%s/widget/dummy-url'

-                      % (widget.hub.name, widget.idx))

-         self.assertEqual(len(calls), 1)

-         # Check arguments: session and widget

-         self.assertEqual(calls[0]["args"][0].__class__, self.session.__class__)

-         self.assertEqual(calls[0]["args"][1], widget)

- 

-     def test_decorator(self):

-         def mock_view(*args, **kw):

-             return ''

-         mock_view.__module__ = 'hubs.widgets.feed'

-         hubs.widgets.feed.mock_view = hubs.widgets.base.widget_route(

-             rule='dummy-url', endpoint='dummy-endpoint')(mock_view)

-         self.assertEqual(

-             hubs.widgets.feed.ROUTES,

-             [{'view_func_name': 'mock_view',

-               'endpoint': 'dummy-endpoint',

-               'rule': 'dummy-url'}]

-             )

@@ -15,30 +15,8 @@ 

          response = self.app.get('/ralph/%i/json/' % widget.idx)

          assert response.status_code == 200, response.status_code

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

-         del data['data']['widget_url']

          self.assertDictEqual(data['data'], {

-             u'edit_url': u'/ralph/56/edit',

-             u'source_url': u'/source/about',

-             u'text': u'Testing.',

+             'text': 'Testing.',

+             'title': "<span class='glyphicon glyphicon-info-sign' "

+                      "aria-hidden='true'></span> About",

          })

- 

-     def test_should_invalidate_wrong_topic(self):

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

-         msg = {'topic': 'hubs.widget.update.WRONG.TOPIC', 'msg': {'widget': {'id': widget.idx}}}

-         module = hubs.widgets.registry[widget.plugin]

-         result = module.should_invalidate(msg, self.session, widget)

-         self.assertFalse(result)

- 

-     def test_should_invalidate_wrong_widget_id(self):

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

-         msg = {'topic': 'hubs.widget.update', 'msg': {'widget': {'id': widget.idx+1}}}

-         module = hubs.widgets.registry[widget.plugin]

-         result = module.should_invalidate(msg, self.session, widget)

-         self.assertFalse(result)

- 

-     def test_should_invalidate_good_match(self):

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

-         msg = {'topic': 'hubs.widget.update', 'msg': {'widget': {'id': widget.idx}}}

-         module = hubs.widgets.registry[widget.plugin]

-         result = module.should_invalidate(msg, self.session, widget)

-         self.assertTrue(result)

@@ -22,7 +22,8 @@ 

          # msg = self.get_fedmsg('2016-ebb84660-59e9-4e68-af8f-4e6f49348b88')

          msg = {'topic': 'hubs.widget.update.WRONG.TOPIC'}

          module = hubs.widgets.registry[widget.plugin]

-         result = module.should_invalidate(msg, self.session, widget)

+         func = module.get_cached_functions()['GetBadges']

+         result = func(widget).should_invalidate(msg)

          self.assertFalse(result)

  

      def test_should_invalidate_wrong_user(self):
@@ -30,7 +31,8 @@ 

          # msg = self.get_fedmsg('2016-e371c7f6-bc8e-4632-8e33-b9102dc30b5f')

          msg = {'topic': 'fedbadges.badge.award', 'msg': {'user': {'username': 'not_ralph'}}}

          module = hubs.widgets.registry[widget.plugin]

-         result = module.should_invalidate(msg, self.session, widget)

+         func = module.get_cached_functions()['GetBadges']

+         result = func(widget).should_invalidate(msg)

          self.assertFalse(result)

  

      def test_should_invalidate_good_match_fedbadges(self):
@@ -38,7 +40,8 @@ 

          # msg = self.get_fedmsg('2016-1fbb1135-681b-4d3b-9a40-d0f6ebd313f4')

          msg = {'topic': 'fedbadges.badge.award', 'msg': {'user': {'username': 'ralph'}}}

          module = hubs.widgets.registry[widget.plugin]

-         result = module.should_invalidate(msg, self.session, widget)

+         func = module.get_cached_functions()['GetBadges']

+         result = func(widget).should_invalidate(msg)

          self.assertTrue(result)

  

      def test_should_invalidate_good_match_hubswidget(self):
@@ -46,5 +49,6 @@ 

          # msg = self.get_fedmsg('2016-1fbb1135-681b-4d3b-9a40-d0f6ebd313f4')

          msg = {'topic': 'hubs.widget.update', 'msg': {'widget': {'id': widget.idx + 1}}}

          module = hubs.widgets.registry[widget.plugin]

-         result = module.should_invalidate(msg, self.session, widget)

+         func = module.get_cached_functions()['GetBadges']

+         result = func(widget).should_invalidate(msg)

          self.assertTrue(result)

@@ -13,12 +13,9 @@ 

          response = self.app.get('/ralph/%i/json/' % widget.idx)

          assert response.status_code == 200, response.status_code

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

-         del data['data']['widget_url']

          self.assertDictEqual(data['data'], {

-             u'edit_url': u'/ralph/37/edit',

              u'fedmsgs': 83854,

              u'fedmsgs_text': u'83,854',

-             u'source_url': u'/source/fedmsgstats',

              u'subscribers': [],

              u'subscribed_to': [],

              u'subscribers_text': u'0',

@@ -18,12 +18,10 @@ 

                  "urls": "ralph/"

              },

              "data": {

-                 u"edit_url": u"/ralph/%d/edit" % widget.idx,

-                 u"source_url": u"/source/library",

-                 u"urls": [

-                     u"<a href=\"ralph/\">ralph/</a>"

+                 'title': 'Library',

+                 "urls": [

+                     "<a href=\"ralph/\">ralph/</a>"

                  ],

-                 u"widget_url": u"/ralph/%d" % widget.idx,

              },

              "description": None,

              "hub": "ralph",
@@ -32,5 +30,4 @@ 

              "left": False,

              "plugin": "library"

          }

- 

          self.assertDictEqual(data['data'], expected_dict['data'])

@@ -20,7 +20,8 @@ 

      def test_render_simple(self):

          team = 'i18n'

          widget = self.widget_instance(team, self.plugin)

-         response = self.app.get('/%s/%i/' % (team, widget.idx))

+         response = self.app.get('/%s/w/%s/%i/'

+                                 % (team, self.plugin, widget.idx))

          self.assertEqual(200, response.status_code)

          self.assertIn('i18n', response.get_data(as_text=True))

          self.assertIn('<strong>Request A New Meeting</strong>',

@@ -0,0 +1,43 @@ 

+ interactions:

+ - request:

+     body: null

+     headers:

+       Accept: ['*/*']

+       Accept-Encoding: ['gzip, deflate']

+       Connection: [keep-alive]

+       User-Agent: [python-requests/2.12.5]

+     method: GET

+     uri: https://apps.fedoraproject.org/calendar/api/meetings/?calendar=i18n

+   response:

+     body:

+       string: !!binary |

+         H4sIAAAAAAAAA+3Zz2/bIBQH8H/lyadNCrZx82s5Teuph/bUSanWKWL2S8pqQ4RJqy3K/z7I0ogw

+         7TBp8rsgxYcA5svDH3Gw91mHaKXa9NkCvuzf/q2s7HDVW2Gsa8/K6aIs3S8bwXmEbFzPhE+qsE2t

+         temElVr52z5J9or43P6A0wBw3SD5XIHBVlhsoJF9vet7d0OfP5pH5a9bbRCCqUC8CNmKby2CsAt4

+         snbbL4pijY02Ymv0d6xtrs2meJXPsrjh87vi9lRUUZV8zJZL9vAAAO+WS2DQaWWfRuBaGDRuEfl7

+         X1YtWlSNMCslOvSL98sM6z3tiN4eN2T254b4uVZuDt/vYmes5KwchyNaXZ/35vfq2anrozR1vjaI

+         SjeYK7ThbZ1QYoPGP6Bsa8SLVH32NQr+e2hYz9tziOv6qdVxzOf76+wwgsTgPzPgcwIGl6GJATWD

+         yj2UwRnEoYkBPQM+IWBwGZoYUDO4ojgN4tDEgJ4BwWkQhyYG9AyqDwQMLkMTA2oGY8arwRnEoYkB

+         PYNqSsDgMjQxoGYwYbwcnEEcmhjQM6iGf4sYhyYG1AymrJwNziAOTQzoGVTDvzeIQxMDagb+iB6c

+         QRyaGNAz4MO/N4hD/5mBn1qYza5DZf135312dnD+hlmFWxbuVlT/1fFgCupWu7Y9HH4Bm4DFHNge

+         AAA=

+     headers:

+       Accept-Ranges: [bytes]

+       Age: ['0']

+       AppServer: [proxy04.fedoraproject.org]

+       AppTime: [D=176951]

+       Connection: [Keep-Alive]

+       Content-Encoding: [gzip]

+       Content-Length: ['515']

+       Content-Type: [application/json]

+       Date: ['Wed, 01 Feb 2017 08:36:43 GMT']

+       Keep-Alive: ['timeout=15, max=500']

+       Server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]

+       Set-Cookie: ['fedocal=eyJfcGVybWFuZW50Ijp0cnVlfQ.C3Mvmw.rzQ95C3LVBlZJKMEJVpzih4SIT4;

+           Expires=Wed, 01-Feb-2017 09:36:43 GMT; Secure; HttpOnly; Path=/calendar']

+       Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]

+       Vary: [Accept-Encoding]

+       Via: [1.1 varnish-v4]

+       X-Varnish: ['1754049']

+     status: {code: 200, message: OK}

+ version: 1

file modified
+4
@@ -34,6 +34,10 @@ 

      # TODO -- implement this.

      return value

  

+ def github_repo(session, value):

+     # TODO -- implement this.

+     return value

+ 

  

  def fmn_context(session, value):

      # TODO get this from the fedmsg config.

file modified
-42
@@ -6,45 +6,3 @@ 

  from .user import *

  from .api import *

  from .plus_plus import *

- 

- #

- # Add widget-specific routes

- #

- 

- import flask

- import functools

- import hubs.models

- import hubs.widgets

- from sqlalchemy.orm.exc import NoResultFound

- from hubs.app import app, session

- from .utils import get_widget

- 

- def _widget_view_decorator(func):

-     """

-     This internal decorator will edit the view function arguments.

- 

-     It will:

-     - remove the hub name and the widget primary key

-     - add the database session and the widget instance

-     """

-     @functools.wraps(func)

-     def inner(*args, **kwargs):

-         hubname = kwargs.pop("hub")

-         widgetidx = kwargs.pop("idx")

-         widget = get_widget(hubname, widgetidx)

-         return func(session, widget, *args, **kwargs)

-     return inner

- 

- def _load_widget_views():

-     for name, module in hubs.widgets.registry.items():

-         for params in getattr(module, 'ROUTES', []):

-             params["rule"] = "/<hub>/<int:idx>/widget/" \

-                              + params["rule"].lstrip("/")

-             if not params.get("endpoint"):

-                 params["endpoint"] = params["view_func_name"]

-             params["endpoint"] = "%s_%s" % (name, params["endpoint"])

-             params["view_func"] = _widget_view_decorator(

-                 getattr(module, params.pop("view_func_name")))

-             app.add_url_rule(**params)

- 

- _load_widget_views()

file modified
+8 -13
@@ -158,19 +158,14 @@ 

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

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

  

-     widget = None

-     w_name = None

-     for widgt in hubs.widgets.registry:

-         if hubs.widgets.registry[widgt].position in ['both', side] \

-                 and widgt == widget_name:

-             w_name = widgt

-             widget = hubs.widgets.registry[widgt]

+     widget = hubs.widgets.registry[widget_name]

+     if widget.position not in ['both', side]:

+         flask.abort(400, "This widget can't be placed here")

  

      return flask.render_template(

          'add_widget.html',

          hub=hub,

          widget=widget,

-         widget_name=w_name,

          side=side,

          url_to=flask.url_for('hub_add_widgets', name=name),

      )
@@ -209,16 +204,16 @@ 

          plugin=widget_name, index=-1, left=position == 'left')

      error = False

      config = {}

-     for arg in widget.module.data.widget_arguments:

-         val = flask.request.form.get(arg.name)

+     for param in widget.module.get_parameters():

+         val = flask.request.form.get(param.name)

          if not val:

              flask.flash(

-                 'You must provide a value for: %s' % arg.name, 'error')

+                 'You must provide a value for: %s' % param.name, 'error')

              error = True

              break

          try:

-             arg.validator(flask.g.db, val)

-             config[arg.name] = val

+             param.validator(flask.g.db, val)

+             config[param.name] = val

          except Exception as err:

              flask.flash('Invalid data provided, error: %s' % err, 'error')

              error = True

file modified
+1 -2
@@ -3,7 +3,6 @@ 

  import flask

  import functools

  import hubs.models

- import hubs.widgets

  

  from six.moves.urllib import parse as urlparse

  from sqlalchemy.orm.exc import NoResultFound
@@ -20,7 +19,7 @@ 

          flask.abort(404)

  

  

- def get_widget(hub, idx, session=None):

+ def get_widget_instance(hub, idx, session=None):

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

      if session is None:

          session = flask.g.db

file modified
+21 -25
@@ -3,22 +3,15 @@ 

  import datetime

  import flask

  

- from hubs.app import app, session

- from .utils import get_widget

- 

- 

- @app.route('/<hub>/<int:idx>/')

- @app.route('/<hub>/<int:idx>')

- def widget_render(hub, idx):

-     widget = get_widget(hub, idx)

-     return widget.render()  # , edit=False)

-     # was blocking all widgets from working, sorry!

+ from hubs.app import app

+ from pkg_resources import resource_isdir

+ from .utils import get_widget_instance

  

  

  @app.route('/<hub>/<int:idx>/json')

  @app.route('/<hub>/<int:idx>/json/')

  def widget_json(hub, idx):

-     widget = get_widget(hub, idx)

+     widget = get_widget_instance(hub, idx)

      response = flask.jsonify(widget.__json__())

      # TODO -- modify headers with response.headers['X-fedora-hubs-wat'] = 'foo'

      return response
@@ -34,7 +27,7 @@ 

  

  

  def widget_edit_get(hub, idx):

-     widget = get_widget(hub, idx)

+     widget = get_widget_instance(hub, idx)

      return flask.render_template(

          'edit.html',

          hub=hub,
@@ -44,19 +37,19 @@ 

  

  

  def widget_edit_post(hub, idx):

-     widget = get_widget(hub, idx)

+     widget = get_widget_instance(hub, idx)

      error = False

      config = {}

-     for arg in widget.module.data.widget_arguments:

-         val = flask.request.form.get(arg.name)

+     for param in widget.module.get_parameters():

+         val = flask.request.form.get(param.name)

          if not val:

              flask.flash(

-                 'You must provide a value for: %s' % arg.name, 'error')

+                 'You must provide a value for: %s' % param.name, 'error')

              error = True

              break

          try:

-             val = arg.validator(flask.g.db, val)

-             config[arg.name] = val

+             val = param.validator(flask.g.db, val)

+             config[param.name] = val

          except Exception as err:

              flask.flash('Invalid data provided, error: %s' % err, 'error')

              error = True
@@ -80,7 +73,7 @@ 

  @app.route('/<hub>/<int:idx>/delete', methods=['POST'])

  def widget_edit_delete(hub, idx):

      ''' Remove a widget from a hub. '''

-     widget = get_widget(hub, idx)

+     widget = get_widget_instance(hub, idx)

      flask.g.db.delete(widget)

      try:

          flask.g.db.commit()
@@ -96,14 +89,17 @@ 

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

  def widget_source(name):

      from hubs.widgets import registry

-     base = '/hubs/'

-     fname = ''

  

      try:

-         fname = base + registry[name].__file__.split(base)[-1]

+         widget_path = registry[name].__module__

      except KeyError:

          flask.abort(404)

- 

-     fname = fname.replace('.pyc', '.py')

-     return flask.redirect(app.config.get('SOURCE_URL') + fname)

+     widget_url = widget_path.replace(".", "/")

+     parent, _ignore, module = widget_path.rpartition(".")

+     if resource_isdir(parent, module):

+         widget_url += "/__init__.py"

+     else:

+         widget_url += ".py"

+     url = "/".join([app.config.get('SOURCE_URL'), widget_url])

+     return flask.redirect(url)

  

file modified
+85 -130
@@ -1,132 +1,87 @@ 

+ """

+ First, some definitions:

+ 

+ - A *widget* is a Python class inheriting from

+   :py:class:`hubs.widgets.base.Widget`.

+   It will be registered with the application in the

+   :py:data:`hubs.widgets.registry` dictionary.

+ - A *widget instance* is a database record (:py:class:`hubs.models.Widget`), it

+   means that a particular *widget* has been set up for a particular *hub*, with

+   a specific configuration.  A *widget* can be set up more that one time for

+   the same *hub*, with different configurations (for example, the ``sticky``

+   widget).  In this case, there will one *widget* but multiple *widget

+   instances*.

+ 

+ As explained above, widgets are Python classes.  They are best isolated in

+ their own module and directory.  To create a new widget, you can follow these

+ steps:

+ 

+ - create a new directory in ``hubs/widgets/``

+ - write your widget subclass in the ``__init__.py`` file

+ - reference the class in your configuration file, or in the general

+   ``hubs/default_config.py`` configuration file.

+ 

+ In order to be valid, a widget must:

+ 

+ - inherit from the :py:class:`hubs.widgets.base.Widget` class,

+ - have a valid :py:attr:`~hubs.widgets.base.Widget.name` attribute,

+ - have a valid :py:attr:`~hubs.widgets.base.Widget.position` attribute,

+ - contain at least one widget view named "``root``", registered for the "``/``"

+   URL.

+ 

+ A widget view is a class which inherits from the

+ :py:class:`hubs.widgets.view.WidgetView` class.  A widget can have one or more

+ of them.

+ 

+ Widget views can make use of templates following the `Jinja2`_ engine syntax.

+ The templates are looked for in the widget's ``templates`` subdirectrory first,

+ and then in the generic ``hubs/widgets/templates/`` directory.  They can make

+ use of any Jinja2 feature, including inheritance, macros, etc.

+ 

+ .. _Jinja2: http://jinja.pocoo.org/docs/

+ 

+ If you want to add filters to youre widget's Jinja2 environment, you can do so

+ by overriding the widget's

+ :py:meth:`~hubs.widgets.base.Widget.get_template_environment` method.

+ 

+ Hubs provides the ``panel.html`` master template for widgets that want their

+ output to be wrapped in the common panel chrome, just use the ``extends`` tag

+ to take advantage of it.  You can also set a ``title`` variable in your root

+ view context, and it will be used for the panel title.

+ 

+ Refer to the API documentation to learn more on how to write a widget view.

+ 

+ A widget can also have any number of cached functions.  A cached function is a

+ subclass of :py:class:`hubs.widgets.caching.CachedFunction`.

+ 

+ The result is cached until a bus message is recieved that marks the former

+ result as invalid.  To filter which messages are relevant, a cached function

+ has a

+ :py:meth:`~hubs.widgets.caching.CachedFunction.should_invalidate(message)`

+ method that returns ``True`` if the cached result is obsolete, ``False``

+ otherwise.  A backend daemon will call this method on each incoming bus

+ message.

+ 

+ Refer to the API documentation to learn more on how to write a cached function.

+ 

+ If you want it to show up on a new **user** page, add it to

+ :py:mod:`hubs.defaults` in the :py:func:`add_user_widgets` function.  If you

+ want it to show up on new **group** pages, add it to the

+ :py:func:`add_group_widgets` function in the same module.  If you want your

+ widget to be part of the example hubs created by default, add it to

+ ``populate.py``.  Then destroy your database, rebuild it, and re-run the app.

+ Your widget should show up.

+ 

+ Feel free to look at existing widgets for inspiration.

+ 

+ The widget registry is instanciated in this module, as :py:data:`registry`.

+ 

+ Attributes:

+     registry (dict): This dictionary is a registry of available widgets in

+         Fedora Hubs.

+ """

  from __future__ import unicode_literals

  

- from hubs.widgets import dummy

- from hubs.widgets import stats

- from hubs.widgets import rules

- from hubs.widgets import sticky

- from hubs.widgets import about

- from hubs.widgets import badges

- from hubs.widgets import library

- from hubs.widgets import linechart

- from hubs.widgets import fedmsgstats

- from hubs.widgets import feed

- from hubs.widgets import subscriptions

- from hubs.widgets import meetings

- from hubs.widgets import pagure_pr

- from hubs.widgets import github_pr

- from hubs.widgets import pagureissues

- from hubs.widgets import githubissues

- from hubs.widgets import bugzilla

- from hubs.widgets import fhosted

- from hubs.widgets import memberships

- from hubs.widgets import contact

- 

- from hubs.widgets.workflow import pendingacls

- from hubs.widgets.workflow import updates2stable

- 

- from hubs.widgets.base import AGPLv3, smartcache

- 

- registry = {

-     'dummy': dummy,

-     'stats': stats,

-     'rules': rules,

-     'sticky': sticky,

-     'about': about,

-     'badges': badges,

-     'library': library,

-     'linechart': linechart,

-     'fedmsgstats': fedmsgstats,

-     'feed': feed,

-     'subscriptions': subscriptions,

-     'meetings': meetings,

-     'pagure_pr': pagure_pr,

-     'github_pr': github_pr,

-     'pagureissues': pagureissues,

-     'githubissues': githubissues,

-     'bugzilla': bugzilla,

-     'fedorahosted': fhosted,

-     'memberships': memberships,

-     'contact': contact,

- 

-     'workflow.pendingacls': pendingacls,

-     'workflow.updates2stable': updates2stable,

- }

- 

- 

- def validate_registry(registry):

-     """ Ensure that the widgets in the registry have the bits they need.

- 

-     - Check that a template is available and has a render callable.

-     - Look for a data function, etc..

-     """

-     for name, module in registry.items():

-         if not hasattr(module, 'template'):

-             raise AttributeError('%r has no "template"' % module)

-         if not hasattr(module.template, 'render'):

-             raise AttributeError('%r\'s template has no "render"' % module)

-         if not callable(module.template.render):

-             raise TypeError('%r\'s template.render not callable' % module)

- 

-         if not hasattr(module, 'position'):

-             raise AttributeError('%r has not "position" function' % module)

-         if module.position not in ['left', 'right', 'both']:

-             raise TypeError(

-                 '%r\'s "position" is not: `left`, `right` or `both`'

-                 % module)

- 

-         if not hasattr(module, 'data'):

-             raise AttributeError('%r has not "data" function' % module)

-         if not callable(module.data):

-             raise TypeError('%r\'s "data" is not callable' % module)

- 

-         if hasattr(module, 'chrome'):

-             if not callable(module.chrome):

-                 raise TypeError('%r\'s "chrome" is not callable' % module)

- 

- 

- def prepare_registry(registry):

-     """ Do things ahead of time that we can to the registry.

- 

-     - Wrap a cache layer around the data functions.

-     - Wrap any chrome around the render functions.

-     """

-     for name, module in registry.items():

-         # Wrap chrome around the render function

-         module.render = module.template.render

-         if hasattr(module, 'chrome'):

-             module.render = module.chrome(module.render)

- 

-         # Put source links in all API results

-         module.data = AGPLv3(name)(module.data)

- 

-         # Wrap the data functions in a cache layer to be invalidated by fedmsg

-         # TODO -- we could just do this with a decorator to be explicit..

-         module.data = smartcache(module.data)

- 

- 

- validate_registry(registry)

- prepare_registry(registry)

- 

- 

- def get_site_vars():

-     import flask

-     return dict(

-         session=flask.app.session,

-         g=flask.g,

-         url_for=flask.url_for,

-     )

- 

- 

- def render(module, session, widget, *args, **kwargs):

-     """ Main API entry point.

- 

-     Call this to render a widget into HTML

-     """

-     # The API returns exactly this data.  Shared cache

-     data = module.data(session, widget, *args, **kwargs)

- 

-     # Also expose some site-level info to the widget here at render-time

-     data.update(get_site_vars())

- 

-     # Use the API data to fill out a template, and potentially decorate it.

-     return module.render(**data)

+ from .registry import WidgetRegistry

+ 

+ registry = WidgetRegistry()

file removed
-33
@@ -1,33 +0,0 @@ 

- from __future__ import unicode_literals

- 

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.chrome import panel

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- 

- import hubs.validators as validators

- 

- chrome = panel("<span class='glyphicon glyphicon-info-sign' aria-hidden='true'></span> About")

- template = templating.environment.get_template('templates/about.html')

- position = 'both'

- 

- 

- @argument(name="text", default="I am a Fedora user, and this is my about",

-           validator=validators.text,

-           help="Text about a user.")

- def data(session, widget, text):

-     ''' Data for the About widget '''

- 

-     return dict(text=text)

- 

- 

- @hint(topics=[_('hubs.widget.update')])

- def should_invalidate(message, session, widget):

-     ''' Checks whether the about widget cache needs an update or not

-     Called by backend daemon which listens to fedmsg '''

- 

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

-         return False

-     if message['msg']['widget']['id'] != widget.idx:

-         return False

-     return True

@@ -0,0 +1,32 @@ 

+ from __future__ import unicode_literals

+ 

+ from hubs.widgets.base import Widget, WidgetView

+ 

+ from hubs import validators

+ 

+ 

+ class About(Widget):

+ 

+     name = "about"

+     position = "both"

+     parameters = [dict(

+         name="text",

+         label="Text",

+         default="I am a Fedora user, and this is my about",

+         validator=validators.text,

+         help="Text about a user.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "about.html"

+ 

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

+         return dict(

+             text=instance.config["text"],

+             title="<span class='glyphicon glyphicon-info-sign' "

+                   "aria-hidden='true'></span> About",

+             )

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

+ {% extends "panel.html" %}

+ 

+ {% block content %}

+ <p>{{text}}</p>

+ {% endblock %}

file removed
-43
@@ -1,43 +0,0 @@ 

- from __future__ import unicode_literals

- 

- import operator

- 

- import requests

- 

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- 

- import hubs.validators as validators

- 

- from hubs.widgets.chrome import panel

- chrome = panel("Badges")

- template = templating.environment.get_template('templates/badges.html')

- position = 'right'

- 

- 

- @argument(name="username",

-           default=None,

-           validator=validators.username,

-           help="A FAS username.")

- def data(session, widget, username):

-     url = "https://badges.fedoraproject.org/user/{username}/json"

-     url = url.format(username=username)

-     response = requests.get(url)

-     assertions = response.json()['assertions']

-     key = operator.itemgetter('issued')

-     return dict(assertions=sorted(assertions, key=key, reverse=True))

- 

- 

- @hint(topics=[_('hubs.widget.update'), _('fedbadges.badge.award')])

- def should_invalidate(message, session, widget):

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

-         if message['msg']['widget']['id'] != widget.idx:

-             return True

- 

-     if message['topic'].endswith('fedbadges.badge.award'):

-         username = widget.config['username']

-         if message['msg']['user']['username'] == username:

-             return True

- 

-     return False

@@ -0,0 +1,56 @@ 

+ from __future__ import unicode_literals

+ 

+ import operator

+ import requests

+ 

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

+ 

+ 

+ class Badges(Widget):

+ 

+     name = "badges"

+     position = "right"

+     parameters = [dict(

+         name="username",

+         label="Username",

+         default=None,

+         validator=validators.username,

+         help="A FAS username.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "badges.html"

+ 

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

+         context = {"title": "Badges"}

+         get_badges = GetBadges(instance)

+         context.update(get_badges())

+         return context

+ 

+ 

+ class GetBadges(CachedFunction):

+ 

+     def execute(self):

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

+         url = "https://badges.fedoraproject.org/user/{username}/json"

+         url = url.format(username=username)

+         response = requests.get(url)

+         assertions = response.json()['assertions']

+         key = operator.itemgetter('issued')

+         return dict(assertions=sorted(assertions, key=key, reverse=True))

+ 

+     def should_invalidate(self, message):

+         if message['topic'].endswith('hubs.widget.update'):

+             if message['msg']['widget']['id'] != self.instance.idx:

+                 return True

+         if message['topic'].endswith('fedbadges.badge.award'):

+             username = self.instance.config['username']

+             if message['msg']['user']['username'] == username:

+                 return True

+         return False

hubs/widgets/badges/templates/badges.html hubs/widgets/templates/badges.html
file renamed
+5 -1
@@ -1,7 +1,11 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <div class="flex-container">

  {% for badge in assertions %}

    <a width="60px" href="https://badges.fedoraproject.org/badge/{{badge['id']}}">

      <span class="f-b-{{badge['id']}}"></span>

    </a>

- {%endfor%}

+ {% endfor %}

  </div>

+ {% endblock %}

file modified
+189 -105
@@ -1,117 +1,201 @@ 

- from __future__ import unicode_literals

- 

- import collections

- import datetime

- import functools

- import hashlib

- import json

- import sys

- 

- import dogpile.cache

- import flask

- import six.moves.urllib_parse

- 

- import fedmsg.config

+ from __future__ import unicode_literals, absolute_import

  

  import logging

- log = logging.getLogger(__name__)

- 

- config = fedmsg.config.load_config()

- cache_defaults = {

-     "backend": "dogpile.cache.dbm",

-     "expiration_time": 1,  # Expire every 1 second, for development

-     "arguments": {

-         "filename": "/var/tmp/fedora-hubs-cache.db",

-     },

- }

- cache = dogpile.cache.make_region()

- cache.configure(**config.get('fedora-hubs.cache', cache_defaults))

- 

- 

- Argument = collections.namedtuple(

-     'Argument', ('name', 'default', 'validator', 'help'))

- 

- 

- def argument(name, default, validator, help):

-     def decorator(func):

-         @wraps(func)

-         def inner(*args, **kwargs):

-             return func(*args, **kwargs)

- 

-         inner.widget_arguments.append(Argument(name, default, validator, help))

-         return inner

-     return decorator

- 

- 

- def AGPLv3(name):

-     def decorator(func):

-         @wraps(func)

-         def inner(session, widget, *args, **kwargs):

-             result = func(session, widget, *args, **kwargs)

-             result['source_url'] = flask.url_for('widget_source', name=name)

-             result['widget_url'] = flask.url_for(

-                 'widget_render', hub=widget.hub.name, idx=widget.idx)

-             result['edit_url'] = flask.url_for(

-                 'widget_edit', hub=widget.hub.name, idx=widget.idx)

-             result['widget'] = widget

-             return result

- 

-         return inner

-     return decorator

- 

- 

- def smartcache(func):

-     @wraps(func)

-     def inner(session, widget, *args, **kwargs):

-         key = cache_key_generator(widget, *args, **kwargs)

-         creator = lambda: func(session, widget, *args, **kwargs)

-         #log.debug("Accessing cache key %s", key)

-         return cache.get_or_create(key, creator)

- 

-     return inner

- 

- 

- def invalidate_cache(widget, *args, **kwargs):

-     key = cache_key_generator(widget, *args, **kwargs)

+ import re

+ import flask

+ import jinja2

+ import six

  

-     value = cache.get(key, ignore_expiration=True)

-     if isinstance(value, dogpile.cache.api.NoValue):

-         log.debug("Not deleting cache key %s.  It is absent.", key)

-         return

+ from importlib import import_module

+ from .caching import CachedFunction

+ from .view import WidgetView

  

-     widget.hub.last_refreshed = datetime.datetime.utcnow()

-     log.debug("Deleting cache key %s.", key)

-     cache.delete(key)

  

+ log = logging.getLogger(__name__)

  

- def cache_key_generator(widget, *args, **kwargs):

-     return "|".join([

-         str(widget.idx),

-         json.dumps(args),

-         json.dumps(kwargs),

-     ]).encode('utf-8')

  

+ class WidgetParameter(object):

+     """

+     Configuration option for a widget.

+ 

+     A widget can be configured differently for each instance of this widget in

+     a hub.  The list of configuration options is described by the list of

+     :py:class:`WidgetParameter` objects returned by the widget's

+     :py:meth:`~Widget.get_parameters` method.

+ 

+     Attributes:

+         name (str): The name of the parameter.

+         label (str): A humanized name of the parameter, which will be shown in

+             the UI.

+         default: The default value if this parameter is not set.

+         validator (function): A validation function that will be called when

+             a user sets the parameter value.

+         help (str): A help text that will be shown in the UI.

+     """

  

- def wraps(original):

-     @functools.wraps(original)

-     def decorator(subsequent):

-         subsequent = functools.wraps(original)(subsequent)

-         subsequent.widget_arguments = getattr(original, 'widget_arguments', [])

-         return subsequent

-     return decorator

+     _attrs = ('name', 'label', 'default', 'validator', 'help')

  

+     def __init__(self, **kwargs):

+         for name in self._attrs:

+             setattr(self, name, kwargs.pop(name, None))

+         for name in kwargs:

+             raise TypeError("Invalid attribute: %s" % name)

  

- def widget_route(**options):

-     """Register a view for the current widget.

  

-     This decorator can be used to expose a specific function below a widget's

-     URL endpoint. Refer to the "Widget-specific views" section in the

-     documentation to learn how to construct the corresponding URL.

+ class Widget(object):

+     """

+     The main widget class, you must subclass it to create a widget.

+ 

+     Arguments:

+         name (str): The widget name. It will not be displayed in the UI, but

+             will appear in some URLs, so be careful to only use simple,

+             URL-compatible characters.

+         position (str): The position of the widget in the rendered page. It

+             should be one of the following values: ``left``, ``right``, or

+             ``both``.

+         parameters (list): A list of dictionaries that describe a widget's

+             configuration. See :py:class:`hubs.widgets.base.WidgetParameter`.

+         views_module (list): The Python path to the module where the widget

+             views must be looked for.  By default, they are looked for in the

+             same module.

+         cached_functions_module (list): The Python path to the module where the

+             cached functions must be looked for.  By default, they are looked

+             for in the same module.

      """

-     def decorator(func):

-         options["view_func_name"] = func.__name__

-         mdict = sys.modules[func.__module__].__dict__

-         mroutes = mdict.setdefault('ROUTES', [])

-         mroutes.append(options)

-         return func

-     return decorator

+ 

+     name = None

+     position = None

+     parameters = []

+     views_module = None

+     cached_functions_module = None

+ 

+     def __init__(self):

+         self._template_environment = None

+ 

+     def validate(self):

+         """

+         Ensure that the widget has the bits it needs.

+ 

+         Raises: AttributeError

+         """

+         if self.name is None:

+             raise AttributeError('widgets must have a "name" attribute')

+         if not re.match('^[\w_.-]+$', self.name):

+             raise AttributeError(

+                 'invalid widget name: %r. ' % self.name +

+                 'Please use URL-compatible characters.')

+         if self.position not in ['left', 'right', 'both']:

+             raise AttributeError(

+                 '"position" attribute is not: `left`, `right` or `both`'

+                 )

+         root_view = self.get_views().get("root")

+         if root_view is None:

+             raise AttributeError(

+                 'widgets must have a "root" view, please refer to the '

+                 'documentation.')

+         if "/" not in root_view.url_rules:

+             raise AttributeError(

+                 'the root view must be registered for the "/" URL rule.')

+ 

+     def get_parameters(self):

+         """

+         Return the :py:class:`WidgetParameter` instances that describe this

+         widget's configuration options.

+         """

+         return [WidgetParameter(**param) for param in self.parameters]

+ 

+     def get_template_environment(self):

+         """

+         Get the template environment that widget-specific views will use.

+ 

+         If additional filters or variables are needed in the templates, they

+         can be added by overriding this method in the widget subclass.

+         """

+         if self._template_environment is None:

+             env = jinja2.Environment(

+                 loader=jinja2.ChoiceLoader([

+                     jinja2.PackageLoader(self.__module__, "templates"),

+                     jinja2.PackageLoader("hubs.widgets", "templates"),

+                     ]),

+                 trim_blocks=True,

+             )

+             env.filters['tojson'] = flask.json.tojson_filter

+             env.globals.update({

+                 'session': flask.app.session,

+                 'g': flask.g,

+                 'url_for': flask.url_for,

+                 })

+             self._template_environment = env

+         return self._template_environment

+ 

+     def _get_local_subclasses(self, parent_class, module=None):

+         """

+         Returns the subclasses of the ``parent_class`` defined in this widget's

+         Python module.

+         """

+         if module is None:

+             module = self.__module__

+         result = []

+         try:

+             widget_module = import_module(module, package=self.__module__)

+         except ImportError as e:

+             raise AttributeError(

+                 "Could not find subclasses of %s in %s: %s"

+                 % (parent_class, module, e))

+         for objname in dir(widget_module):

+             obj = getattr(widget_module, objname)

+             if type(obj) != type(object):

+                 continue  # we only look for classes

+             if not issubclass(obj, parent_class):

+                 continue  # we only want subclasses of the parent_class

+             if obj.__module__ != widget_module.__name__:

+                 # we don't want locally imported classes (like the

+                 # parent_class itself)

+                 continue

+             result.append(obj)

+         return result

+ 

+     def get_views(self):

+         """

+         Returns:

+             dict: A dictionary of the widget-specific views, indexed by their

+                 :py:attr:`~hubs.widgets.view.WidgetView.name` attribute.

+         """

+         return dict([

+             (view.name, view) for view in

+             self._get_local_subclasses(WidgetView, self.views_module)

+             ])

+ 

+     def register_routes(self, app):

+         """

+         Register the widget-specific views in the web framework.

+ 

+         Args:

+             app (flask.Flask): The Flask application to register the views

+                 with.

+ 

+         This function is Flask-specific.

+         """

+         for view_name, view_class in self.get_views().items():

+             endpoint = "%s_%s" % (self.name, view_name)

+             if six.PY2:

+                 # Flask will set the __name__ property, which must be a

+                 # bytestring on PY2

+                 endpoint = endpoint.encode("ascii", "replace")

+             view_func = view_class.as_view(endpoint, self)

+             for url_rule in view_class.url_rules:

+                 rule = "/<hub>/w/%s/<int:idx>/%s" % (

+                     self.name, url_rule.lstrip("/"))

+                 app.add_url_rule(rule, view_func=view_func)

+ 

+     def get_cached_functions(self):

+         """

+         Returns:

+             dict: A dictionary of the widget's cached functions, indexed by

+                 their class name.

+         """

+         result = {}

+         for fn_class in self._get_local_subclasses(

+                 CachedFunction, self.cached_functions_module):

+             result[fn_class.__name__] = fn_class

+         return result

hubs/widgets/bugzilla/__init__.py hubs/widgets/bugzilla.py
file renamed
+81 -68
@@ -3,87 +3,100 @@ 

  import requests

  import pkgwat.api

  

- import hubs.validators as validators

- from hubs.widgets.base import argument

- from hubs.widgets.chrome import panel

- from hubs.widgets import templating

- from hubs.hinting import hint

- 

- chrome = panel("Bugzilla: Issues")

- template = templating.environment.get_template('templates/bugzilla.html')

- position = 'right'

- 

- 

- @argument(name="username",

-           default=None,

-           validator=validators.username,

-           help="A FAS username.")

- def data(session, widget, username):

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

+ 

+ 

+ PKGDB_URL = "https://admin.fedoraproject.org/pkgdb/api/packager/package"

+ 

+ 

+ class Bugzilla(Widget):

+ 

+     name = "bugzilla"

+     position = "right"

+     parameters = [dict(

+         name="username",

+         label="Username",

+         default=None,

+         validator=validators.username,

+         help="A FAS username.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "bugzilla.html"

+ 

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

+         get_issues = GetIssues(instance)

+         return dict(

+             title="Bugzilla: Issues",

+             username=instance.config["username"],

+             issues=get_issues()

+             )

+ 

+ 

+ class GetIssues(CachedFunction):

+ 

      ''' Returns data for Bugzilla Widget. Queries pkgdb api for package

      list of the user and bugzilla for correspoding issues '''

  

-     pkgdb_url = "https://admin.fedoraproject.org/pkgdb/api/packager/package"

-     url = "/".join([pkgdb_url, username])

-     response = requests.get(url)

-     data = response.json()

+     def execute(self):

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

+         url = "/".join([PKGDB_URL, username])

+         response = requests.get(url)

+         data = response.json()

  

-     issues = []

+         issues = []

  

-     # get the packages of which the user is

-     # point of contact

-     for package in data["point of contact"]:

-         if len(issues) == 3:

-             break

-         pkg_details = pkgwat.api.bugs(package['name'])

-         for row in pkg_details['rows']:

+         # get the packages of which the user is

+         # point of contact

+         for package in data["point of contact"]:

              if len(issues) == 3:

                  break

-             issues.append(

-                 dict(

-                     id=row['id'],

-                     title=row['description'],

-                     pkg_name=package['name'],

+             pkg_details = pkgwat.api.bugs(package['name'])

+             for row in pkg_details['rows']:

+                 if len(issues) == 3:

+                     break

+                 issues.append(

+                     dict(

+                         id=row['id'],

+                         title=row['description'],

+                         pkg_name=package['name'],

+                     )

                  )

-             )

  

-     # get the packages of which the user is

-     # co maintainer

-     for package in data["co-maintained"]:

-         if len(issues) == 3:

-             break

-         pkg_details = pkgwat.api.bugs(package['name'])

-         for row in pkg_details['rows']:

+         # get the packages of which the user is

+         # co maintainer

+         for package in data["co-maintained"]:

              if len(issues) == 3:

                  break

-             issues.append(

-                 dict(

-                     id=row['id'],

-                     title=row['description'],

-                     pkg_name=package['name'],

+             pkg_details = pkgwat.api.bugs(package['name'])

+             for row in pkg_details['rows']:

+                 if len(issues) == 3:

+                     break

+                 issues.append(

+                     dict(

+                         id=row['id'],

+                         title=row['description'],

+                         pkg_name=package['name'],

+                     )

                  )

-             )

- 

-     return dict(

-         username=username,

-         issues=issues,

-     )

- 

  

- @hint(categories=['bugzilla'])

- def should_invalidate(message, session, widget):

-     ''' Validate if the bugzilla widget cache needs an update

-     This is called by a backend daemon listening to fedmsg '''

+         return issues

  

-     # TODO -- wrap this pkgdb pull in another invalidatible cache.

-     username = widget.config['username']

-     pkgdb_url = "https://admin.fedoraproject.org/pkgdb/api/packager/package"

-     url = "/".join([pkgdb_url, username])

-     response = requests.get(url)

-     data = response.json()

+     def should_invalidate(self, message):

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

+         url = "/".join([PKGDB_URL, username])

+         response = requests.get(url)

+         data = response.json()

  

-     owned = data['point of contact'] + data['co-maintained']

-     owned = [p['name'] for p in owned]

-     if message['msg']['bug']['component'] in owned:

-         return True

+         owned = data['point of contact'] + data['co-maintained']

+         owned = [p['name'] for p in owned]

+         if message['msg']['bug']['component'] in owned:

+             return True

  

-     return False

+         return False

hubs/widgets/bugzilla/templates/bugzilla.html hubs/widgets/templates/bugzilla.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <ul class="media-list">

    {% for item in issues %}

      <li class="media">
@@ -15,3 +18,4 @@ 

      </li>

    {% endfor %}

  </ul>

+ {% endblock %}

@@ -0,0 +1,130 @@ 

+ """

+ Attributes:

+     cache (dogpile.cache.region.CacheRegion): The cache where function results

+         will be stored. It is configured with the ``fedora-hubs.cache`` key in

+         fedmsg configuration.

+ """

+ from __future__ import unicode_literals

+ 

+ import datetime

+ import dogpile

+ import dogpile.cache

+ import fedmsg.config

+ import logging

+ 

+ log = logging.getLogger(__name__)

+ 

+ 

+ def _get_cache():

+     config = fedmsg.config.load_config()

+     cache_defaults = {

+         "backend": "dogpile.cache.dbm",

+         "expiration_time": 1,  # Expire every 1 second, for development

+         "arguments": {

+             "filename": "/var/tmp/fedora-hubs-cache.db",

+         },

+     }

+     cache = dogpile.cache.make_region()

+     cache.configure(**config.get('fedora-hubs.cache', cache_defaults))

+     return cache

+ 

+ cache = _get_cache()

+ 

+ 

+ class CachedFunction(object):

+     """

+     A function that has automatic caching and invalidation features.

+ 

+     This class is a wrapper for a function that will cache its results until a

+     relevant message is emitted on the bus.

+ 

+     To use this class, you must subclass it and implement a couple methods.

+     It is instantiated by passing a widget instance (database record) as only

+     argument, which lets it access the :py:attr:`~hubs.models.Widget.config`

+     property and act accordingly.

+ 

+     You must implement the :py:meth:`.execute` method, which must return a

+     JSON-serializable value.

+ 

+     You must also implement the :py:meth:`.should_invalidate` method, which

+     takes the bus message as only argument, and returns ``True`` if the cache

+     should be rebuild, ``False`` otherwise.

+ 

+     To call the function, instantiate the class and execute it. You may also

+     call the :py:meth:`.get_data` method.

+ 

+     Args:

+         instance (hubs.models.Widget): The widget instance.

+     """

+ 

+     def __init__(self, instance):

+         self.instance = instance

+ 

+     def execute(self):

+         """

+         The function to cache.

+ 

+         This is the main method, it must be implemented.

+ 

+         Returns:

+             dict or list: A JSON-serializable value that will be cached.

+         """

+         raise NotImplementedError

+ 

+     def get_cache_key(self):

+         return "|".join([

+             str(self.instance.idx),

+             self.__class__.__name__,

+         ]).encode('utf-8')

+ 

+     def get_data(self):

+         key = self.get_cache_key()

+         log.debug("Accessing cache key %s", key)

+         return cache.get_or_create(

+             key, self.execute)

+ 

+     __call__ = get_data

+ 

+     def should_invalidate(self, message):

+         """

+         Tell the cache invalidator if the received message should invalidate

+         this function's cache.  This function must be implemented by

+         subclasses.

+ 

+         Args:

+             message (dict): The recieved bus message.

+ 

+         Returns:

+             bool: If ``True``, the cache will be rebuilt.

+         """

+         raise NotImplementedError

+ 

+     def is_cached(self):

+         """

+         Return a boolean indicating if the function's result is currently in

+         the cache.

+ 

+         Returns:

+             bool: Whether the cache currently contains the function result.

+         """

+         result = cache.get(self.get_cache_key(), ignore_expiration=True)

+         return not isinstance(result, dogpile.cache.api.NoValue)

+ 

+     def invalidate(self):

+         """

+         Invalidate this function's cache.

+         """

+         key = self.get_cache_key()

+         if not self.is_cached():

+             log.debug("Not deleting cache key %s.  It is absent.", key)

+             return

+         log.debug("Deleting cache key %s.", key)

+         cache.delete(key)

+ 

+     def rebuild(self):

+         """

+         Rebuild this function's cache.

+         """

+         self.invalidate()

+         self.instance.hub.last_refreshed = datetime.datetime.utcnow()

+         self.get_data()

file removed
-32
@@ -1,32 +0,0 @@ 

- from __future__ import unicode_literals

- 

- from hubs.widgets.base import wraps

- from hubs.widgets import templating

- 

- _panel_template = templating.environment.get_template(

-     'templates/panel.html')

- _panel_heading_template = templating.environment.get_template(

-     'templates/panel_heading.html')

- 

- 

- def panel(title=None, klass="panel-default", key=None, footer_template=None):

-     def decorator(func):

-         @wraps(func)

-         def inner(*args, **kwargs):

-             heading = ''

-             footer = ''

-             if title:

-                 heading = _panel_heading_template.render(title=title)

-             if footer_template:

-                 footer = footer_template.render(**kwargs)

-             content = func(*args, **kwargs)

-             if key and not kwargs.get(key):

-                 return content

-             return _panel_template.render(

-                 content=content,

-                 heading=heading,

-                 footer=footer,

-                 klass=klass(kwargs) if callable(klass) else klass,

-                 **kwargs)

-         return inner

-     return decorator

hubs/widgets/contact/__init__.py hubs/widgets/contact.py
file renamed
+59 -51
@@ -5,61 +5,69 @@ 

  import fedmsg.config

  import fedmsg.meta

  import hubs.models

- from hubs.widgets.chrome import panel

- from hubs.widgets import templating

+ 

+ from hubs.widgets.base import Widget, WidgetView

  

  config = fedmsg.config.load_config()

  

- chrome = panel()

- template = templating.environment.get_template('templates/contact.html')

- position = 'both'

  

+ class Contact(Widget):

+ 

+     name = "contact"

+     position = "both"

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "contact.html"

  

- # TODO: update this section when FAS3 is deployed

- def data(session, widget, **kwargs):

-     ''' Data for Contact widget. Checks if the hub associated

-     with widget is of a user or not. If the hub is of a user, return

-     data related to the user else, hub is of a fedora team

-     - return data related to the team '''

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

+         ''' Data for Contact widget. Checks if the hub associated

+         with instance is of a user or not. If the hub is of a user, return

+         data related to the user else, hub is of a fedora team

+         - return data related to the team '''

+         # TODO: update this section when FAS3 is deployed

  

-     hub = widget.hub

-     if hub.user_hub:

-         usergroup = True

-         user = hubs.models.User.by_username(hub.name)

-         email = user.username + '@fedoraproject.org'

-         karma_url = flask.url_for('plus_plus_status', user=user.username)

-         fas_info = {

-             'usergroup': usergroup,

-             'location': 'United States',

-             'timezone': 'UTC',

-             'email': email,

-             'ircnick': user.username,

-             'karma_url': karma_url,

-             'account_age': 'Oct 2010',

-         }

-     else:

-         usergroup = False

-         # TODO: update this section integrating with FAS3

-         if hub.name == 'infrastructure':

-             ircchannel = 'apps'

-             hubname = 'infrastructure'

-         elif hub.name == 'designteam':

-             ircchannel = 'design'

-             hubname = 'design'

-         elif hub.name == 'marketing':

-             ircchannel = 'mktg'

-             hubname = 'marketing'

+         hub = instance.hub

+         if hub.user_hub:

+             usergroup = True

+             user = hubs.models.User.by_username(hub.name)

+             email = user.username + '@fedoraproject.org'

+             karma_url = flask.url_for('plus_plus_status', user=user.username)

+             fas_info = {

+                 'usergroup': usergroup,

+                 'location': 'United States',

+                 'timezone': 'UTC',

+                 'email': email,

+                 'ircnick': user.username,

+                 'karma_url': karma_url,

+                 'account_age': 'Oct 2010',

+             }

          else:

-             ircchannel = hub.name

-             hubname = hub.name

-         mailinglist = 'https://lists.fedoraproject.org/archives/list/{}'

-         '@lists.fedoraproject.org/'.format(hubname)

-         wikilink = 'https://fedoraproject.org/wiki/' + hubname

-         fas_info = {

-             'usergroup': usergroup,

-             'hubname': hubname,

-             'ircchannel': 'fedora-%s' % ircchannel,

-             'mailinglist': mailinglist,

-             'wikilink': wikilink,

-         }

-     return fas_info

+             usergroup = False

+             # TODO: update this section integrating with FAS3

+             if hub.name == 'infrastructure':

+                 ircchannel = 'apps'

+                 hubname = 'infrastructure'

+             elif hub.name == 'designteam':

+                 ircchannel = 'design'

+                 hubname = 'design'

+             elif hub.name == 'marketing':

+                 ircchannel = 'mktg'

+                 hubname = 'marketing'

+             else:

+                 ircchannel = hub.name

+                 hubname = hub.name

+             mailinglist = 'https://lists.fedoraproject.org/archives/list/{}'

+             '@lists.fedoraproject.org/'.format(hubname)

+             wikilink = 'https://fedoraproject.org/wiki/' + hubname

+             fas_info = {

+                 'usergroup': usergroup,

+                 'hubname': hubname,

+                 'ircchannel': 'fedora-%s' % ircchannel,

+                 'mailinglist': mailinglist,

+                 'wikilink': wikilink,

+             }

+         return fas_info

hubs/widgets/contact/templates/contact.html hubs/widgets/templates/contact.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <div class="contactinfo-container">

    {% if usergroup %}

      <ul style="list-style: none; padding-left: 5px">
@@ -68,3 +71,4 @@ 

      </ul>

    {% endif %}

  </div>

+ {% endblock %}

file removed
-28
@@ -1,28 +0,0 @@ 

- from __future__ import unicode_literals

- 

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.chrome import panel

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- 

- import hubs.validators as validators

- 

- chrome = panel("This is a dummy widget")

- template = templating.environment.get_template('templates/dummy.html')

- position = 'both'

- 

- 

- @argument(name="text", default="Lorem ipsum dolor...",

-           validator=validators.text,

-           help="Some dummy text to display.")

- def data(session, widget, text):

-     return dict(text=text)

- 

- 

- @hint(topics=[_('hubs.widget.update')])

- def should_invalidate(message, session, widget):

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

-         return False

-     if message['msg']['widget']['id'] != widget.id:

-         return False

-     return True

@@ -0,0 +1,30 @@ 

+ from __future__ import unicode_literals

+ 

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ 

+ 

+ class Dummy(Widget):

+ 

+     name = "dummy"

+     position = "both"

+     parameters = [dict(

+         name="text",

+         label="Text",

+         default="Lorem ipsum dolor...",

+         validator=validators.text,

+         help="Some dummy text to display.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "dummy.html"

+ 

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

+         return dict(

+             title="This is a dummy widget",

+             text=instance.config["text"],

+             )

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

+ {% extends "panel.html" %}

+ 

+ {% block content %}

+ {{text}}

+ {% endblock %}

@@ -1,51 +0,0 @@ 

- from __future__ import unicode_literals

- 

- from hubs.hinting import hint

- from hubs.widgets.chrome import panel

- from hubs.widgets import templating

- 

- from hubs.utils import commas

- 

- import flask

- import requests

- 

- import fedmsg.config

- import fedmsg.meta

- 

- config = fedmsg.config.load_config()

- 

- chrome = panel()

- template = templating.environment.get_template('templates/fedmsgstats.html')

- position = 'both'

- 

- 

- def data(session, widget, username):

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

-     url = url.format(username=username)

-     response = requests.get(url)

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

-     sub_list = []

-     for assoc in widget.hub.associations:

-         if assoc.user:

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

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

-     return dict(

-         username=username,

-         fedmsgs=fedmsgs,

-         subscribers=subscribers,

-         subscribed_to=sub_list,

-         fedmsgs_text=commas(fedmsgs),

-         subscribers_text=commas(len(subscribers)),

-         subscribed_text=commas(len(sub_list)),

-         hub_subscribe_url=flask.url_for(

-             'hub_subscribe', hub=widget.hub.name),

-         hub_unsubscribe_url=flask.url_for(

-             'hub_unsubscribe', hub=widget.hub.name),

-     )

- 

- 

- @hint(usernames=lambda widget: [widget.config['username']])

- def should_invalidate(message, session, widget):

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

-     username = widget.config['username']

-     return username in usernames

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

+ from __future__ import unicode_literals

+ 

+ import flask

+ import fedmsg.config

+ import fedmsg.meta

+ import requests

+ 

+ from hubs import validators

+ from hubs.utils import commas

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

+ 

+ fedmsg_config = fedmsg.config.load_config()

+ 

+ 

+ class FedmsgStats(Widget):

+ 

+     name = "fedmsgstats"

+     position = "both"

+     parameters = [dict(

+         name="username",

+         label="Username",

+         default=None,

+         validator=validators.username,

+         help="A FAS username.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "fedmsgstats.html"

+ 

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

+         username = instance.config["username"]

+         context = dict(

+             username=username,

+             hub_subscribe_url=flask.url_for(

+                 'hub_subscribe', hub=instance.hub.name),

+             hub_unsubscribe_url=flask.url_for(

+                 'hub_unsubscribe', hub=instance.hub.name),

+         )

+         get_stats = GetStats(instance)

+         context.update(get_stats())

+         return context

+ 

+ 

+ class GetStats(CachedFunction):

+ 

+     def execute(self):

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

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

+         url = url.format(username=username)

+         response = requests.get(url)

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

+         sub_list = []

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

+             if assoc.user:

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

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

+         return dict(

+             fedmsgs=fedmsgs,

+             fedmsgs_text=commas(fedmsgs),

+             subscribers=subscribers,

+             subscribed_to=sub_list,

+             subscribers_text=commas(len(subscribers)),

+             subscribed_text=commas(len(sub_list)),

+             )

+ 

+     def should_invalidate(self, message):

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

+         username = self.instance.config['username']

+         return username in usernames

hubs/widgets/fedmsgstats/templates/fedmsgstats.html hubs/widgets/templates/fedmsgstats.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <div class="stats-container row">

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

    <table class="stats-table">
@@ -21,3 +24,4 @@ 

    {% endif %}

    </div>

  </div>

+ {% endblock %}

file removed
-36
@@ -1,36 +0,0 @@ 

- from __future__ import print_function, unicode_literals

- 

- from hubs.hinting import hint

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- 

- import hubs.validators as validators

- 

- import logging

- 

- log = logging.getLogger('hubs')

- 

- from hubs.widgets.chrome import panel

- chrome = panel('Live Feed')

- template = templating.environment.get_template('templates/feed.html')

- position = 'left'

- 

- 

- @argument(name="username",

-           default=None,

-           validator=validators.username,

-           help="A FAS username.")

- @argument(name="message_limit",

-           default=20,

-           validator=validators.integer,

-           help="Max number of feed messages to display")

- def data(session, widget, username, message_limit):

-     # Avoid circular import

-     from hubs.app import app

-     feed_url = app.config['SSE_URL'] + username

-     return dict(matches=[], message_limit=message_limit, feed_url=feed_url)

- 

- 

- @hint(ubiquitous=True)

- def should_invalidate(message, session, widget):

-     pass

@@ -0,0 +1,47 @@ 

+ from __future__ import unicode_literals

+ 

+ 

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ 

+ import logging

+ log = logging.getLogger('hubs')

+ 

+ 

+ class Feed(Widget):

+ 

+     name = "feed"

+     position = "left"

+     parameters = [

+         {

+             "name": "username",

+             "label": "Username",

+             "default": None,

+             "validator": validators.username,

+             "help": "A FAS username.",

+         }, {

+             "name": "message_limit",

+             "label": "Message limit",

+             "default": 20,

+             "validator": validators.integer,

+             "help": "Max number of feed messages to display.",

+         }]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "feed.html"

+ 

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

+         # Avoid circular import

+         from hubs.app import app

+         username = instance.config["username"]

+         feed_url = app.config['SSE_URL'] + username

+         return dict(

+             title="Live Feed",

+             matches=[],

+             message_limit=instance.config["message_limit"],

+             feed_url=feed_url,

+             )

hubs/widgets/feed/templates/feed.html hubs/widgets/templates/feed.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <div id="feed">

  </div>

  
@@ -15,3 +18,4 @@ 

     ReactDOM.render(FeedElement, document.getElementById('feed'));

   })();

  </script>

+ {% endblock %}

hubs/widgets/fhosted/__init__.py hubs/widgets/fhosted.py
file renamed
+81 -61
@@ -2,70 +2,90 @@ 

  

  from six.moves.xmlrpc_client import ServerProxy

  

- import hubs.validators as validators

- from hubs.widgets.chrome import panel

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- 

- chrome = panel("Fedorahosted: Open Tickets")

- template = templating.environment.get_template('templates/fedorahosted.html')

- position = 'right'

- 

- 

- @argument(name="project",

-           default=None,

-           validator=validators.fedorahosted_project,

-           help="Name of the trac instance on fedorahosted.org")

- @argument(name="n_tickets",

-           default=4,

-           validator=validators.integer,

-           help="The number of tickets to display.")

- def data(session, widget, project, n_tickets=4):

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

+ 

+ 

+ class FedoraHosted(Widget):

+ 

+     name = "fhosted"

+     position = "right"

+     parameters = [

+         dict(

+             name="project",

+             label="Project",

+             default=None,

+             validator=validators.fedorahosted_project,

+             help="Name of the trac instance on fedorahosted.org.",

+         ), dict(

+             name="n_tickets",

+             label="Number of tickets",

+             default=4,

+             validator=validators.integer,

+             help="The number of tickets to display.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "fhosted.html"

+ 

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

+         get_tickets = GetTickets(instance)

+         context = dict(

+             title="Fedorahosted: Open Tickets",

+             project=instance.config["project"],

+             )

+         context.update(get_tickets())

+         return context

+ 

+ 

+ class GetTickets(CachedFunction):

      ''' Data for Fedorahosted widget.

      Queries Fedorahosted via xmlrpc for tickets. '''

  

-     n_tickets = int(n_tickets)

-     url = 'https://fedorahosted.org/%s/rpc' % project

-     filters = 'status=accepted&status=assigned&status=new&status=reopened'\

-         '&col=id&col=summary&col=status&col=owner&col=type&col=priority'\

-         '&col=milestone&col=changetime&order=changetime'

- 

-     # get the tickets based on the filters

-     # returns a list of ticket ids

-     try:

-         server = ServerProxy(url)

-         tickets = server.ticket.query(filters)

-     except:

+     def execute(self):

+         n_tickets = int(self.instance.config["n_tickets"])

+         url = 'https://fedorahosted.org/%s/rpc' \

+             % self.instance.config["project"]

+         filters = 'status=accepted&status=assigned&status=new&status=reopened'\

+             '&col=id&col=summary&col=status&col=owner&col=type&col=priority'\

+             '&col=milestone&col=changetime&order=changetime'

+ 

+         # get the tickets based on the filters

+         # returns a list of ticket ids

+         try:

+             server = ServerProxy(url)

+             tickets = server.ticket.query(filters)

+         except:

+             return dict(

+                 error='Invalid or wrongly configured project'

+             )

+ 

+         output = []

+         total_tickets = len(tickets)

+         for ticket in tickets[:n_tickets]:

+             # get the details of the ticket

+             ticket = server.ticket.get(ticket)

+             data = ticket[3]

+             data['id'] = ticket[0]

+             data['short_summary'] = data['summary'][:45]

+             output.append(data)

+ 

          return dict(

-             error='Invalid or wrongly configured project'

+             tickets=output,

+             total_tickets=total_tickets,

          )

  

-     output = []

-     total_tickets = len(tickets)

-     for ticket in tickets[:n_tickets]:

-         # get the details of the ticket

-         ticket = server.ticket.get(ticket)

-         data = ticket[3]

-         data['id'] = ticket[0]

-         data['short_summary'] = data['summary'][:45]

-         output.append(data)

- 

-     return dict(

-         project=project,

-         tickets=output,

-         total_tickets=total_tickets,

-     )

- 

- 

- @hint(topics=[_('trac.ticket.update'), _('trac.ticket.new')])

- def should_invalidate(message, session, widget):

-     ''' Checks if the Fedorahosted widget needs an update.

-     Called by backend daemon listening to fedmsg '''

- 

-     project = widget.config.get('project', '')

-     url = 'https://fedorahosted.org/%s/' % project

-     if '.trac.ticket' in message['topic']:

-         if message['msg']['instance']['base_url'] == url:

-             return True

-     return False

+     def should_invalidate(self, message):

+         ''' Checks if the Fedorahosted widget needs an update.

+         Called by backend daemon listening to fedmsg '''

+         if '.trac.ticket' in message['topic']:

+             project = self.instance.config.get("project", "")

+             url = 'https://fedorahosted.org/%s/' % project

+             if message['msg']['instance']['base_url'] == url:

+                 return True

+         return False

hubs/widgets/fhosted/templates/fhosted.html hubs/widgets/templates/fedorahosted.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "templates/panel.html" %}

+ 

+ {% block content %}

  {% if error %}

  <p>{{ error }}</p>

  {% else %}
@@ -42,3 +45,4 @@ 

    {% endif %}

  </ul>

  {% endif %}

+ {% endblock %}

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

- from __future__ import unicode_literals

- 

- import logging

- 

- from hubs.widgets.chrome import panel

- from hubs.hinting import hint

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- 

- import hubs.validators as validators

- import hubs.utils

- 

- import fedmsg.config

- config = fedmsg.config.load_config()

- 

- log = logging.getLogger(__name__)

- 

- chrome = panel("Github: Pull Requests")

- template = templating.environment.get_template('templates/github_pr.html')

- position = 'right'

- 

- 

- @argument(name="display_number",

-           default=6,

-           validator=validators.integer,

-           help="How many pull requests to display at max.")

- @argument(name="organization",

-           default=None,

-           validator=validators.github_organization,

-           help="Github Organization or username")

- def data(session, widget, display_number, organization):

-     log.info("Getting GH prs for %r, (%r)" % (organization, display_number))

-     token = config.get('github.oauth_token')

-     org = organization

-     pulls = []

-     display_number = 0

-     more = 0

-     if token:

-         repos = hubs.utils.github_repos(token, org)

-         pulls = sum([

-             list(hubs.utils.github_pulls(token, org, repo))

-             for repo in repos

-         ], [])

-         # Reverse-sort by time (newest-first)

-         pulls.sort(lambda a, b: cmp(b['timestamp'], a['timestamp']))

- 

-         # Some hints for display

-         len_pulls = len(pulls)

-         display_number = min(display_number, len_pulls)

-         more = max(len_pulls - display_number, 0)

- 

-     return dict(

-         organization=org,

-         display_number=display_number,

-         more=more,

-         pulls=pulls,

-     )

- 

- 

- @hint(categories=['github'])

- def should_invalidate(message, session, widget):

-     owner = message['msg']['repository']['owner']

- 

-     if 'login' in owner:

-         owner = owner['login']

-     else:

-         owner = owner['name']

- 

-     return owner == widget.config['organization']

@@ -0,0 +1,93 @@ 

+ from __future__ import unicode_literals

+ 

+ import logging

+ import hubs.utils

+ 

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

+ 

+ import fedmsg.config

+ fedmsg_config = fedmsg.config.load_config()

+ 

+ log = logging.getLogger(__name__)

+ 

+ 

+ class GitHubPRs(Widget):

+ 

+     name = "github_pr"

+     position = "right"

+     parameters = [

+         dict(

+             name="organization",

+             label="Organization",

+             default=None,

+             validator=validators.github_organization,

+             help="Github Organization or username",

+         ), dict(

+             name="display_number",

+             label="Number of tickets",

+             default=6,

+             validator=validators.integer,

+             help="How many pull requests to display at max.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "github_pr.html"

+ 

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

+         get_prs = GetPRs(instance)

+         org = instance.config["organization"]

+         display_number = int(instance.config["display_number"])

+         context = dict(

+             organization=org,

+             display_number=display_number,

+             title="Github: Pull Requests",

+         )

+         context.update(get_prs())

+         return context

+ 

+ 

+ class GetPRs(CachedFunction):

+ 

+     def execute(self):

+         org = self.instance.config["organization"]

+         display_number = int(self.instance.config["display_number"])

+         log.info("Getting GH prs for %r, (%r)" % (org, display_number))

+         token = fedmsg_config.get('github.oauth_token')

+         pulls = []

+         displayed_number = 0

+         more = 0

+         if token:

+             repos = hubs.utils.github_repos(token, org)

+             pulls = sum([

+                 list(hubs.utils.github_pulls(token, org, repo))

+                 for repo in repos

+             ], [])

+             # Reverse-sort by time (newest-first)

+             pulls.sort(lambda a, b: cmp(b['timestamp'], a['timestamp']))

+ 

+             # Some hints for display

+             len_pulls = len(pulls)

+             displayed_number = min(display_number, len_pulls)

+             more = max(len_pulls - displayed_number, 0)

+ 

+         return dict(

+             more=more,

+             pulls=pulls,

+         )

+ 

+     def should_invalidate(self, message):

+         category = message["topic"].split('.')[3]

+         if category != "github":

+             return False

+         owner = message['msg']['repository']['owner']

+         if 'login' in owner:

+             owner = owner['login']

+         else:

+             owner = owner['name']

+         return owner == self.instance.config['organization']

hubs/widgets/github_pr/templates/github_pr.html hubs/widgets/templates/github_pr.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <a class="btn btn-success" target="_blank"

      href="https://github.com/{{ organization }}" >

    Organization Github Page
@@ -44,3 +47,4 @@ 

    </li>

  {% endif %}

  </ul>

+ {% endblock %}

@@ -1,53 +0,0 @@ 

- from __future__ import unicode_literals

- 

- import requests

- 

- from hubs.hinting import hint

- from hubs.widgets import templating

- from hubs.widgets.chrome import panel

- from hubs.widgets.base import argument

- import hubs.validators as validators

- 

- chrome = panel("Github: Newest Open Tickets")

- template = templating.environment.get_template('templates/githubissues.html')

- position = 'right'

- 

- 

- @argument(name="repo",

-           default=None,

-           validator=validators.github_organization,

-           help="Github Organization or username")

- def data(session, widget, display_number, org, repo):

-     ''' Data for Github Issues widget. Queries github api for issues '''

- 

-     url = '/'.join(['https://api.github.com/repos', org, repo, "issues"])

-     issues = requests.get(url).json()

- 

-     all_issues = []

-     for issue in issues[:10]:

-         issue_details = {}

-         issue_details['num'] = issue['number']

-         issue_details['title'] = issue['title']

-         issue_details['openedby'] = issue['user']['login']

- 

-         issue_assignee = None

-         if issue['assignee'] is not None:

-             issue_assignee = issue['assignee']['login']

- 

-         issue_details['assignee'] = issue_assignee

-         all_issues.append(issue_details)

- 

-     return dict(

-         org=org,

-         repo=repo,

-         all_issues=all_issues.reverse(),

-         display_number=display_number,

-     )

- 

- 

- @hint()

- def should_invalidate(message, session, widget):

-     ''' Checks if the Github Issue widget needs an update.

-     Called by a backend daemon listening to fedmsg '''

- 

-     raise NotImplementedError

@@ -0,0 +1,81 @@ 

+ from __future__ import unicode_literals

+ 

+ import requests

+ 

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

+ 

+ 

+ class GitHubIssues(Widget):

+ 

+     name = "githubissues"

+     position = "right"

+     parameters = [

+         dict(

+             name="org",

+             label="Username",

+             default=None,

+             validator=validators.github_organization,

+             help="Github Organization or username",

+         ), dict(

+             name="repo",

+             label="Repository",

+             default=None,

+             validator=validators.github_repo,

+             help="Github repository",

+         ), dict(

+             name="display_number",

+             label="Number of tickets",

+             default=10,

+             validator=validators.integer,

+             help="The number of tickets to display.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "githubissues.html"

+ 

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

+         get_issues = GetIssues(instance)

+         return dict(

+             org=instance.config["org"],

+             repo=instance.config["repo"],

+             display_number=int(instance.config["display_number"]),

+             title="Github: Newest Open Tickets",

+             all_issues=get_issues(),

+         )

+ 

+ 

+ class GetIssues(CachedFunction):

+     ''' Data for Github Issues widget. Queries github api for issues '''

+ 

+     def execute(self):

+         org = self.instance.config["org"]

+         repo = self.instance.config["repo"]

+         display_number = int(self.instance.config["display_number"])

+         url = '/'.join(['https://api.github.com/repos', org, repo, "issues"])

+         issues = requests.get(url).json()

+ 

+         all_issues = []

+         for issue in issues[:display_number]:

+             issue_details = {}

+             issue_details['num'] = issue['number']

+             issue_details['title'] = issue['title']

+             issue_details['openedby'] = issue['user']['login']

+ 

+             issue_assignee = None

+             if issue['assignee'] is not None:

+                 issue_assignee = issue['assignee']['login']

+ 

+             issue_details['assignee'] = issue_assignee

+             all_issues.append(issue_details)

+ 

+         all_issues.reverse()

+         return all_issues

+ 

+     def should_invalidate(self, message):

+         raise NotImplementedError

hubs/widgets/githubissues/templates/githubissues.html hubs/widgets/templates/githubissues.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  {% for i in range(display_number) %}

  <div>

    <table class="table-responsive table-condensed">
@@ -36,3 +39,4 @@ 

  <div class="row">

    <a href="https://github.com/{{ org }}/{{ repo }}/issues" target="_blank"><center>All Issues</center></a>

  </div>

+ {% endblock %}

file removed
-42
@@ -1,42 +0,0 @@ 

- from __future__ import unicode_literals

- 

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.chrome import panel

- from hubs.widgets.base import argument

- from hubs.widgets import clean_input

- from hubs.widgets import templating

- 

- import hubs.validators as validators

- 

- chrome = panel("Library")

- template = templating.environment.get_template('templates/library.html')

- position = 'both'

- 

- 

- @argument(name="urls", default=None,

-           validator=validators.text,

-           help="A comma separated list of URLs to add to the library. "

-           "External links must include the whole link (starting with http...)")

- def data(session, widget, urls):

-     ''' Data for the Library widget. Given the widget, it

-     returns the hypertext links of it's configuration urls '''

- 

-     urls = [

-         clean_input.clean(

-             '<a href="{0}">{0}</a>'.format(u.strip()))

-         for u in widget.config.get('urls', '').split(',')

-         if u.strip()

-     ]

-     return dict(urls=urls)

- 

- 

- @hint(topics=[_('hubs.widget.update')])

- def should_invalidate(message, session, widget):

-     ''' Check if the Library widget cache needs an update.

-     Called by a backend daemon listening to fedmsg '''

- 

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

-         return False

-     if message['msg']['widget']['id'] != widget.id:

-         return False

-     return True

@@ -0,0 +1,39 @@ 

+ from __future__ import unicode_literals

+ 

+ from hubs import validators

+ from hubs.widgets import clean_input

+ from hubs.widgets.base import Widget, WidgetView

+ 

+ 

+ class Library(Widget):

+ 

+     name = "library"

+     position = "both"

+     parameters = [dict(

+         name="urls",

+         label="URLs",

+         default=None,

+         validator=validators.text,

+         help="A comma separated list of URLs to add to the library. "

+              "External links must include the whole link "

+              "(starting with http...)."

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "library.html"

+ 

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

+         urls = [

+             clean_input.clean(

+                 '<a href="{0}">{0}</a>'.format(u.strip()))

+             for u in instance.config.get('urls', '').split(',')

+             if u.strip()

+         ]

+         return dict(

+             title="Library",

+             urls=urls,

+             )

hubs/widgets/library/templates/library.html hubs/widgets/templates/library.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <div class="rules-container">

    <div class="row">

      <ul>
@@ -9,3 +12,4 @@ 

      </ul>

    </div>

  </div>

+ {% endblock %}

@@ -1,43 +0,0 @@ 

- from __future__ import unicode_literals

- 

- from hubs.hinting import hint

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- 

- import hubs.validators as validators

- 

- import fedmsg.config

- config = fedmsg.config.load_config()

- 

- from hubs.widgets.chrome import panel

- chrome = panel("Weekly Activity")

- template = templating.environment.get_template('templates/linechart.html')

- position = 'left'

- 

- 

- @argument(name="username",

-           default=None,

-           validator=validators.username,

-           help="A FAS username.")

- def data(session, widget, username):

-     categories = ['git', 'Wiki', 'Copr', 'anitya', 'mirrormanager', 'ansible',

-                   'fedoratagger', 'Pkgdb', 'summershum', 'nuancier', 'Mailman',

-                   'fedbadges', 'FMN', 'koschei', 'compose', 'fedimg',

-                   'Jenkins', 'irc', 'FAS', 'buildsys', 'Askbot', 'pagure',

-                   'Bodhi', 'faf', 'kerneltest', 'github', 'Trac', 'meetbot',

-                   'planet', 'fedocal', 'hotness']

-     categories = [c.lower() for c in categories]

-     categories = "&".join(['category=%s' % c for c in categories])

-     url = "https://apps.fedoraproject.org/datagrepper/charts/stackedline" \

-           "?delta=604800&N=12&style=clean&height=300&fill=true"\

-           "&user={username}&split_on=categories"

-     url = url + "&" + categories

-     url = url.format(username=username)

-     return dict(url=url)

- 

- 

- @hint(usernames=lambda widget: [widget.config['username']])

- def should_invalidate(message, session, widget):

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

-     username = widget.config['username']

-     return username in usernames

@@ -0,0 +1,46 @@ 

+ from __future__ import unicode_literals

+ 

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ 

+ 

+ class Linechart(Widget):

+ 

+     name = "linechart"

+     position = "left"

+     parameters = [dict(

+         name="username",

+         label="Username",

+         default=None,

+         validator=validators.username,

+         help="A FAS username.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "linechart.html"

+ 

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

+         username = instance.config["username"]

+         categories = [

+             'git', 'Wiki', 'Copr', 'anitya', 'mirrormanager', 'ansible',

+             'fedoratagger', 'Pkgdb', 'summershum', 'nuancier', 'Mailman',

+             'fedbadges', 'FMN', 'koschei', 'compose', 'fedimg',

+             'Jenkins', 'irc', 'FAS', 'buildsys', 'Askbot', 'pagure',

+             'Bodhi', 'faf', 'kerneltest', 'github', 'Trac', 'meetbot',

+             'planet', 'fedocal', 'hotness',

+             ]

+         categories = [c.lower() for c in categories]

+         categories = "&".join(['category=%s' % c for c in categories])

+         url = ("https://apps.fedoraproject.org/datagrepper/charts/stackedline"

+                "?delta=604800&N=12&style=clean&height=300&fill=true"

+                "&user={username}&split_on=categories")

+         url = url + "&" + categories

+         url = url.format(username=username)

+         return dict(

+             title="Weekly Activity",

+             url=url,

+             )

hubs/widgets/linechart/templates/linechart.html hubs/widgets/templates/linechart.html
file renamed
+4
@@ -1,3 +1,7 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <div class="flex-container">

      <img src="{{url}}" />

  </div>

+ {% endblock %}

hubs/widgets/meetings/__init__.py hubs/widgets/meetings.py
file renamed
+91 -74
@@ -1,20 +1,99 @@ 

  from __future__ import unicode_literals

  

- from hubs.hinting import hint

- from hubs.widgets.base import argument

- from hubs.widgets.chrome import panel

- from hubs.widgets import templating

- 

- from hubs import validators

- from hubs import utils

- 

- import hubs.models

- 

  import arrow

  import collections

  import datetime

  import requests

  

+ from hubs import validators

+ from hubs import utils

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

+ 

+ 

+ class Meetings(Widget):

+ 

+     name = "meetings"

+     position = "both"

+     parameters = [

+         dict(

+             name="calendar",

+             label="Calendar",

+             default=None,

+             validator=validators.required,

+             help="A fedocal calendar.",

+         ), dict(

+             name="n_meetings",

+             label="Number of meetings",

+             default=4,

+             validator=validators.integer,

+             help="The number of meetings to display.",

+         )]

+ 

+     def get_template_environment(self):

+         env = super(Meetings, self).get_template_environment()

+         # Add a filter

+         env.filters['humanize'] = lambda d: arrow.get(d).humanize()

+         return env

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "meetings.html"

+ 

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

+         get_meetings = GetMeetings(instance)

+         return dict(

+             title="Meetings",

+             calendar=instance.config["calendar"],

+             meetings=get_meetings(),

+             )

+ 

+ 

+ class GetMeetings(CachedFunction):

+ 

+     def execute(self):

+         calendar = self.instance.config["calendar"]

+         n_meetings = int(self.instance.config.get("n_meetings", 4))

+         base = ('https://apps.fedoraproject.org/calendar/api/meetings/'

+                 '?calendar=%s')

+         url = base % calendar

+         response = requests.get(url).json()

+ 

+         tmp = collections.defaultdict(list)

+         for meeting in response['meetings']:

+             if meeting.get('meeting_information_html'):

+                 meeting['meeting_information_html'] = utils.markup(

+                     meeting['meeting_information'])

+             tmp[meeting['meeting_name']].append(meeting)

+ 

+         meetings = {}

+         for title, items in tmp.items():

+             selected = next_meeting(items)

+             if not selected:

+                 continue

+             meetings[title] = selected

+             if len(meetings) >= n_meetings:

+                 break

+ 

+         return meetings

+ 

+     def should_invalidate(self, message):

+         # TODO -- first, if this is a fedocal widget, we need to just

+         # invalidate ourselves right away.

+ 

+         # second, check our old cache value and see if any of our meetings have

+         # passed by in time.

+         old_meetings = self.execute()

+         now = datetime.datetime.utcnow()

+         for title, meeting in old_meetings.items():

+             assert type(meeting['start_dt']) == type(now)

+             if meeting['start_dt'] < now:

+                 return True

+         return False

+ 

  

  def next_meeting(meetings):

      now = datetime.datetime.utcnow()
@@ -46,72 +125,10 @@ 

          meeting['location'] = meeting['meeting_location'] or ''

          if '@' in meeting['location']:

              meeting['location'] = '#' + meeting['location'].split('@')[0]

-         if stop_dt - start_dt >= datetime.timedelta(hours=24) :

+         if stop_dt - start_dt >= datetime.timedelta(hours=24):

              meeting['display_time'] = False

-             if stop_dt - start_dt > datetime.timedelta(hours=24) :

+             if stop_dt - start_dt > datetime.timedelta(hours=24):

                  meeting['display_duration'] = True

          return meeting

  

      return None

- 

- 

- footer_template = templating.environment.get_template('templates/meeting_footer.html')

- chrome = panel(title='Meetings', footer_template=footer_template)

- templating.environment.filters['humanize'] = lambda d: arrow.get(d).humanize()

- template = templating.environment.get_template('templates/meetings.html')

- position = 'both'

- 

- 

- @argument(name="calendar",

-           default=None,

-           validator=validators.required,

-           help="A fedocal calendar.")

- @argument(name="n_meetings",

-           default=4,

-           validator=validators.integer,

-           help="The number of meetings to display.")

- def data(session, widget, calendar, n_meetings=4):

-     n_meetings = int(n_meetings)

-     base = 'https://apps.fedoraproject.org/calendar/api/meetings/?calendar=%s'

-     url = base % calendar

-     response = requests.get(url).json()

- 

-     tmp = collections.defaultdict(list)

-     for meeting in response['meetings']:

-         if meeting.get('meeting_information_html'):

-             meeting['meeting_information_html'] = utils.markup(

-                 meeting['meeting_information'])

-         tmp[meeting['meeting_name']].append(meeting)

- 

-     meetings = {}

-     for title, items in tmp.items():

-         selected = next_meeting(items)

-         if not selected:

-             continue

-         meetings[title] = selected

-         if len(meetings) >= n_meetings:

-             break

- 

-     return dict(

-         calendar=calendar,

-         meetings=meetings,

-     )

- 

- 

- @hint(ubiquitous=True)

- def should_invalidate(message, session, widget):

- 

-     # TODO -- first, if this is a fedocal widget, we need to just invalidate

-     # ourselves right away.

- 

-     # second, check our old cache value and see if any of our meetings have

-     # passed by in time.

-     old_data = data(session, widget, **widget.config)

-     meetings = old_data['meetings']

-     now = datetime.datetime.utcnow()

-     for title, meeting in meetings.items():

-         assert type(meeting['start_dt']) == type(now)

-         if meeting['start_dt'] < now:

-             return True

- 

-     return False

hubs/widgets/meetings/templates/meetings.html hubs/widgets/templates/meetings.html
file renamed
+15
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  {% for title, next in meetings.items() %}

  <div class="meeting">

    <div class="row">
@@ -46,3 +49,15 @@ 

    </div>

  </div>

  {% endfor %}

+ {% endblock %}

+ 

+ 

+ {% block footer %}

+ <div class="panel-footer">

+   <div class="row" align="center">

+     <button class="btn btn-default">

+       <strong>Request A New Meeting</strong>

+     </button>

+   </div>

+ </div>

+ {% endblock %}

@@ -1,41 +0,0 @@ 

- from __future__ import unicode_literals

- 

- import hubs.models

- from hubs.hinting import hint, prefixed as _

- import hubs.validators as validators

- from hubs.widgets.base import argument

- from hubs.widgets.chrome import panel

- from hubs.widgets import templating

- 

- ELLIPSIS_LIMIT = 3

- chrome = panel('Hubs')

- template = templating.environment.get_template('templates/memberships.html')

- position = 'both'

- 

- 

- def data(session, widget, **kwargs):

-     hub = widget.hub

-     members = []

-     if hub.user_hub:

-         user = hubs.models.User.by_username(hub.name)

-         members = [m.__json__() for m in user.memberships

-                    if m.name != user.username]

-     else:

-         members_name = []

-         for member in widget.hub.members:

-             if member.username in members_name:

-                 continue

-             members_name.append(member.username)

-             members.append(member.__json__())

- 

-     oldest_members = sorted(members,

-                             key=lambda m: m.get('created_on'))[:ELLIPSIS_LIMIT]

-     return dict(memberships=list(members), oldest_members=list(oldest_members))

- 

- 

- @hint(topics=[_('hubs.hub.update')])

- def should_invalidate(message, session, widget):

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

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

-             return True

-     return False

@@ -0,0 +1,44 @@ 

+ from __future__ import unicode_literals

+ 

+ import hubs.models

+ 

+ from hubs.widgets.base import Widget, WidgetView

+ 

+ 

+ ELLIPSIS_LIMIT = 3

+ 

+ 

+ class Memberships(Widget):

+ 

+     name = "memberships"

+     position = "both"

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "memberships.html"

+ 

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

+         hub = instance.hub

+         members = []

+         if hub.user_hub:

+             user = hubs.models.User.by_username(hub.name)

+             members = [m.__json__() for m in user.memberships

+                        if m.name != user.username]

+         else:

+             members_name = []

+             for member in hub.members:

+                 if member.username in members_name:

+                     continue

+                 members_name.append(member.username)

+                 members.append(member.__json__())

+ 

+         oldest_members = sorted(

+             members, key=lambda m: m.get('created_on'))[:ELLIPSIS_LIMIT]

+         return dict(

+             title="Hubs",

+             memberships=list(members),

+             oldest_members=list(oldest_members),

+             )

hubs/widgets/memberships/templates/memberships.html hubs/widgets/templates/memberships.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <div class="card-block">

    {% if memberships|length > oldest_members|length %}

    {% for member in oldest_members %}
@@ -74,3 +77,4 @@ 

     padding: 10px;

   }

  </style>

+ {% endblock %}

hubs/widgets/pagure_pr/__init__.py hubs/widgets/pagure_pr.py
file renamed
+69 -46
@@ -1,57 +1,80 @@ 

  from __future__ import unicode_literals

  

- from hubs.hinting import hint

- from hubs.widgets.chrome import panel

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- 

- import hubs.validators as validators

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

  

  import requests

  

  pagure_url = "https://pagure.io/api/0"

  

- chrome = panel("Newest Open Pull Requests on Pagure")

- template = templating.environment.get_template('templates/pagure_pr.html')

- position = 'right'

- 

- 

- @argument(name="repo",

-           default=None,

-           validator=validators.pagure_repo,

-           help="Pagure repo name")

- def data(session, widget, repo):

-     url = '/'.join([pagure_url, repo, "pull-requests"])

-     response = requests.get(url)

-     data = response.json()

-     total_req = data['total_requests']

-     all_pr = list()

- 

-     for request in data['requests']:

-         pr_project_user = None

-         if request['project']['parent']:

-             pr_project_user = request['project']['user']['username']

- 

-         all_pr.append(

-             dict(

-                 pr_project_name=request['project']['name'],

-                 pr_project_user=pr_project_user,

-                 pr_id=request['id'],

-                 pr_title=request['title'][:45],

-                 pr_title_full=request['title'],

-                 pr_openedby=request['user']['name'],

-                 pr_assignee=request['assignee'],

+ 

+ class PagurePRs(Widget):

+ 

+     name = "pagure_pr"

+     position = "right"

+     parameters = [

+         dict(

+             name="repo",

+             label="Repository",

+             default=None,

+             validator=validators.pagure_repo,

+             help="Pagure repo name.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "pagure_pr.html"

+ 

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

+         get_prs = GetPRs(instance)

+         context = dict(

+             title="Newest Open Pull Requests on Pagure",

+             repo=instance.config["repo"],

              )

-         )

+         context.update(get_prs())

+         return context

+ 

+ 

+ class GetPRs(CachedFunction):

+ 

+     def execute(self):

+         repo = self.instance.config["repo"]

+         url = '/'.join([pagure_url, repo, "pull-requests"])

+         response = requests.get(url)

+         data = response.json()

+         total_req = data['total_requests']

+         all_pr = list()

  

-     return dict(

-         all_pr=all_pr,

-         total_req=total_req,

-         repo=repo,

-     )

+         for request in data['requests']:

+             pr_project_user = None

+             if request['project']['parent']:

+                 pr_project_user = request['project']['user']['username']

  

+             all_pr.append(

+                 dict(

+                     pr_project_name=request['project']['name'],

+                     pr_project_user=pr_project_user,

+                     pr_id=request['id'],

+                     pr_title=request['title'][:45],

+                     pr_title_full=request['title'],

+                     pr_openedby=request['user']['name'],

+                     pr_assignee=request['assignee'],

+                 )

+             )

+ 

+         return dict(

+             all_pr=all_pr,

+             total_req=total_req,

+         )

  

- # TODO -- this hint could be honed in more to just PRs

- @hint(categories=['pagure'])

- def should_invalidate(message, session, widget):

-     return message['msg']['project']['name'] == widget.config['repo']

+     def should_invalidate(self, message):

+         category = message["topic"].split('.')[3]

+         if category != "pagure":

+             # TODO -- this could be honed in more to just PRs

+             return False

+         return (message['msg']['project']['name'] ==

+                 self.instance.config['repo'])

hubs/widgets/pagure_pr/templates/pagure_pr.html hubs/widgets/templates/pagure_pr.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <ul class="media-list">

    {% for pr in all_pr[:10] %}

      <li class="media">
@@ -46,3 +49,4 @@ 

      href="https://pagure.io/{{ repo }}/pull-requests">

    All Pull-Requests

  </a>

+ {% endblock %}

hubs/widgets/pagureissues/__init__.py hubs/widgets/pagureissues.py
file renamed
+72 -57
@@ -2,76 +2,91 @@ 

  

  import requests

  

- from hubs.hinting import hint

- from hubs.widgets.chrome import panel

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- import hubs.validators as validators

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

  

  pagure_url = "https://pagure.io/api/0"

  

- chrome = panel("Newest Open Tickets on Pagure")

- template = templating.environment.get_template('templates/pagureissues.html')

- position = 'right'

  

+ class PagureIssues(Widget):

  

- @argument(name="repo",

-           default=None,

-           validator=validators.pagure_repo,

-           help="Pagure repo name")

- def data(session, widget, repo):

+     name = "pagureissues"

+     position = "right"

+     parameters = [

+         dict(

+             name="repo",

+             label="Repository",

+             default=None,

+             validator=validators.pagure_repo,

+             help="Pagure repo name",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "pagureissues.html"

+ 

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

+         get_issues = GetIssues(instance)

+         context = dict(

+             title="Newest Open Tickets on Pagure",

+             repo=instance.config["repo"],

+             )

+         context.update(get_issues())

+         return context

+ 

+ 

+ class GetIssues(CachedFunction):

      ''' Data for pagure issues widget

      Queries Pagure api for issues '''

  

-     repo = "pagure"

+     def execute(self):

+         repo = self.instance.config["repo"]

  

-     url = '/'.join([pagure_url, repo, "issues"])

-     issue_response = requests.get(url)

-     data = issue_response.json()

-     total = data['total_issues']

+         url = '/'.join([pagure_url, repo, "issues"])

+         issue_response = requests.get(url)

+         data = issue_response.json()

+         total = data['total_issues']

  

-     all_issues = list()

-     for issue in data['issues']:

+         all_issues = list()

+         for issue in data['issues']:

  

-         issue_assignee = None

-         if issue['assignee']:

-             issue_assignee = issue['assignee']['name']

+             issue_assignee = None

+             if issue['assignee']:

+                 issue_assignee = issue['assignee']['name']

  

-         issue_project_user = None

-         if 'project' in issue:

-             issue_project_name = issue['project']['name']

-             if issue['project']['parent']:

-                 issue_project_user = issue['project']['user']['username']

-             else:

-                 if '/' in repo:

-                     issue_project_name, issue_project_user = repo.split('/', 1)

+             issue_project_user = None

+             if 'project' in issue:

+                 issue_project_name = issue['project']['name']

+                 if issue['project']['parent']:

+                     issue_project_user = issue['project']['user']['username']

                  else:

-                     issue_project_name = repo

- 

-         all_issues.append(

-             dict(

-                 issue_project_name=issue_project_name,

-                 issue_project_user=issue_project_user,

-                 issue_id=issue['id'],

-                 issue_title=issue['title'][:45],

-                 issue_title_full=issue['title'],

-                 issue_openedby=issue['user']['name'],

-                 issue_assignee=issue_assignee,

+                     if '/' in repo:

+                         issue_project_name, issue_project_user = \

+                             repo.split('/', 1)

+                     else:

+                         issue_project_name = repo

+ 

+             all_issues.append(

+                 dict(

+                     issue_project_name=issue_project_name,

+                     issue_project_user=issue_project_user,

+                     issue_id=issue['id'],

+                     issue_title=issue['title'][:45],

+                     issue_title_full=issue['title'],

+                     issue_openedby=issue['user']['name'],

+                     issue_assignee=issue_assignee,

+                 )

              )

-         )

- 

-     all_issues.reverse()

  

-     return dict(

-         repo=repo,

-         total=total,

-         all_issues=all_issues,

-     )

- 

- 

- @hint()

- def should_invalidate(message, session, widget):

-     ''' Checks whether pagureissues widget cache needs an update

-     Run by backend daemon listening to fedmsg '''

+         all_issues.reverse()

+         return dict(

+             total=total,

+             all_issues=all_issues,

+         )

  

-     raise NotImplementedError

+     def should_invalidate(self, message):

+         raise NotImplementedError

hubs/widgets/pagureissues/templates/pagureissues.html hubs/widgets/templates/pagureissues.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <a class="btn btn-success" target="_blank"

      href="https://pagure.io/{{ repo }}/issues">

    All Issues
@@ -45,3 +48,4 @@ 

    </li>

    {% endif %}

  </ul>

+ {% endblock %}

@@ -0,0 +1,76 @@ 

+ from __future__ import unicode_literals

+ 

+ from collections import OrderedDict

+ from importlib import import_module

+ from hubs.widgets.base import Widget

+ 

+ 

+ class WidgetRegistry(OrderedDict):

+     """

+     The widget registry.

+ 

+     It behaves like an ordered dictionary where widget names are keys and

+     widget class instances are values.  There are additional methods to

+     register widgets and widget-specific routes.

+ 

+     Widgets are registered using their class path in the following format:

+     `python.path.to.module:TheWidgetClass`.

+     """

+ 

+     def register_list(self, widget_list):

+         """

+         Register a list of widget class paths.

+ 

+         Args:

+             widget_list (list): List of Python paths to widget classes that

+                 must be registered.

+         """

+         for widget_path in widget_list:

+             self.register(widget_path)

+ 

+     def register(self, widget_path):

+         """

+         Register a widget.

+ 

+         Args:

+             widget_path (str): Python path to the subclass of

+                 :py:class:`hubs.widgets.base.Widget`. The format is

+                 `python.path.to.module:TheWidgetClass`.

+         """

+         mod_path, cls_name = widget_path.split(':', 1)

+         mod = import_module(mod_path)

+         try:

+             widget_class = getattr(mod, cls_name)

+         except AttributeError:

+             raise ValueError("Can't find widget %s" % widget_path)

+         if not issubclass(widget_class, Widget):

+             raise ValueError(

+                 "Widget %s must be a subclass of %s.Widget"

+                 % (widget_path, Widget.__module__))

+         widget = widget_class()

+         if not widget.name:

+             prefix = 'hubs.widgets.'

+             if widget_path.startswith(prefix):

+                 widget.name = widget_path[len(prefix):]

+             else:

+                 widget.name = widget_path

+         try:

+             widget.validate()

+         except AttributeError as e:

+             raise AttributeError(

+                 "Error registering %s: %s" % (widget_path, e))

+         self[widget.name] = widget

This does not look right.

+ 

+     def register_routes(self, app):

+         """

+         Register widget-specific routes with the provided app.

+ 

+         This is not done automatically to allow widgets to be registered

+         without instantiating the Flask application.

+ 

+         Args:

+             app (flask.Flask): The Flask application to register the views

+                 with.

+         """

+         for widget in self.values():

+             widget.register_routes(app)

file removed
-58
@@ -1,58 +0,0 @@ 

- from __future__ import unicode_literals

- 

- from collections import OrderedDict as ordereddict

- 

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.chrome import panel

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- from hubs.utils import username2avatar

- from hubs import validators

- 

- ELLIPSIS_LIMIT = 5

- chrome = panel()

- template = templating.environment.get_template('templates/rules.html')

- position = 'both'

- 

- 

- @argument(name='link', default=None,

-           validator=validators.link,

-           help="Link to the community rules and guidelines")

- @argument(name='schedule_text', default=None,

-           validator=validators.text,

-           help="Some text about when meetings are")

- @argument(name='schedule_link', default=None,

-           validator=validators.link,

-           help="Link to a schedule for IRC meetings, etc..")

- @argument(name='minutes_link', default=None,

-           validator=validators.link,

-           help="Link to meeting minutes from past meetings..")

- def data(session, widget, link, schedule_text, schedule_link, minutes_link):

-     owners = widget.hub.owners

-     oldest_owners = sorted(owners, key=lambda o: o.created_on)[:ELLIPSIS_LIMIT]

-     oldest_owners = [{

-         'username': o.username,

-         'avatar': username2avatar(o.username)

-     } for o in oldest_owners]

- 

-     owners = ordereddict([

-         (o.username, username2avatar(o.username)) for o in owners

-     ])

-     return dict(oldest_owners=oldest_owners,

-                 owners=owners, link=link,

-                 schedule_text=schedule_text,

-                 schedule_link=schedule_link,

-                 minutes_link=minutes_link)

- 

- 

- @hint(topics=[_('hubs.widget.update'), _('hubs.hub.update')])

- def should_invalidate(message, session, widget):

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

-         if message['msg']['widget']['id'] == widget.id:

-             return True

- 

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

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

-             return True

- 

-     return False

@@ -0,0 +1,70 @@ 

+ from __future__ import unicode_literals

+ 

+ from collections import OrderedDict as ordereddict

+ 

+ from hubs import validators

+ from hubs.utils import username2avatar

+ from hubs.widgets.base import Widget, WidgetView

+ 

+ 

+ ELLIPSIS_LIMIT = 5

+ 

+ 

+ class Rules(Widget):

+ 

+     name = "rules"

+     position = "both"

+     parameters = [

+         dict(

+             name="link",

+             label="Link",

+             default=None,

+             validator=validators.link,

+             help="Link to the community rules and guidelines.",

+         ), dict(

+             name="schedule_text",

+             label="Schedule text",

+             default=None,

+             validator=validators.text,

+             help="Some text about when meetings are.",

+         ), dict(

+             name="schedule_link",

+             label="Schedule link",

+             default=None,

+             validator=validators.link,

+             help="Link to a schedule for IRC meetings, etc.",

+         ), dict(

+             name="minutes_link",

+             label="Minutes link",

+             default=None,

+             validator=validators.link,

+             help="Link to meeting menutes from past meetings.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "rules.html"

+ 

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

+         owners = instance.hub.owners

+         oldest_owners = sorted(

+             owners, key=lambda o: o.created_on)[:ELLIPSIS_LIMIT]

+         oldest_owners = [{

+             'username': o.username,

+             'avatar': username2avatar(o.username)

+         } for o in oldest_owners]

+ 

+         owners = ordereddict([

+             (o.username, username2avatar(o.username)) for o in owners

+         ])

+         return dict(

+             oldest_owners=oldest_owners,

+             owners=owners,

+             link=instance.config["link"],

+             schedule_text=instance.config["schedule_text"],

+             schedule_link=instance.config["schedule_link"],

+             minutes_link=instance.config["minutes_link"],

+             )

hubs/widgets/rules/templates/rules.html hubs/widgets/templates/rules.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <div class="rules-container">

    {% if link %}

    <h6>community rules</h6>
@@ -85,3 +88,4 @@ 

     border-bottom: 0;

   }

  </style>

+ {% endblock %}

file removed
-51
@@ -1,51 +0,0 @@ 

- from __future__ import unicode_literals

- 

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.chrome import panel

- from hubs.widgets import templating

- 

- from hubs.utils import commas

- 

- import flask

- 

- chrome = panel()

- template = templating.environment.get_template('templates/stats.html')

- position = 'right'

- 

- 

- def data(session, widget):

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

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

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

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

- 

-     return dict(

-         owners=owners,

-         members=members,

-         subscribers=subscribers,

-         stargazers=stargazers,

- 

-         owners_text=commas(len(owners)),

-         members_text=commas(len(members)),

-         subscribers_text=commas(len(subscribers)),

-         stargazers_text=commas(len(stargazers)),

- 

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

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

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

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

-         hub_subscribe_url=flask.url_for('hub_subscribe', hub=widget.hub.name),

-         hub_unsubscribe_url=flask.url_for(

-             'hub_unsubscribe', hub=widget.hub.name),

-     )

- 

- 

- @hint(topics=[_('hubs.hub.update')])

- def should_invalidate(message, session, widget):

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

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

-             return True

- 

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

- 

-     return False

@@ -0,0 +1,64 @@ 

+ from __future__ import unicode_literals

+ 

+ import flask

+ 

+ from hubs.utils import commas

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

+ 

+ 

+ class Stats(Widget):

+ 

+     name = "stats"

+     position = "right"

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "stats.html"

+ 

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

+         get_stats = GetStats(instance)

+         return get_stats()

+ 

+ 

+ class GetStats(CachedFunction):

+ 

+     def execute(self):

+         hub = self.instance.hub

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

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

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

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

+ 

+         return dict(

+             owners=owners,

+             members=members,

+             subscribers=subscribers,

+             stargazers=stargazers,

+ 

+             owners_text=commas(len(owners)),

+             members_text=commas(len(members)),

+             subscribers_text=commas(len(subscribers)),

+             stargazers_text=commas(len(stargazers)),

+ 

+             hub_leave_url=flask.url_for('hub_leave', hub=hub.name),

+             hub_join_url=flask.url_for('hub_join', hub=hub.name),

+             hub_unstar_url=flask.url_for('hub_unstar', hub=hub.name),

+             hub_star_url=flask.url_for('hub_star', hub=hub.name),

+             hub_subscribe_url=flask.url_for('hub_subscribe', hub=hub.name),

+             hub_unsubscribe_url=flask.url_for(

+                 'hub_unsubscribe', hub=hub.name),

+         )

+ 

+     def should_invalidate(self, message):

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

+             return False

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

+             return True

+ 

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

+ 

+         return False

hubs/widgets/stats/templates/stats.html hubs/widgets/templates/stats.html
file renamed
+4
@@ -1,3 +1,6 @@ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

  <div class="stats-container row">

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

    <table class="stats-table">
@@ -72,3 +75,4 @@ 

    </ul>

  </div>

  </div>

+ {% endblock %}

file removed
-29
@@ -1,29 +0,0 @@ 

- from __future__ import unicode_literals

- 

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.chrome import panel

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- 

- import hubs.validators as validators

- 

- chrome = panel(title='Sticky Note', klass="card-info")

- template = templating.environment.get_template('templates/sticky.html')

- position = 'both'

- 

- 

- @argument(name="text", default="Lorem ipsum dolor...",

-           validator=validators.text,

-           help="Some dummy text to display.")

- def data(session, widget, text):

-     # TODO -- render with markdown

-     return dict(text=text)

- 

- 

- @hint(topics=[_('hubs.widget.update')])

- def should_invalidate(message, session, widget):

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

-         if message['msg']['widget']['id'] == widget.id:

-             return True

- 

-     return False

@@ -0,0 +1,33 @@ 

+ from __future__ import unicode_literals

+ 

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ 

+ 

+ class Sticky(Widget):

+ 

+     name = "sticky"

+     position = "both"

+     parameters = [

+         dict(

+             name="text",

+             label="Text",

+             default="Lorem ipsum dolor...",

+             validator=validators.text,

+             help="Some dummy text to display.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "sticky.html"

+ 

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

+         # TODO -- render with markdown

+         return dict(

+             text=instance.config["text"],

+             title="Sticky Note",

+             panel_css_class="card-info",

+             )

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

+ {% extends "panel.html" %}

+ 

+ {% block content %}

+ {{text}}

+ {% endblock %}

hubs/widgets/subscriptions/__init__.py hubs/widgets/subscriptions.py
file renamed
+55 -37
@@ -1,46 +1,43 @@ 

  from __future__ import unicode_literals

  

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.base import argument

- from hubs.widgets.chrome import panel

- from hubs.widgets import templating

+ import flask

+ import hubs.models

  

  from hubs import validators

- 

- import hubs.models

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

  

  from fedmsg.meta.base import BaseConglomerator as BC

  

  import fedmsg.config

  fm_config = fedmsg.config.load_config()

  

- import flask

  

- chrome = panel('Hubs', key='associations')

- template = templating.environment.get_template('templates/subscriptions.html')

- position = 'right'

- 

- 

- @argument(name="username",

-           default=None,

-           validator=validators.username,

-           help="A FAS username.")

- def data(session, widget, username):

-     user = hubs.models.User.by_username(username)

-     ownerships = [u.name for u in user.ownerships]

-     memberships = [u.name for u in user.memberships]

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

-     subscriptions_list = manage_subscriptions(subscriptions)

-     memberships_list = manage_subscriptions(memberships)

-     return dict(

-         associations=memberships + ownerships,

-         ownerships=ownerships,

-         memberships=memberships,

-         subscriptions=subscriptions,

-         ownerships_text=BC.list_to_series(ownerships),

-         memberships_text=BC.list_to_series(memberships_list),

-         subscriptions_text=BC.list_to_series(subscriptions_list),

-     )

+ class Subscriptions(Widget):

+ 

+     name = "subscriptions"

+     position = "right"

+     parameters = [

+         dict(

+             name="username",

+             label="Username",

+             default=None,

+             validator=validators.username,

+             help="A FAS username.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     template_name = "subscriptions.html"

+ 

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

+         context = dict(title="Hubs")

+         get_subs = GetSubs(instance)

+         context.update(get_subs())

+         return context

  

  

  # function hyperlinks the hubs in the subscription widget
@@ -53,8 +50,29 @@ 

      ]

  

  

- @hint(topics=[_('hubs.associate')])

- def should_invalidate(message, session, widget):

-     username = widget.config['username']

-     users = fedmsg.meta.msg2usernames(message, **fm_config)

-     return username in users

+ class GetSubs(CachedFunction):

+ 

+     def execute(self):

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

+         user = hubs.models.User.by_username(username)

+         ownerships = [u.name for u in user.ownerships]

+         memberships = [u.name for u in user.memberships]

+         subscriptions = [u.name for u in user.subscriptions]

+         subscriptions_list = manage_subscriptions(subscriptions)

+         memberships_list = manage_subscriptions(memberships)

+         return dict(

+             associations=memberships + ownerships,

+             ownerships=ownerships,

+             memberships=memberships,

+             subscriptions=subscriptions,

+             ownerships_text=BC.list_to_series(ownerships),

+             memberships_text=BC.list_to_series(memberships_list),

+             subscriptions_text=BC.list_to_series(subscriptions_list),

+         )

+ 

+     def should_invalidate(self, message):

+         if not message['topic'].endswith('hubs.associate'):

+             return False

+         username = self.instance.config['username']

+         users = fedmsg.meta.msg2usernames(message, **fm_config)

+         return username in users

hubs/widgets/subscriptions/templates/subscriptions.html hubs/widgets/templates/subscriptions.html
file renamed
+4
@@ -1,4 +1,7 @@ 

  {% if associations %}

+ {% extends "panel.html" %}

+ 

+ {% block content %}

      {% if memberships %}

          <p><strong>Belongs to: </strong> {{memberships_text}}</p>

      {% endif %}
@@ -10,4 +13,5 @@ 

          {{subscription}}</a>

          {% endfor %}</p>

      {% endif %}

+ {% endblock %}

  {% endif %}

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

- <p>{{text}}</p>

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

- {{text}}

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

- <div class="panel-footer">

-   <div class="row" align="center">

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

-       <strong>Request A New Meeting</strong>

-     </button>

-   </div>

- </div>

@@ -1,18 +1,22 @@ 

- <div class="card {{ klass }}">

+ <div class="card {{ panel_css_class|default('panel-default') }} widget-{{ widget.name }}">

    <div class="pull-right widget-buttons">

      <!-- the AGPLv3 wrapper puts the source url in all responses -->

-     <a href="{{ source_url }}"><span><i class="fa fa-eye" aria-hidden="true"></i></span></a>

-     <a href="{{ widget_url }}"><span><i class="fa fa-external-link" aria-hidden="true"></i></span></a>

+     <a href="{{ url_for('widget_source', name=widget.name) }}"><span><i class="fa fa-eye" aria-hidden="true"></i></span></a>

+     <a href="{{ url_for('%s_root' % widget.name, hub=widget_instance.hub.name, idx=widget_instance.idx) }}">

+         <span><i class="fa fa-external-link" aria-hidden="true"></i></span>

+     </a>

      <a data-target="#edit_modal" data-toggle="modal" type="button"

-         class="edit_widget" data-idx="{{ widget.idx }}">

+         class="edit_widget" data-idx="{{ widget_instance.idx }}">

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

      </a>

    </div>

-   {{ heading }}

+   {% if title %}

+   <div class="card-header">

+     {{title}}

+   </div> <!-- end card-header -->

+   {% endif %}

    <div class="card-block">

-     {{ content }}

+     {% block content %}{% endblock %}

    </div> <!-- end card-block -->

-   {% if footer %}

-     {{footer}}

-   {% endif %}

+   {% block footer %}{% endblock %}

  </div> <!-- end card -->

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

- <div class="card-header">

-     {{title}}

- </div> <!-- end card-header -->

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

- {{text}}

@@ -1,12 +0,0 @@ 

- from __future__ import unicode_literals

- 

- import os

- 

- import jinja2

- 

- here = os.path.dirname(os.path.abspath(__file__))

- 

- environment = jinja2.Environment(

-     loader=jinja2.FileSystemLoader(here),

-     trim_blocks=True,

- )

file added
+100
@@ -0,0 +1,100 @@ 

+ from __future__ import unicode_literals, absolute_import

+ 

+ from flask.views import View

+ 

+ 

+ class WidgetView(View):

+     """

+     This class is the skeleton of a widget-specific view.

+ 

+     You must subclass this class to use it in your view:

+ 

+     - the :py:attr:`.name` attribute must be defined

+     - the :py:attr:`.template_name` attribute must be defined, or the

+       :py:meth:`.get_template` method must be implemented

+     - the :py:meth:`.get_context` method must be implemented

+ 

+     Every widget must have a view with the name "``root``" registered to

+     ``['/']``, which will be the main entry point for the widget.

+ 

+     The resulting endpoint will be composed using the widget name and the view

+     name as ``<widget_name>_<view_name>``, 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

+     (:py:attr:`hubs.models.Widget.idx`).

+ 

+     Attributes:

+         name (str): The view name. It will be used to compose the URL endpoint,

+             following the "``<widget.name>_<view.name>``" convention.

+         url_rules (list): A list of URL rules that this view must be registered

+             for (like the ``rule`` parameter of Flask's

+             :py:meth:`add_url_rule`).

+         template_name (str): The template name to use for this view. It will be

+             looked for in the widget's ``templates`` subdirectory first, and

+             then in the global ``hubs/widgets/templates/`` directory.

+ 

+     This class is Flask-specific. If another framework were to be switched to,

+     it would have to be re-implemented.

+     """

+ 

+     name = None

+     url_rules = []

+     template_name = None

+ 

+     def __init__(self, widget):

+         """

+         Args:

+             widget (hubs.widgets.base.Widget): the widget that uses this view.

+         """

+         self.widget = widget

+         if self.name is None:

+             raise NotImplementedError

+ 

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

+         """

+         Return the template context for this view.

+ 

+         Args:

+             instance (hubs.models.Widget): the widget instance.

+         """

+         raise NotImplementedError

+ 

+     def get_template(self):

+         """

+         Return the template object, looking for :py:attr:`template_name` in the

+         widget's template environment.

+         """

+         if self.template_name is None:

+             return NotImplementedError

+         tpl_env = self.widget.get_template_environment()

+         template = tpl_env.get_template(self.template_name)

+         return template

+ 

+     def get_extra_context(self, instance, *args, **kwargs):

+         """

+         Add extra data to the template context.

+         """

+         # Put source links in all API results

+         return {

+             'widget': self.widget,

+             'widget_instance': instance,

+         }

+ 

+     def _get_instance(self, *args, **kwargs):

+         from hubs.views.utils import get_widget_instance

+         hubname = kwargs.pop("hub")

+         widgetidx = kwargs.pop("idx")

+         return get_widget_instance(hubname, widgetidx)

+ 

+     def dispatch_request(self, *args, **kwargs):

+         """

+         The method that subclasses of :py:class:`flask.views.View` must

+         implement. It binds the other methods together.

+         """

+         instance = self._get_instance(*args, **kwargs)

+         context = self.get_context(instance, *args, **kwargs)

+         context.update(self.get_extra_context(instance, *args, **kwargs))

+         return self.get_template().render(**context)

@@ -2,52 +2,67 @@ 

  

  import requests

  

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.base import argument

- from hubs.widgets import templating

+ from hubs import validators

  from hubs.utils import username2avatar

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

  

- import hubs.validators as validators

- 

- from hubs.widgets.chrome import panel

- # If 'pending_acls' is empty, then don't render any chrome.

- chrome = panel('Pending ACL Requests', key='pending_acls')

- # TODO -- add approve/deny buttons or just link through to pkgdb

- template = templating.environment.get_template(

-     'templates/workflow/pendingacls.html')

- position = 'right'

- 

- 

- @argument(name="username",

-           default=None,

-           validator=validators.username,

-           help="A FAS username.")

- def data(session, widget, username):

- 

-     # TODO -- rewrite this to

-     # 1) use the datagrepper API instead of the direct pkgdb API

-     # 2) so that we can use the fedmsg.meta.conglomerate API to

-     # 3) group messages nicely in the UI instead of having repeats

-     # It will be slower, but that's fine because of our smart cache.

- 

-     baseurl = "https://admin.fedoraproject.org/pkgdb/api/pendingacls"

-     query = "?username={username}&format=json".format(username=username)

-     url = baseurl + query

-     response = requests.get(url)

-     data = response.json()

-     for acl in data['pending_acls']:

-         acl['avatar'] = username2avatar(acl['user'], s=32)

-     data['username'] = username

-     return data

- 

- 

- @hint(topics=[_('pkgdb.acl.update')])

- def should_invalidate(message, session, widget):

-     # Search the message to see if I am in the ACLs list of the request.

-     username = widget.config['username']

- 

-     for acl in message['msg']['package_listing']['acls']:

-         if acl['fas_name'] == username and acl['status'] == 'Approved':

-             return True

- 

-     return False

+ 

+ class PendingACLs(Widget):

+ 

+     name = "workflow.pendingacls"

+     position = "right"

+     parameters = [

+         dict(

+             name="username",

+             label="Username",

+             default=None,

+             validator=validators.username,

+             help="A FAS username.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     # TODO -- add approve/deny buttons or just link through to pkgdb

+     template_name = "pendingacls.html"

+ 

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

+         get_pending = GetPending(instance)

+         context = dict(

+             title='Pending ACL Requests',

+             username=instance.config["username"],

+             )

+         context.update(get_pending())

+         return context

+ 

+ 

+ class GetPending(CachedFunction):

+ 

+     def execute(self):

+         # TODO -- rewrite this to

+         # 1) use the datagrepper API instead of the direct pkgdb API

+         # 2) so that we can use the fedmsg.meta.conglomerate API to

+         # 3) group messages nicely in the UI instead of having repeats

+         # It will be slower, but that's fine because of our cache.

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

+         baseurl = "https://admin.fedoraproject.org/pkgdb/api/pendingacls"

+         query = "?username={username}&format=json".format(username=username)

+         url = baseurl + query

+         response = requests.get(url)

+         data = response.json()

+         for acl in data['pending_acls']:

+             acl['avatar'] = username2avatar(acl['user'], s=32)

+         return data

+ 

+     def should_invalidate(self, message):

+         if not message['topic'].endswith('pkgdb.acl.update'):

+             return False

+         # Search the message to see if I am in the ACLs list of the request.

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

+         for acl in message['msg']['package_listing']['acls']:

+             if acl['fas_name'] == username and acl['status'] == 'Approved':

+                 return True

+         return False

hubs/widgets/workflow/templates/pendingacls.html hubs/widgets/templates/workflow/pendingacls.html
file renamed
+6
@@ -1,4 +1,8 @@ 

  {% if pending_acls %}

+ 

+ {% extends "panel.html" %}

+ 

+ {% block content %}

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

  <a class="btn btn-success" target="_blank" href="https://admin.fedoraproject.org/pkgdb/acl/pending/">Manage requests</a>

  <hr/>
@@ -17,4 +21,6 @@ 

      </li>

  {% endfor %}

  </ul>

+ {% endblock %}

+ 

  {% endif %}

hubs/widgets/workflow/templates/updates2stable.html hubs/widgets/templates/workflow/updates2stable.html
file renamed
+4
@@ -1,4 +1,7 @@ 

  {% if updates %}

+ {% extends "panel.html" %}

+ 

+ {% block content %}

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

  <a class="btn btn-success" target="_blank" href="https://admin.fedoraproject.org/updates/mine">Manage updates</a>

  <hr/>
@@ -18,4 +21,5 @@ 

      </li>

  {% endfor %}

  </ul>

+ {% endblock %}

  {% endif %}

@@ -2,60 +2,45 @@ 

  

  import requests

  

- from hubs.hinting import hint, prefixed as _

- from hubs.widgets.base import argument

- from hubs.widgets import templating

- import hubs.validators as validators

+ from hubs import validators

+ from hubs.widgets.base import Widget, WidgetView

+ from hubs.widgets.caching import CachedFunction

  

  

- from hubs.widgets.chrome import panel

- # If 'updates' is empty, then don't render any chrome.

- chrome = panel('Updates Ready for Stable', key='updates')

- # TODO -- add approve/deny buttons or just link through to pkgdb

- template = templating.environment.get_template(

-     'templates/workflow/updates2stable.html')

- position = 'right'

- 

- # TODO - the bodhi api exposes a new flag we can use instead of scraping comments...

+ # TODO - the bodhi api exposes a new flag we can use instead of scraping

+ # comments...

  giveaway = 'can be pushed to stable now'

  

  

- @argument(name="username",

-           default=None,

-           validator=validators.username,

-           help="A FAS username.")

- def data(session, widget, username):

- 

-     # First, get all of my updates currently in testing.

-     bodhiurl = 'https://bodhi.fedoraproject.org/updates/'

-     query = '?user={username}&status=testing'.format(username=username)

-     url = bodhiurl + query

-     headers = {'Accept': 'application/json'}

-     response = requests.get(url, headers=headers)

-     if response.status_code == 200:

-         data = response.json()

- 

-         # Limit this to only the updates that haven't already requested stable

-         # but which can actually be pushed to stable now.

-         data['updates'] = [

-             update for update in data['updates']

-             if update['request'] is None and

-             any([giveaway in comment['text'] for comment in update['comments']])

-         ]

- 

-         # Stuff some useful information in there.

-         baseurl = 'https://apps.fedoraproject.org/packages/images/icons/'

-         for update in data['updates']:

-             build = update['builds'][0]

-             nvr = build['nvr']

-             package = parse_nvr(nvr)

-             update['icon'] = baseurl + package[0] + '.png'

-             update['link'] = bodhiurl + update['title']

- 

-         return data

-     else:

-         data = {}

-         return data

+ class Updates2Stable(Widget):

+ 

+     name = "workflow.updates2stable"

+     position = "right"

+     parameters = [

+         dict(

+             name="username",

+             label="Username",

+             default=None,

+             validator=validators.username,

+             help="A FAS username.",

+         )]

+ 

+ 

+ class BaseView(WidgetView):

+ 

+     name = "root"

+     url_rules = ["/"]

+     # TODO -- add approve/deny buttons or just link through to pkgdb

+     template_name = "updates2stable.html"

+ 

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

+         get_pending = GetPending(instance)

+         context = dict(

+             title='Updates Ready for Stable',

+             username=instance.config["username"],

+             )

+         context.update(get_pending())

+         return context

  

  

  def parse_nvr(nvr):
@@ -64,11 +49,47 @@ 

      return ['-'.join(x[:-2]), x[-2], x[-1]]

  

  

- @hint(topics=[_('bodhi.update.comment')])

- def should_invalidate(message, session, widget):

-     if giveaway in message['msg']['comment']['text']:

-         username = widget.config['username']

-         if username == message['msg']['comment']['update_submitter']:

-             return True

- 

-     return False

+ class GetPending(CachedFunction):

+ 

+     def execute(self):

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

+         # First, get all of my updates currently in testing.

+         bodhiurl = 'https://bodhi.fedoraproject.org/updates/'

+         query = '?user={username}&status=testing'.format(username=username)

+         url = bodhiurl + query

+         headers = {'Accept': 'application/json'}

+         response = requests.get(url, headers=headers)

+         if response.status_code == 200:

+             data = response.json()

+ 

+             # Limit this to only the updates that haven't already requested

+             # stable but which can actually be pushed to stable now.

+             data['updates'] = [

+                 update for update in data['updates']

+                 if update['request'] is None and

+                 any([giveaway in comment['text']

+                      for comment in update['comments']])

+             ]

+ 

+             # Stuff some useful information in there.

+             baseurl = 'https://apps.fedoraproject.org/packages/images/icons/'

+             for update in data['updates']:

+                 build = update['builds'][0]

+                 nvr = build['nvr']

+                 package = parse_nvr(nvr)

+                 update['icon'] = baseurl + package[0] + '.png'

+                 update['link'] = bodhiurl + update['title']

+ 

+             return data

+         else:

+             data = {}

+             return data

+ 

+     def should_invalidate(self, message):

+         if not message['topic'].endswith('bodhi.update.comment'):

+             return False

+         if giveaway in message['msg']['comment']['text']:

+             username = self.instance.config['username']

+             if username == message['msg']['comment']['update_submitter']:

+                 return True

+         return False

file modified
+16 -4
@@ -6,12 +6,24 @@ 

  import json

  

  import hubs.models

+ import hubs.widgets

  

  import fedmsg.config

  fedmsg_config = fedmsg.config.load_config()

  

  session = hubs.models.init(fedmsg_config['hubs.sqlalchemy.uri'], True, True)

  

+ # Register widgets we will use

+ hubs.widgets.registry.register_list([

+     "hubs.widgets.contact.Contact",

+     "hubs.widgets.stats.Stats",

+     "hubs.widgets.rules.Rules",

+     "hubs.widgets.meetings.Meetings",

+     "hubs.widgets.about.About",

+     "hubs.widgets.sticky.Sticky",

+     "hubs.widgets.dummy.Dummy",

+     ])

+ 

  users = ['mrichard', 'duffy', 'ryanlerch', 'gnokii', 'nask0',

           'abompard', 'decause', 'ralph', 'lmacken', 'croberts', 'mattdm',

           'pravins', 'keekri', 'linuxmodder', 'bee2502', 'jflory7']
@@ -87,7 +99,7 @@ 

  hub.widgets.append(widget)

  

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

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

+                             _config=json.dumps({'calendar': 'commops', 'n_meetings': 4}))

  hub.widgets.append(widget)

  

  # Added a hubs about widget
@@ -134,7 +146,7 @@ 

  hub.widgets.append(widget)

  

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

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

+                             _config=json.dumps({'calendar': 'marketing', 'n_meetings': 4}))

  hub.widgets.append(widget)

  

  # Added a hubs about widget
@@ -182,7 +194,7 @@ 

  hub.widgets.append(widget)

  

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

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

+                             _config=json.dumps({'calendar': 'design', 'n_meetings': 4}))

  hub.widgets.append(widget)

  

  # Added a hubs about widget
@@ -231,7 +243,7 @@ 

  hub.widgets.append(widget)

  

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

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

+                             _config=json.dumps({'calendar': 'infrastructure', 'n_meetings': 4}))

  hub.widgets.append(widget)

  

  # Added a hubs about widget

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

  psycopg2

  pytz

  requests

+ setuptools

  sqlalchemy

  markdown

  pkgwat.api

file modified
+14 -8
@@ -18,8 +18,10 @@ 

  import fedmsg.meta

  

  

+ import hubs.app

  import hubs.models

  import hubs.widgets.base

+ import hubs.widgets.caching

  

  # get the DB session

  fedmsg_config = fedmsg.config.load_config()
@@ -27,21 +29,24 @@ 

  

  session = hubs.models.init(fedmsg_config['hubs.sqlalchemy.uri'])

  

+ # Register widgets

+ hubs.widgets.registry.register_list(hubs.app.app.config["WIDGETS"])

+ 

  

  def do_list(args):

      ''' List the different widget for which there is data cached. '''

-     for widget in session.query(hubs.models.Widget).all():

-         key = hubs.widgets.base.cache_key_generator(widget, **widget.config)

-         result = hubs.widgets.base.cache.get(key, ignore_expiration=True)

-         if not isinstance(result, dogpile.cache.api.NoValue):

-             print('- Widget cached {0} (#{1}) in {2}'.format(

-                       widget.hub_id, widget.idx, widget.plugin))

+     for w_instance in session.query(hubs.models.Widget).all():

+         widget = w_instance.module

+         for fn_name, fn_class in widget.get_cached_functions().items():

+             if fn_class(w_instance).is_cached():

+                 print('- Widget cached {0} (#{1}) in {2}'.format(

+                           w_instance.hub_id, w_instance.idx, w_instance.plugin))

  

  

  def do_clean(args):

      ''' Clean the widget for which there is data cached. '''

      for widget in args.widgets:

-         wid_obj = hubs.models.Widget.get(session, widget)

+         wid_obj = hubs.models.Widget.get(widget)

  

          if not wid_obj:

              wid_obj = session.query(hubs.models.Widget).filter_by(
@@ -52,7 +57,8 @@ 

  

          print('- Removing cached {0} (#{1}) in {2}'.format(

                wid_obj.hub_id, wid_obj.idx, wid_obj.plugin))

-         result = hubs.widgets.base.invalidate_cache(wid_obj, **wid_obj.config)

+         for fn_class in wid_obj.module.get_cached_functions().values():

+             fn_class(wid_obj).invalidate()

  

  

  def setup_parser():

file modified
+3 -1
@@ -30,7 +30,9 @@ 

  

  [testenv:docs]

  changedir = docs

- deps = sphinx

+ deps =

+     -rrequirements.txt

typo? :)

+     sphinx

  whitelist_externals =

      mkdir

  commands=

OK, this is a big one. This branch redesigns the widget plugin system in hubs to use classes instead of Python modules.

In this new world, widgets are Python classes in their own module, which is usually in their own directory (but they don't have to be).

Here are the components involved for a widget:

  • a widget class
  • one or more view(s) that will be registered with the app
  • templates which are now in a subdirectory of the widget itself
  • cached functions, which are functions whoose results get cached and invalidated when the right message is recieved on the bus (as before). There can be as many cached functions as necessary per widget.

I would recommend reading the integrated documentation, you can build it with the tox -e docs command and read it with xdg-open ./docs/_build/html/api.html. If there's something unclear, please tell me, I'll fix it.

Improvements over the current system :

  • widgets can reside anywhere that is Python-accessible, which means they don't have to be inside the hubs app itself : widgets can be added from external modules, which makes it easier to have out-of-tree widgets, for people wanting to contribute to hubs or having their own site-specific widgets (there may be other installs of hubs one day).
  • widgets can have more than one view, and the root view (ex-data function) isn't any different from the others
  • the files are in their own directory, including templates (but not CSS files or JS, that's for later) which makes it easier to know what belongs to a widget and what belongs to the hubs app
  • the widget registration isn't static, it can be set in the config file, which means that the admin can disable or enable more widgets without touching the code
  • there's no implicit decoration of the functions anymore, which made it hard to understand where the data was coming from
  • the caching is separated from the view context, which enables view to have no caching, or to use multiple cached function to generate their output (with different invalidation policies)
  • there's way less global variables (like the widget templating environment, that one widget could edit and involuntarily propagate the change to other widgets).
  • the chrome template decoration system has been replaced with jinja template extention, it's the exact use cas for this feature.
  • the widget parameters have gained a label key which will make configuration more human-readable
  • probably other minor things I forgot... ;-)

I recognize that it's a big change, so I'd like to add the following :

  • try to read commits in order, I've made them topic-specific, so you can understand what's being changed and why. Reading the whole "Files changed" page is actually much harder.
  • I'm available for any questions you may have
  • I've converted all existing widgets, and if you don't want to convert the widget you're working on, I pledge to convert it myself (it's actually not that hard once you understand how the pieces fit together)

I'm working my way along the commits and I'll leave comments inline as I run across stuff. I'll let you know when I've made it all the way through my review (and I don't know that I'll finish today).

It might be better to use the scheme python.path.to.module:TheModuleAttribute since that's what is used in most other Python tooling I've seen (pdb when specifying breakpoints, Python entry points, etc).

Not a big deal, but when I see all dots I think that the last thing is also a module.

Did you switch this from a namedtuple to a class so that it could be extended? If so, you can easily extend namedtuples by taking the old class's _fields list and creating a new one like this.

s/instanciate/instantiate

Is this left-over from something?

I'm guessing this is a way to let the function just define a set of topics/categories it wants to trigger a refresh? Makes sense. My preference is to not have commented-out blocks of code and to make issues/RFEs instead, though.

This seems like a reasonable thing to log at debug level so I think it'd be good to uncomment it.

Can you not just pass self.execute rather than using lambda?

It's a little touch, but I like to link this sort of stuff out to documentation:

`Jinja2 <http://jinja.pocoo.org/docs/>`_ 

should work. So should

`Jinja2`

.. _Jinja2:
   http://jinja.pocoo.org/docs/

It's a total nitpick and you should feel free to not bother :smile:

Ugh, I can't edit that comment, but I noticed right after I submitted the second example should be

`Jinja2`_

.. _Jinja2:
   http://jinja.pocoo.org/docs/

I think you want Attributes: here since these are class attributes.

It might be better to use the scheme python.path.to.module:TheModuleAttribute

Yeah I hesitated. Django doesn't use the colon, neither does nose2, so it's not unanimous. I'll just switch to it if it's more familiar for you guys, I just think we should pick one and stick to it :-)

Did you switch this from a namedtuple to a class so that it could be extended?

Actually, the tuple behavior of Argument wasn't used anywhere in the code, so it felt like this should have been a class all along. It's also more extendable, we can provide defaults for some attributes if necessary (e.g the label could be derived from the name if not given).

I think you want Attributes: here since these are class attributes.

Fixed, thanks.

Can you not just pass self.execute rather than using lambda?

Right, that's a leftover from when this method had an argument.

I'm guessing this is a way to let the function just define a set of topics/categories it wants to trigger a refresh? Makes sense. My preference is to not have commented-out blocks of code and to make issues/RFEs instead, though.

I agree, I'll remove it.

Other fixes done, thanks a lot for your review.

1 new commit added

  • Implement fixes from jcline's review
7 years ago

Isn't this an abstract method?

Yeah, now that I think of it it doesn't make much sense to have a default implementation that will invalidate the cache on every message. I'll make it abstract.

1 new commit added

  • Make CachedFunction.should_invalidate() abstract
7 years ago

The universe started only after this PR :alien:

The universe started only after this PR 👽

agreed!

2 new commits added

  • Sort the widget registry by name
  • Fix the help text in the widgets config template
7 years ago

45 new commits added

  • Sort the widget registry by name
  • Fix the help text in the widgets config template
  • Make CachedFunction.should_invalidate() abstract
  • Implement fixes from jcline's review
  • Allow the widget to declare where the views and cached functions are
  • More explicit widget validation errors
  • Make a real class for WidgetParameter
  • Improve API doc crossreferences
  • Build the API documentation with Sphinx
  • Adapt dev-guide documentation
  • Fix compatibility with Python 2
  • Move widget templates inside the widget directory
  • Replace the AGPLv3 template variables with template tags
  • Replace the chrome wrapper with template inheritance
  • Convert the workflow widgets
  • Convert the subscriptions widget
  • Convert the sticky widget
  • Convert the stats widget
  • Convert the rules widget
  • Convert the pagure_pr widget
  • Convert the pagureissues widget
  • Convert the memberships widget
  • Convert the meetings widget
  • Convert the linechart widget
  • Convert the library widget
  • Convert the github_pr widget
  • Convert the githubissues widget
  • Convert the fhosted widget
  • Convert the feed widget
  • Convert the fedmsgstats widget
  • Convert the dummy widget
  • Convert the contact widget
  • Convert the bugzilla widget
  • Convert the badges widget
  • Convert the about widget
  • Adapt the cache scripts to the new CachedFunction
  • Adapt the backend workers to the new CachedFunction
  • Add the Flask variables and filters to widget template env
  • Adapt views and templates to the new widget class
  • Rename get_widget to get_widget_instance
  • Adapt the models to the new widget class
  • Create a widget registry class
  • Create a base class for widgets
  • Add a class for widget-specific views
  • Add a CachedFunction class
7 years ago

This does not look right.

The registry is a subclass of OrderedDict, so it's basically a dict with additional features.
self[key] = value is how one would set a key to the dict from an internal method. What bothers you with it?

This does not look right.

The registry is a subclass of OrderedDict, so it's basically a dict with additional features.
self[key] = value is how one would set a key to the dict from an internal method. What bothers you with it?

Sorry, My mistake

I changed it to an OrderedDict so the admin could choose in which order the widgets appear on the "Add new widget" config box. But if you prefer the old way, I can revert this commit and just sort the list alphabetically in the view.

I skimmed the PR again, and I'm generally very happy with it. Fully disclosure, I didn't spend tons of time scrutinizing the widget conversions, but I think we can handle any bugs in that as they come up.

Thanks for doing this, it looks great :clap:! I'm satisfied, but I'll defer to sayan since this is big enough to deserve a couple pairs of eyes.

Thanks for your review Jeremy.

This PR looks great to me too. :ok_hand: I am setting up the hubs-devel.fic.org and planning to deploy this.

Great, I'm merging it then. Thanks a lot for you reviews! :)

My offer to migrate the widgets you're working on still stands if you need it :)

Pull-Request has been merged by abompard

7 years ago
Metadata
Changes Summary 107
+18 -13
file changed
check-cache-coverage.py
+42
file added
docs/api.rst
+10 -5
file changed
docs/conf.py
+3 -60
file changed
docs/dev-guide.rst
+1 -0
file changed
docs/index.rst
+7 -0
file changed
hubs/app.py
+0 -0
file changed
hubs/backend/consumer.py
+13 -17
file changed
hubs/backend/triage.py
+7 -9
file changed
hubs/backend/worker.py
+26 -0
file changed
hubs/default_config.py
+1 -0
file changed
hubs/defaults.py
-37
file removed
hubs/hinting.py
+10 -10
file changed
hubs/models.py
+12 -16
file changed
hubs/templates/add_widget.html
+11 -15
file changed
hubs/templates/edit.html
+1 -1
file changed
hubs/templates/includes/left_widgets.html
+1 -1
file changed
hubs/templates/includes/right_widgets.html
+1 -1
file changed
hubs/tests/__init__.py
+3 -3
file changed
hubs/tests/test_fedora_hubs_flask_api.py
+140
file added
hubs/tests/test_widget_base.py
+74
file added
hubs/tests/test_widget_caching.py
-70
file removed
hubs/tests/test_widget_routes.py
+3 -25
file changed
hubs/tests/test_widgets/test_about.py
+8 -4
file changed
hubs/tests/test_widgets/test_badges.py
+0 -3
file changed
hubs/tests/test_widgets/test_fedmsgstats.py
+3 -6
file changed
hubs/tests/test_widgets/test_library.py
+2 -1
file changed
hubs/tests/test_widgets/test_meetings.py
+43
file added
hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_meetings.TestMeetings.test_render_simple
+4 -0
file changed
hubs/validators.py
+0 -42
file changed
hubs/views/__init__.py
+8 -13
file changed
hubs/views/hub.py
+1 -2
file changed
hubs/views/utils.py
+21 -25
file changed
hubs/views/widget.py
+85 -130
file changed
hubs/widgets/__init__.py
-33
file removed
hubs/widgets/about.py
+32
file added
hubs/widgets/about/__init__.py
+5
file added
hubs/widgets/about/templates/about.html
-43
file removed
hubs/widgets/badges.py
+56
file added
hubs/widgets/badges/__init__.py
+5 -1
file renamed
hubs/widgets/templates/badges.html
hubs/widgets/badges/templates/badges.html
+189 -105
file changed
hubs/widgets/base.py
+81 -68
file renamed
hubs/widgets/bugzilla.py
hubs/widgets/bugzilla/__init__.py
+4 -0
file renamed
hubs/widgets/templates/bugzilla.html
hubs/widgets/bugzilla/templates/bugzilla.html
+130
file added
hubs/widgets/caching.py
-32
file removed
hubs/widgets/chrome.py
+59 -51
file renamed
hubs/widgets/contact.py
hubs/widgets/contact/__init__.py
+4 -0
file renamed
hubs/widgets/templates/contact.html
hubs/widgets/contact/templates/contact.html
-28
file removed
hubs/widgets/dummy.py
+30
file added
hubs/widgets/dummy/__init__.py
+5
file added
hubs/widgets/dummy/templates/dummy.html
-51
file removed
hubs/widgets/fedmsgstats.py
+74
file added
hubs/widgets/fedmsgstats/__init__.py
+4 -0
file renamed
hubs/widgets/templates/fedmsgstats.html
hubs/widgets/fedmsgstats/templates/fedmsgstats.html
-36
file removed
hubs/widgets/feed.py
+47
file added
hubs/widgets/feed/__init__.py
+4 -0
file renamed
hubs/widgets/templates/feed.html
hubs/widgets/feed/templates/feed.html
+81 -61
file renamed
hubs/widgets/fhosted.py
hubs/widgets/fhosted/__init__.py
+4 -0
file renamed
hubs/widgets/templates/fedorahosted.html
hubs/widgets/fhosted/templates/fhosted.html
-69
file removed
hubs/widgets/github_pr.py
+93
file added
hubs/widgets/github_pr/__init__.py
+4 -0
file renamed
hubs/widgets/templates/github_pr.html
hubs/widgets/github_pr/templates/github_pr.html
-53
file removed
hubs/widgets/githubissues.py
+81
file added
hubs/widgets/githubissues/__init__.py
+4 -0
file renamed
hubs/widgets/templates/githubissues.html
hubs/widgets/githubissues/templates/githubissues.html
-42
file removed
hubs/widgets/library.py
+39
file added
hubs/widgets/library/__init__.py
+4 -0
file renamed
hubs/widgets/templates/library.html
hubs/widgets/library/templates/library.html
-43
file removed
hubs/widgets/linechart.py
+46
file added
hubs/widgets/linechart/__init__.py
+4 -0
file renamed
hubs/widgets/templates/linechart.html
hubs/widgets/linechart/templates/linechart.html
+91 -74
file renamed
hubs/widgets/meetings.py
hubs/widgets/meetings/__init__.py
+15 -0
file renamed
hubs/widgets/templates/meetings.html
hubs/widgets/meetings/templates/meetings.html
-41
file removed
hubs/widgets/memberships.py
+44
file added
hubs/widgets/memberships/__init__.py
+4 -0
file renamed
hubs/widgets/templates/memberships.html
hubs/widgets/memberships/templates/memberships.html
+69 -46
file renamed
hubs/widgets/pagure_pr.py
hubs/widgets/pagure_pr/__init__.py
+4 -0
file renamed
hubs/widgets/templates/pagure_pr.html
hubs/widgets/pagure_pr/templates/pagure_pr.html
+72 -57
file renamed
hubs/widgets/pagureissues.py
hubs/widgets/pagureissues/__init__.py
+4 -0
file renamed
hubs/widgets/templates/pagureissues.html
hubs/widgets/pagureissues/templates/pagureissues.html
+76
file added
hubs/widgets/registry.py
-58
file removed
hubs/widgets/rules.py
+70
file added
hubs/widgets/rules/__init__.py
+4 -0
file renamed
hubs/widgets/templates/rules.html
hubs/widgets/rules/templates/rules.html
-51
file removed
hubs/widgets/stats.py
+64
file added
hubs/widgets/stats/__init__.py
+4 -0
file renamed
hubs/widgets/templates/stats.html
hubs/widgets/stats/templates/stats.html
-29
file removed
hubs/widgets/sticky.py
+33
file added
hubs/widgets/sticky/__init__.py
+5
file added
hubs/widgets/sticky/templates/sticky.html
+55 -37
file renamed
hubs/widgets/subscriptions.py
hubs/widgets/subscriptions/__init__.py
+4 -0
file renamed
hubs/widgets/templates/subscriptions.html
hubs/widgets/subscriptions/templates/subscriptions.html
-1
file removed
hubs/widgets/templates/about.html
-1
file removed
hubs/widgets/templates/dummy.html
-7
file removed
hubs/widgets/templates/meeting_footer.html
+13 -9
file changed
hubs/widgets/templates/panel.html
-3
file removed
hubs/widgets/templates/panel_heading.html
-1
file removed
hubs/widgets/templates/sticky.html
-12
file removed
hubs/widgets/templating.py
+100
file added
hubs/widgets/view.py
+62 -47
file changed
hubs/widgets/workflow/pendingacls.py
+6 -0
file renamed
hubs/widgets/templates/workflow/pendingacls.html
hubs/widgets/workflow/templates/pendingacls.html
+4 -0
file renamed
hubs/widgets/templates/workflow/updates2stable.html
hubs/widgets/workflow/templates/updates2stable.html
+78 -57
file changed
hubs/widgets/workflow/updates2stable.py
+16 -4
file changed
populate.py
+1 -0
file changed
requirements.txt
+14 -8
file changed
smart_cache_invalidator.py
+3 -1
file changed
tox.ini