From 652158d17aaf465bdb0a3dba5cfef7e4134f2f37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Thu, 9 Jul 2015 15:43:11 +0200 Subject: [PATCH] WIP: Announcement plugin --- THIRDPARTYLICENSES.md | 3 +- setup.py | 3 +- .../plugins/announcements/__init__.py | 273 ++++++++++++++++++ .../announcements/static/js/announcements.js | 268 +++++++++++++++++ .../static/less/announcements.less | 37 +++ .../templates/announcements.jinja2 | 44 +++ .../templates/announcements_navbar.jinja2 | 3 + .../templates/announcements_settings.jinja2 | 34 +++ src/octoprint/settings.py | 4 +- 9 files changed, 665 insertions(+), 4 deletions(-) create mode 100644 src/octoprint/plugins/announcements/__init__.py create mode 100644 src/octoprint/plugins/announcements/static/js/announcements.js create mode 100644 src/octoprint/plugins/announcements/static/less/announcements.less create mode 100644 src/octoprint/plugins/announcements/templates/announcements.jinja2 create mode 100644 src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2 create mode 100644 src/octoprint/plugins/announcements/templates/announcements_settings.jinja2 diff --git a/THIRDPARTYLICENSES.md b/THIRDPARTYLICENSES.md index 2ac46c7d..ec3a6674 100644 --- a/THIRDPARTYLICENSES.md +++ b/THIRDPARTYLICENSES.md @@ -32,10 +32,11 @@ ## Server + * [feedparser](https://github.com/kurtmckee/feedparser): BSD * [Flask](http://flask.pocoo.org/): BSD * [Flask-Assets](http://github.com/miracle2k/flask-assets): BSD * [Flask-Babel](http://github.com/mitsuhiko/flask-babel): BSD - * [Flask-Login](https://flask-login.readthedocs.org/en/latest/https://github.com/maxcountryman/flask-login): MIT + * [Flask-Login](https://github.com/maxcountryman/flask-login): MIT * [Flask-Markdown](http://github.com/dcolish/flask-markdown): BSD * [Flask-Principal](http://packages.python.org/Flask-Principal/): MIT * [netaddr](https://github.com/drkjam/netaddr/): BSD diff --git a/setup.py b/setup.py index 6479414a..aaf98e98 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,8 @@ INSTALL_REQUIRES = [ "requests==2.7.0", "semantic_version==2.4.2", "psutil==3.2.1", - "awesome-slugify>=1.6.5,<1.7" + "awesome-slugify>=1.6.5,<1.7", + "feedparser>=5.2.1,<5.3" ] # Additional requirements for optional install options diff --git a/src/octoprint/plugins/announcements/__init__.py b/src/octoprint/plugins/announcements/__init__.py new file mode 100644 index 00000000..c5ec7064 --- /dev/null +++ b/src/octoprint/plugins/announcements/__init__.py @@ -0,0 +1,273 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2016 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import octoprint.plugin + +import codecs +import datetime +import os +import re +import time +import threading + +import feedparser +import flask + +from octoprint.server import admin_permission +from octoprint.server.util.flask import restricted_access +from flask.ext.babel import gettext + +class AnnouncementPlugin(octoprint.plugin.AssetPlugin, + octoprint.plugin.SettingsPlugin, + octoprint.plugin.BlueprintPlugin, + octoprint.plugin.StartupPlugin, + octoprint.plugin.TemplatePlugin): + + def __init__(self): + self._cached_channels = dict() + self._cached_channels_mutex = threading.RLock() + + # StartupPlugin + + def on_after_startup(self): + self._fetch_all_channels() + + # SettingsPlugin + + def get_settings_defaults(self): + return dict(channels=dict(_important=dict(name="Important OctoPrint Announcements", + priority=1, + type="rss", + url="http://octoprint.org/feeds/important.xml"), + _releases=dict(name="OctoPrint Release Announcements", + priority=2, + type="rss", + url="http://octoprint.org/feeds/releases.xml"), + _plugins=dict(name="New Plugins in the Repository", + priority=2, + type="rss", + url="http://octoprint.org/rss/important.xml")), + enabled_channels=[], + forced_channels=["_important"], + ttl=6*60, + display_limit=3, + summary_limit=300) + + # AssetPlugin + + def get_assets(self): + return dict(js=["js/announcements.js"], + less=["less/announcements.less"], + css=["css/announcements.css"]) + + # Template Plugin + + def get_template_configs(self): + return [ + dict(type="settings", name=gettext("Announcements"), template="announcements_settings.jinja2", custom_bindings=True), + dict(type="navbar", template="announcements_navbar.jinja2", styles=["display: none"], data_bind="visible: loginState.isAdmin") + ] + + # Blueprint Plugin + + @octoprint.plugin.BlueprintPlugin.route("/channels", methods=["GET"]) + @restricted_access + @admin_permission.require(403) + def get_channel_data(self): + result = dict() + + channel_data = self._fetch_all_channels() + + channel_configs = self._get_channel_configs() + enabled = self._settings.get(["enabled_channels"]) + forced = self._settings.get(["forced_channels"]) + for key, data in channel_configs.items(): + entries = self._to_internal_feed(channel_data.get(key, []), read_until=channel_configs[key].get("read_until", None)) + unread = len(filter(lambda e: not e["read"], entries)) + + result[key] = dict(channel=data["name"], + url=data["url"], + priority=data["priority"], + enabled=key in enabled or key in forced, + forced=key in forced, + data=entries, + unread=unread) + + return flask.jsonify(result) + + @octoprint.plugin.BlueprintPlugin.route("/channels/", methods=["POST"]) + @restricted_access + @admin_permission.require(403) + def channel_command(self, channel): + from octoprint.server.util.flask import get_json_command_from_request + from octoprint.server import NO_CONTENT + + valid_commands = dict(read=["until"], + toggle=[]) + + command, data, response = get_json_command_from_request(flask.request, valid_commands=valid_commands) + if response is not None: + return response + + if command == "read": + current_read_until = None + channel_data = self._settings.get(["channels", channel], merged=True) + if channel_data: + current_read_until = channel_data.get("read_until", None) + + defaults = dict(plugins=dict(announcements=dict(channels=dict()))) + defaults["plugins"]["announcements"]["channels"][channel] = dict(read_until=current_read_until) + + until = data["until"] + self._settings.set(["channels", channel, "read_until"], until, defaults=defaults) + self._settings.save() + + elif command == "toggle": + enabled_channels = list(self._settings.get(["enabled_channels"])) + + if channel in enabled_channels: + enabled_channels.remove(channel) + else: + enabled_channels.append(channel) + + self._settings.set(["enabled_channels"], enabled_channels) + self._settings.save() + + return NO_CONTENT + + # Internal Tools + + def _get_channel_configs(self): + return self._settings.get(["channels"], merged=True) + + def _fetch_all_channels(self): + with self._cached_channels_mutex: + channels = self._get_channel_configs() + enabled = self._settings.get(["enabled_channels"]) + forced = self._settings.get(["forced_channels"]) + + all_channels = dict() + for key, config in channels.items(): + if not key in enabled and not key in forced: + continue + + data = self._get_channel_data(key, config) + if data is not None: + all_channels[key] = data + + self._cached_channels = all_channels + + return self._cached_channels + + def _get_channel_data(self, key, config): + data = self._get_channel_data_from_cache(key, config) + if data is None: + data = self._get_channel_data_from_network(key, config) + return data + + def _get_channel_data_from_cache(self, key, config): + channel_path = os.path.join(self.get_plugin_data_folder(), "{}.cache".format(key)) + + if os.path.exists(channel_path): + if "ttl" in config and isinstance(config["ttl"], int): + ttl = config["ttl"] + else: + ttl = self._settings.get_int(["ttl"]) + + ttl *= 60 + now = time.time() + if os.stat(channel_path).st_mtime + ttl > now: + d = feedparser.parse(channel_path) + self._logger.info("Loaded channel {} from cache".format(key)) + return d + + return None + + def _get_channel_data_from_network(self, key, config): + import requests + + url = config["url"] + try: + r = requests.get(url) + self._logger.info("Loaded channel {} from {}".format(key, config["url"])) + except Exception as e: + self._logger.exception( + "Could not fetch channel {} from {}: {}".format(key, config["url"], str(e))) + return None + + response = r.text + channel_path = os.path.join(self.get_plugin_data_folder(), "{}.cache".format(key)) + with codecs.open(channel_path, mode="w", encoding="utf-8") as f: + f.write(response) + return feedparser.parse(response) + + def _to_internal_feed(self, feed, read_until=None): + result = [] + if "entries" in feed: + for entry in feed["entries"]: + internal_entry = self._to_internal_entry(entry, read_until=read_until) + if internal_entry: + result.append(internal_entry) + return result + + def _to_internal_entry(self, entry, read_until=None): + published = time.mktime(entry["published_parsed"]) + + read = False + if read_until is not None: + read = published <= read_until + + return dict(title=entry["title"], + title_without_tags=_strip_tags(entry["title"]), + summary=entry["summary"], + summary_without_images=_strip_images(entry["summary"]), + published=published, + link=entry["link"], + read=read) + + +_image_tag_re = re.compile(r'') +def _strip_images(text): + return _image_tag_re.sub('', text) + + +def _strip_tags(text): + """ + >>> _strip_tags(u"Hello world<img src='foo.jpg'>") + u"Hello world<img src='foo.jpg'>" + >>> _strip_tags(u"> > Foo") + u'> > Foo' + """ + + from HTMLParser import HTMLParser + + class TagStripper(HTMLParser): + + def __init__(self): + HTMLParser.__init__(self) + self._fed = [] + + def handle_data(self, data): + self._fed.append(data) + + def handle_entityref(self, ref): + self._fed.append("&{};".format(ref)) + + def handle_charref(self, ref): + self._fed.append("&#{};".format(ref)) + + def get_data(self): + return "".join(self._fed) + + tag_stripper = TagStripper() + tag_stripper.feed(text) + return tag_stripper.get_data() + + +__plugin_name__ = "Announcement Plugin" +__plugin_implementation__ = AnnouncementPlugin() diff --git a/src/octoprint/plugins/announcements/static/js/announcements.js b/src/octoprint/plugins/announcements/static/js/announcements.js new file mode 100644 index 00000000..00e34cb5 --- /dev/null +++ b/src/octoprint/plugins/announcements/static/js/announcements.js @@ -0,0 +1,268 @@ +$(function() { + function AnnouncementsViewModel(parameters) { + var self = this; + + self.loginState = parameters[0]; + self.settings = parameters[1]; + + self.channels = new ItemListHelper( + "plugin.announcements.channels", + { + "channel": function (a, b) { + // sorts ascending + if (a["channel"].toLocaleLowerCase() < b["channel"].toLocaleLowerCase()) return -1; + if (a["channel"].toLocaleLowerCase() > b["channel"].toLocaleLowerCase()) return 1; + return 0; + } + }, + { + }, + "name", + [], + [], + 5 + ); + + self.unread = ko.observable(); + self.hiddenChannels = []; + self.channelNotifications = {}; + + self.announcementDialog = undefined; + self.announcementDialogContent = undefined; + self.announcementDialogTabs = undefined; + + self.setupTabLink = function(item) { + $("a[data-toggle='tab']", item).on("show", self.resetContentScroll); + }; + + self.resetContentScroll = function() { + self.announcementDialogContent.scrollTop(0); + }; + + self.toggleButtonCss = function(data) { + var icon = data.enabled ? "icon-circle" : "icon-circle-blank"; + var disabled = (self.enableToggle(data)) ? "" : " disabled"; + + return icon + disabled; + }; + + self.toggleButtonTitle = function(data) { + return data.forced ? gettext("Cannot be toggled") : (data.enabled ? gettext("Disable Channel") : gettext("Enable Channel")); + }; + + self.enableToggle = function(data) { + return !data.forced; + }; + + self.markRead = function(channel, until) { + if (!self.loginState.isAdmin()) return; + + var url = PLUGIN_BASEURL + "announcements/channels/" + channel; + + var payload = { + command: "read", + until: until + }; + + $.ajax({ + url: url, + type: "POST", + dataType: "json", + data: JSON.stringify(payload), + contentType: "application/json; charset=UTF-8", + success: function() { + self.retrieveData() + } + }) + }; + + self.toggleChannel = function(channel) { + if (!self.loginState.isAdmin()) return; + + var url = PLUGIN_BASEURL + "announcements/channels/" + channel; + + var payload = { + command: "toggle" + }; + + $.ajax({ + url: url, + type: "POST", + dataType: "json", + data: JSON.stringify(payload), + contentType: "application/json; charset=UTF-8", + success: function() { + self.retrieveData() + } + }) + }; + + self.retrieveData = function(force) { + if (!self.loginState.isAdmin()) return; + + var url = PLUGIN_BASEURL + "announcements/channels"; + if (force) { + url += "?force=true"; + } + + $.ajax({ + url: url, + type: "GET", + dataType: "json", + success: function(data) { + self.fromResponse(data); + } + }); + }; + + self.fromResponse = function(data) { + var unread = 0; + var channels = []; + _.each(data, function(value, key) { + value.key = key; + value.last = value.data.length ? value.data[0].published : undefined; + unread += value.unread; + channels.push(value); + }); + self.channels.updateItems(channels); + self.unread(unread); + + self.displayAnnouncements(channels); + }; + + self.showAnnouncementDialog = function() { + //self.aboutContent.scrollTop(0); + self.announcementDialog.modal({ + minHeight: function() { return Math.max($.fn.modal.defaults.maxHeight() - 80, 250); } + }).css({ + width: 'auto', + 'margin-left': function() { return -($(this).width() /2); } + }); + return false; + }; + + self.showChannel = function(channel) { + self.showAnnouncementDialog(); + $("a[href=#plugin_announcements_dialog_channel_" + channel + "]", self.announcementDialogTabs).tab("show"); + }; + + self.displayAnnouncements = function(channels) { + var displayLimit = self.settings.settings.plugins.announcements.display_limit(); + var maxLength = self.settings.settings.plugins.announcements.summary_limit(); + + var cutAfterNewline = function(text) { + text = text.trim(); + + var firstNewlinePos = text.indexOf("\n"); + if (firstNewlinePos > 0) { + text = text.substr(0, firstNewlinePos).trim(); + } + + return text; + }; + + _.each(channels, function(value) { + var key = value.key; + var channel = value.channel; + var priority = value.priority; + var items = value.data; + + if ($.inArray(key, self.hiddenChannels) > -1) { + // channel currently ignored + return; + } + + var newItems = _.filter(items, function(entry) { return !entry.read; }); + if (newItems.length == 0) { + // no new items at all, we don't display anything for this channel + return; + } + + var displayedItems; + if (newItems.length > displayLimit) { + displayedItems = newItems.slice(0, displayLimit); + } else { + displayedItems = newItems; + } + var rest = newItems.length - displayedItems.length; + + var text = "
    "; + _.each(displayedItems, function(item) { + var limitedSummary = item.summary_without_images.trim(); + if (limitedSummary.length > maxLength) { + limitedSummary = limitedSummary.substr(0, maxLength); + limitedSummary = limitedSummary.substr(0, Math.min(limitedSummary.length, limitedSummary.lastIndexOf(" "))); + limitedSummary += "..."; + } + + text += "
  • " + cutAfterNewline(item.title) + "
    " + formatTimeAgo(item.published) + "

    " + limitedSummary + "

  • "; + }); + text += "
