Skip to content

Commit

Permalink
Merge pull request #1 from rcthomas/persist-announcements
Browse files Browse the repository at this point in the history
Persist announcements
  • Loading branch information
rcthomas authored Sep 29, 2019
2 parents 4de05cd + da94230 commit ee85608
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 22 deletions.
23 changes: 23 additions & 0 deletions announcement_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,26 @@
## Search paths for jinja templates, coming before default ones
#c.AnnouncementService.template_paths = []

#------------------------------------------------------------------------------
# AnnouncementQueue(LoggingConfigurable) configuration
#------------------------------------------------------------------------------

## File path where announcements persist as JSON.
#
# For a persistent announcement queue, this parameter must be set to a non-empty
# value and correspond to a read+write-accessible path. The announcement queue
# is stored as a list of JSON objects. If this parameter is set to a non-empty
# value:
#
# * The persistence file is used to initialize the announcement queue
# at start-up. This is the only time the persistence file is read.
# * If the persistence file does not exist at start-up, it is
# created when an announcement is added to the queue.
# * The persistence file is over-written with the contents of the
# announcement queue each time a new announcement is added.
#
# If this parameter is set to an empty value (the default) then the queue is
# just empty at initialization and the queue is ephemeral; announcements will
# not be persisted on updates to the queue.
#c.AnnouncementQueue.persist_path = ''

119 changes: 99 additions & 20 deletions jupyterhub_announcement/announcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from jupyterhub._data import DATA_FILES_PATH
from tornado import escape, gen, ioloop, web

from traitlets.config import Application
from traitlets.config import Application, LoggingConfigurable
from traitlets import Bool, Dict, Integer, List, Unicode, default


Expand All @@ -22,17 +22,92 @@ def default(self, obj):
return json.JSONEncoder.default(self, obj)


def _datetime_hook(json_dict):
for (key, value) in json_dict.items():
try:
json_dict[key] = datetime.datetime.fromisoformat(value)
except:
pass
return json_dict


class AnnouncementQueue(LoggingConfigurable):

announcements = List()

persist_path = Unicode(
"",
help="""File path where announcements persist as JSON.
For a persistent announcement queue, this parameter must be set to
a non-empty value and correspond to a read+write-accessible path.
The announcement queue is stored as a list of JSON objects. If this
parameter is set to a non-empty value:
* The persistence file is used to initialize the announcement queue
at start-up. This is the only time the persistence file is read.
* If the persistence file does not exist at start-up, it is
created when an announcement is added to the queue.
* The persistence file is over-written with the contents of the
announcement queue each time a new announcement is added.
If this parameter is set to an empty value (the default) then the
queue is just empty at initialization and the queue is ephemeral;
announcements will not be persisted on updates to the queue."""
).tag(config=True)

def __init__(self, **kwargs):
super().__init__(**kwargs)

if self.persist_path:
self.log.info(f"restoring queue from {self.persist_path}")
self._handle_restore()
else:
self.log.info("ephemeral queue, persist_path not set")
self.log.info(f"queue has {len(self.announcements)} announcements")

def _handle_restore(self):
try:
self._restore()
except FileNotFoundError:
self.log.info(f"persist_path not found ({self.persist_path})")
except Exception as err:
self.log.error(f"failed to restore queue ({err})")

def _restore(self):
with open(self.persist_path, "r") as stream:
self.announcements = json.load(stream, object_hook=_datetime_hook)

def update(self, user, announcement=""):
self.announcements.append(dict(user=user,
announcement=announcement,
timestamp=datetime.datetime.now()))
if self.persist_path:
self.log.info(f"persisting queue to {self.persist_path}")
self._handle_persist()

def _handle_persist(self):
try:
self._persist()
except Exception as err:
self.log.error(f"failed to persist queue ({err})")

def _persist(self):
with open(self.persist_path, "w") as stream:
json.dump(self.announcements, stream, cls=_JSONEncoder, indent=2)


class AnnouncementHandler(HubAuthenticated, web.RequestHandler):

def initialize(self, storage):
self.storage = storage
def initialize(self, queue):
self.queue = queue


class AnnouncementViewHandler(AnnouncementHandler):
"""View announcements page"""

