WIP: Announcement plugin

This commit is contained in:
Gina Häußge 2015-07-09 15:43:11 +02:00
parent 73d981ed6b
commit 652158d17a
9 changed files with 665 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,273 @@
# coding=utf-8
from __future__ import absolute_import
__author__ = "Gina Häußge <osd@foosel.net>"
__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/<channel>", 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'<img.*?/?>')
def _strip_images(text):
return _image_tag_re.sub('', text)
def _strip_tags(text):
"""
>>> _strip_tags(u"<a href='test.html'>Hello world</a>&lt;img src='foo.jpg'&gt;")
u"Hello world&lt;img src='foo.jpg'&gt;"
>>> _strip_tags(u"&#62; &#x3E; Foo")
u'&#62; &#x3E; 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()

View file

@ -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 = "<ul>";
_.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 += "<li><a href='" + item.link + "' target='_blank'>" + cutAfterNewline(item.title) + "</a><br><small>" + formatTimeAgo(item.published) + "</small><p>" + limitedSummary + "</p></li>";
});
text += "</ul>";
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"]
]);
});

View file

@ -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;
}
}
}

View file

@ -0,0 +1,44 @@
<div id="plugin_announcements_dialog" class="modal hide fade large" tabindex="-1" role="dialog" aria-labelledby="announcements_dialog_label" aria-hidden="true">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h3 id="announcements_dialog_label">{{ _('Announcements') }}</h3>
</div>
<div class="modal-body">
<div class="full-sized-box">
<div class="tabbable row-fluid">
<div class="span3 scrollable" id="plugin_announcements_dialog_menu">
<ul class="nav nav-list" id="plugin_announcements_dialog_tabs">
<!-- ko foreach: {data: channels.items, afterAdd: setupTabLink} -->
<!-- ko if: $data.enabled || $data.forced -->
<li data-bind="css: {active: $index() == 0}">
<a data-toggle="tab" target="_blank" data-bind="text: $data.channel + ' (' + $data.unread + ')', attr: {href: '#plugin_announcements_dialog_channel_' + $data.key}, css: {unread: $data.unread}"></a>
</li>
<!-- /ko -->
<!-- /ko -->
</ul>
</div>
<div class="tab-content span9 scrollable" id="plugin_announcements_dialog_content">
<!-- ko foreach: channels.items -->
<!-- ko if: $data.enabled || $data.forced -->
<div class="tab-pane" data-bind="css: {active: $index() == 0}, attr: {id: 'plugin_announcements_dialog_channel_' + $data.key}">
<!-- ko foreach: $data.data -->
<hr data-bind="visible: $index() > 0">
<article data-bind="css: {muted: $data.read}">
<h3><a data-bind="text: $data.title, attr: {href: $data.url}"></a> <small data-bind="text: formatTimeAgo($data.published)"></small></h3>
<div data-bind="html: $data.summary"></div>
<div class="actions">
<small><a data-bind="click: function() { $root.markRead($parent.key, $data.published) }"><i class="icon-eye-open"></i> {{ _('Last read') }}</a></small>
</div>
</article>
<!-- /ko -->
</div>
<!-- /ko -->
<!-- /ko -->
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-block" data-dismiss="modal" aria-hidden="true">{{ _('Close') }}</button>
</div>
</div>

View file

@ -0,0 +1,3 @@
<a id="navbar_show_announcements" class="pull-right" href="javascript:void(0)" data-bind="click: function() { showAnnouncementDialog(); }" title="{{ _('Announcements') }}">
<i data-bind="css: {'icon-bell': !unread(), 'icon-bell-alt': unread()}"></i>
</a>

View file

@ -0,0 +1,34 @@
<h3>{{ _('Configured Channels') }}</h3>
<table class="table table-striped table-hover table-condensed table-hover">
<thead>
<tr>
<th class="settings_plugin_announcements_channels_name">{{ _('Name') }}</th>
<th class="settings_plugin_announcements_channels_actions">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody data-bind="foreach: channels.paginatedItems">
<tr>
<td class="settings_plugin_announcements_channels_name">
<div data-bind="text: channel, css: {muted: !enabled}"></div>
<div><small class="muted" data-bind="text: url">&nbsp;</small></div>
</td>
<td class="settings_plugin_announcements_channels_actions">
<a href="javascript:void(0)" data-bind="css: $root.toggleButtonCss($data), attr: {title: $root.toggleButtonTitle($data)}, enable: $root.enableToggle($data), click: function() { $root.toggleChannel($data.key) }"></a>
</td>
</tr>
</tbody>
</table>
<div class="pagination pagination-mini pagination-centered">
<ul>
<li data-bind="css: {disabled: channels.currentPage() === 0}"><a href="javascript:void(0)" data-bind="click: channels.prevPage">«</a></li>
</ul>
<ul data-bind="foreach: channels.pages">
<li data-bind="css: { active: $data.number === $root.channels.currentPage(), disabled: $data.number === -1 }"><a href="javascript:void(0)" data-bind="text: $data.text, click: function() { $root.channels.changePage($data.number); }"></a></li>
</ul>
<ul>
<li data-bind="css: {disabled: channels.currentPage() === channels.lastPage()}"><a href="javascript:void(0)" data-bind="click: channels.nextPage">»</a></li>
</ul>
</div>
<button class="btn btn-block" data-bind="click: $root.showAnnouncementDialog">{{ _('Show Announcements...') }}</button>

View file

@ -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"],