"; + + if (rest) { + text += gettext(_.sprintf("... and %(rest)d more.", {rest: rest})); + } + + var options = { + title: channel, + text: text, + hide: false, + confirm: { + confirm: true, + buttons: [{ + text: gettext("Later"), + click: function(notice) { + self.hiddenChannels.push(key); + notice.remove(); + } + }, { + text: gettext("Mark read"), + click: function(notice) { + self.markRead(key, value.last); + notice.remove(); + } + }, { + text: gettext("Read..."), + addClass: "btn-primary", + click: function(notice) { + self.showChannel(key); + self.markRead(key, value.last); + notice.remove(); + } + }] + }, + buttons: { + sticker: false, + closer: false + } + }; + + if (priority == 1) { + options.type = "error"; + } + + if (self.channelNotifications[key]) { + self.channelNotifications[key].remove(); + } + self.channelNotifications[key] = new PNotify(options); + }); + }; + + self.onUserLoggedIn = function() { + self.retrieveData(); + }; + + self.onStartup = function() { + self.announcementDialog = $("#plugin_announcements_dialog"); + self.announcementDialogContent = $("#plugin_announcements_dialog_content"); + self.announcementDialogTabs = $("#plugin_announcements_dialog_tabs"); + } + } + + // view model class, parameters for constructor, container to bind to + ADDITIONAL_VIEWMODELS.push([ + AnnouncementsViewModel, + ["loginStateViewModel", "settingsViewModel"], + ["#plugin_announcements_dialog", "#settings_plugin_announcements", "#navbar_plugin_announcements"] + ]); +}); diff --git a/src/octoprint/plugins/announcements/static/less/announcements.less b/src/octoprint/plugins/announcements/static/less/announcements.less new file mode 100644 index 00000000..067a969a --- /dev/null +++ b/src/octoprint/plugins/announcements/static/less/announcements.less @@ -0,0 +1,37 @@ +table { + th, td { + &.settings_plugin_announcements_channels_name { + text-overflow: ellipsis; + text-align: left; + } + + &.settings_plugin_announcements_channels_actions { + text-align: center; + width: 80px; + + a { + text-decoration: none; + color: #000; + + &.disabled { + color: #ccc; + cursor: default; + } + } + } + } +} + +#plugin_announcements_dialog { + .unread { + font-weight: bold; + } + + .actions { + text-align: right; + + a { + color: black; + } + } +} diff --git a/src/octoprint/plugins/announcements/templates/announcements.jinja2 b/src/octoprint/plugins/announcements/templates/announcements.jinja2 new file mode 100644 index 00000000..fb370733 --- /dev/null +++ b/src/octoprint/plugins/announcements/templates/announcements.jinja2 @@ -0,0 +1,44 @@ + diff --git a/src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2 b/src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2 new file mode 100644 index 00000000..8f91e47e --- /dev/null +++ b/src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2 @@ -0,0 +1,3 @@ + + + diff --git a/src/octoprint/plugins/announcements/templates/announcements_settings.jinja2 b/src/octoprint/plugins/announcements/templates/announcements_settings.jinja2 new file mode 100644 index 00000000..d9851abe --- /dev/null +++ b/src/octoprint/plugins/announcements/templates/announcements_settings.jinja2 @@ -0,0 +1,34 @@ +

{{ _('Configured Channels') }}

+ + + + + + + + + + + + + + +
{{ _('Name') }}{{ _('Actions') }}
+
+
 
+
+ +
+ + + diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index b125c82e..a66c588e 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -209,13 +209,13 @@ default_settings = { "defaultLanguage": "_default", "components": { "order": { - "navbar": ["settings", "systemmenu", "login"], + "navbar": ["settings", "systemmenu", "login", "plugin_announcements"], "sidebar": ["connection", "state", "files"], "tab": ["temperature", "control", "gcodeviewer", "terminal", "timelapse"], "settings": [ "section_printer", "serial", "printerprofiles", "temperatures", "terminalfilters", "gcodescripts", "section_features", "features", "webcam", "accesscontrol", "api", - "section_octoprint", "server", "folders", "appearance", "logs", "plugin_pluginmanager", "plugin_softwareupdate" + "section_octoprint", "server", "folders", "appearance", "logs", "plugin_pluginmanager", "plugin_softwareupdate", "plugin_announcements" ], "usersettings": ["access", "interface"], "about": ["about", "sponsors", "authors", "changelog", "license", "thirdparty", "plugin_pluginmanager"],