def initialize(self, storage, fixed_message, loader):
super().initialize(storage)
def initialize(self, queue, fixed_message, loader):
super().initialize(queue)
self.fixed_message = fixed_message
self.loader = loader
self.env = Environment(loader=self.loader)
Expand All @@ -45,7 +120,8 @@ def get(self):
logout_url = url_path_join(prefix, "logout")
self.write(self.template.render(user=user,
fixed_message=self.fixed_message,
storage=self.storage, static_url=self.static_url,
announcements=self.queue.announcements,
static_url=self.static_url,
login_url=self.hub_auth.login_url,
logout_url=logout_url,
base_url=prefix,
Expand All @@ -56,10 +132,9 @@ class AnnouncementLatestHandler(AnnouncementHandler):
"""Return the latest announcement as JSON"""

def get(self):
if self.storage:
latest = self.storage[-1]
else:
latest = {"announcement": ""}
latest = {"announcement": ""}
if self.queue:
latest = self.queue.announcements[-1]
self.set_header("Content-Type", "application/json; charset=UTF-8")
self.write(escape.utf8(json.dumps(latest, cls=_JSONEncoder)))

Expand All @@ -70,22 +145,19 @@ class AnnouncementUpdateHandler(AnnouncementHandler):
hub_users = []
allow_admin = True

def push(self, user, announcement=""):
self.storage.append(dict(user=user,
announcement=announcement,
timestamp=datetime.datetime.now()))

@web.authenticated
def post(self):
"""Update announcement"""
user = self.get_current_user()
announcement = self.get_body_argument("announcement")
self.push(user["name"], announcement)
self.queue.update(user["name"], announcement)
self.redirect(self.application.reverse_url("view"))


class AnnouncementService(Application):

classes = [AnnouncementQueue]

flags = Dict({
'generate-config': (
{'AnnouncementService': {'generate_config': True}},
Expand Down Expand Up @@ -148,14 +220,18 @@ def _logo_file_default(self):

def initialize(self, argv=None):
super().initialize(argv)

if self.generate_config:
print(self.generate_config_file())
sys.exit(0)

if self.config_file:
self.load_config_file(self.config_file)

self.storage = list()
# Totally confused by traitlets logging
self.log.parent.setLevel(self.log.level)

self.init_queue()

base_path = self._template_paths_default()[0]
if base_path not in self.template_paths:
Expand All @@ -173,13 +249,16 @@ def initialize(self, argv=None):
}

self.app = web.Application([
(self.service_prefix, AnnouncementViewHandler, dict(storage=self.storage, fixed_message=self.fixed_message, loader=loader), "view"),
(self.service_prefix + r"latest", AnnouncementLatestHandler, dict(storage=self.storage)),
(self.service_prefix + r"update", AnnouncementUpdateHandler, dict(storage=self.storage)),
(self.service_prefix, AnnouncementViewHandler, dict(queue=self.queue, fixed_message=self.fixed_message, loader=loader), "view"),
(self.service_prefix + r"latest", AnnouncementLatestHandler, dict(queue=self.queue)),
(self.service_prefix + r"update", AnnouncementUpdateHandler, dict(queue=self.queue)),
(self.service_prefix + r"static/(.*)", web.StaticFileHandler, dict(path=self.settings["static_path"])),
(self.service_prefix + r"logo", LogoHandler, {"path": self.logo_file})
], **self.settings)

def init_queue(self):
self.queue = AnnouncementQueue(log=self.log, config=self.config)

def start(self):
self.app.listen(self.port)
ioloop.IOLoop.current().start()
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

setup(
name='jupyterhub-announcement',
version='0.1.0',
version='0.2.0',
description='JupyterHub Announcement Service',
author='R. C. Thomas',
author_email='[email protected]',
Expand Down
2 changes: 1 addition & 1 deletion templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<h2>Latest Announcement</h2>
</div>
</div>
{% for entry in storage | reverse %}
{% for entry in announcements | reverse %}
{% if loop.index == 2 %}
<div class="row">
<div class="col-md-offset-3 col-md-6">
Expand Down

0 comments on commit ee85608

Please sign in to comment.