From 99ed046b7aaad32e56ee622c1d99cfd66a90a56d Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Sun, 4 Feb 2018 10:25:35 +0100 Subject: [PATCH 01/12] First ical parser --- service/api/events.py | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 service/api/events.py diff --git a/service/api/events.py b/service/api/events.py new file mode 100644 index 0000000..126c3ea --- /dev/null +++ b/service/api/events.py @@ -0,0 +1,51 @@ +# coding: utf8 + +import requests +import icalendar +from datetime import datetime +from datetime import date + +class Client(object): + + def __init__(self, url, timezone=None): + self.url = url + self.timezone = timezone + self.events = [] + self.__load() + + def __load(self): + r = requests.get(self.url) + r.raise_for_status() + + cal = icalendar.Calendar.from_ical(r.text) + self.events = [] + + for event in cal.walk('vevent'): + title = None + description = None + if "SUMMARY" in event: + title = event["SUMMARY"] + if "DESCRIPTION" in event: + description = event["DESCRIPTION"] + dtstart = event["DTSTART"].dt + dtend = event["DTEND"].dt + self.events.append({ + "title": title, + "description": description, + "start": dtstart, + "end": dtend, + }) + + def next_events(self, num=10): + """ + Returns the next num events from the calendar + """ + now = datetime.utcnow() + out = [] + for event in self.events: + end = event["end"] + if isinstance(end, date): + end = datetime.combine(end, datetime.min.time()) + if end > now: + out.append(event) + return out From 1fcc52925ea276516f91c06a54b268c7c4b391ee Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Sun, 4 Feb 2018 13:20:24 +0100 Subject: [PATCH 02/12] First version of an iCal calender service --- README.md | 8 +++++++- service/.gitignore | 3 +++ service/Makefile | 6 ++++++ service/README.md | 42 ++++++++++++++++++++++++++++++++++++++ service/api/__init__.py | 0 service/api/events.py | 24 +++++++++++++++------- service/api/jsonhandler.py | 38 ++++++++++++++++++++++++++++++++++ service/api/main.py | 37 +++++++++++++++++++++++++++++++++ service/requirements.txt | 17 +++++++++++++++ service/tests/__init__.py | 0 service/tests/test_main.py | 23 +++++++++++++++++++++ 11 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 service/.gitignore create mode 100644 service/Makefile create mode 100644 service/README.md create mode 100644 service/api/__init__.py create mode 100644 service/api/jsonhandler.py create mode 100644 service/api/main.py create mode 100644 service/requirements.txt create mode 100644 service/tests/__init__.py create mode 100644 service/tests/test_main.py diff --git a/README.md b/README.md index a76796b..fe9d800 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ kostenlos. Damit eignet sich Yodeck evtl. für kleine Büros. ### Screenly OpenSource alternative zu Yodeck. Sollte ähnlichen Umfang haben wie Yodeck, aber durch OS flexibler. -Beschreibung folgt. +Beschreibung folgt. https://www.screenly.io/ose/ + +## Software + +Im Unterordner `service` entsteht Software zur Erzeugung dynamischer +HTML-Inhalte, die für Digital Signage eingesetzt werden können. Dokumentation +folgt, sobald diese Software einsatzfähig ist. diff --git a/service/.gitignore b/service/.gitignore new file mode 100644 index 0000000..dfed302 --- /dev/null +++ b/service/.gitignore @@ -0,0 +1,3 @@ +venv +.pytest_cache +*.pyc diff --git a/service/Makefile b/service/Makefile new file mode 100644 index 0000000..00f4509 --- /dev/null +++ b/service/Makefile @@ -0,0 +1,6 @@ + +serve: + gunicorn --reload api.main:app + +test: + pytest tests diff --git a/service/README.md b/service/README.md new file mode 100644 index 0000000..952a223 --- /dev/null +++ b/service/README.md @@ -0,0 +1,42 @@ +# Schaufenster Service + +Dies ist ein Webservice zur Erzeugung dynamischer Inhalte für +Digital Signage Anwendungen. + +_Anwendungsbeispiel:_ + +Auf einer digitalen Anzeigetafel soll stets aktuell der nächste Sitzungstermin +angezeigt werden. Hierfür geben wir eine iCal-Kalender-URL an +und bekommen dafür Titel und weitere Details der nächsten Termine in diesem +Kalender zurück. + +## API + +### `GET /events/` - Die nächsten Termine eines iCal Kalenders ausgeben + +Request URL Parameter: + +- `ical_url`: Adresse des iCal-Kalenders (erforderlich). +- `num`: Maximale Anzahl der Termine, die ausgegeben werden. +- `charset`: Zeichensatz der iCal-Quelle. Normalerweise wird der Zeichensatz + angenommen, den der Webserver im `Content-type` header angibt. Mit diesem + Parameter kann der Wert des Servers überschrieben werden. Beispiel: `charset=utf-8`. + +Ausgabe: + +```json +[ + { + "title": "Karfreitag", + "start": "2018-03-30", + "end": "2018-03-31" + }, + ... +] +``` + +Liste mit Terminen als JSON Array. Jeder Termin enthält: + +- `title`: Titel des Termins +- `start`: Start-Datum (oder Datum/Uhrzeit) des Termins +- `end`: (optional) Enddatum (oder Datum/Uhrzeit) des Termins diff --git a/service/api/__init__.py b/service/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/service/api/events.py b/service/api/events.py index 126c3ea..212a8a2 100644 --- a/service/api/events.py +++ b/service/api/events.py @@ -1,4 +1,4 @@ -# coding: utf8 +# -*- coding: utf-8 -*- import requests import icalendar @@ -7,9 +7,9 @@ from datetime import date class Client(object): - def __init__(self, url, timezone=None): + def __init__(self, url, charset=None): self.url = url - self.timezone = timezone + self.charset = charset self.events = [] self.__load() @@ -17,25 +17,33 @@ class Client(object): r = requests.get(self.url) r.raise_for_status() + # requests normally uses encoding returned by "Content-type" header. + # If charset is set, this overwrites the detected character encoding. + if self.charset is not None: + r.encoding = self.charset + cal = icalendar.Calendar.from_ical(r.text) self.events = [] for event in cal.walk('vevent'): title = None - description = None if "SUMMARY" in event: title = event["SUMMARY"] - if "DESCRIPTION" in event: - description = event["DESCRIPTION"] dtstart = event["DTSTART"].dt dtend = event["DTEND"].dt self.events.append({ "title": title, - "description": description, "start": dtstart, "end": dtend, }) + # sort events by start datetime + def getdatetime(event): + if isinstance(event["start"], date): + return datetime.combine(event["start"], datetime.min.time()) + return event["start"] + self.events = sorted(self.events, key=getdatetime) + def next_events(self, num=10): """ Returns the next num events from the calendar @@ -48,4 +56,6 @@ class Client(object): end = datetime.combine(end, datetime.min.time()) if end > now: out.append(event) + if len(out) >= num: + break return out diff --git a/service/api/jsonhandler.py b/service/api/jsonhandler.py new file mode 100644 index 0000000..141be06 --- /dev/null +++ b/service/api/jsonhandler.py @@ -0,0 +1,38 @@ +import six + +from datetime import date, datetime + +from falcon import errors +from falcon.media import BaseHandler +from falcon.util import json + +class ComplexEncoder(json.JSONEncoder): + + """JSONENcoder that handles date and datetime""" + + def default(self, obj): + if isinstance(obj, date) or isinstance(obj, datetime): + return obj.isoformat() + # Let the base class default method raise the TypeError + return json.JSONEncoder.default(self, obj) + +class JSONHandler(BaseHandler): + """Handler built using Python's :py:mod:`json` module.""" + + def deserialize(self, raw): + try: + return json.loads(raw.decode('utf-8')) + except ValueError as err: + raise errors.HTTPBadRequest( + 'Invalid JSON', + 'Could not parse JSON body - {0}'.format(err) + ) + + def serialize(self, media): + result = json.dumps(media, + ensure_ascii=False, + cls=ComplexEncoder) + if six.PY3 or not isinstance(result, bytes): + return result.encode('utf-8') + + return result diff --git a/service/api/main.py b/service/api/main.py new file mode 100644 index 0000000..a3f5df1 --- /dev/null +++ b/service/api/main.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- + +import falcon +from falcon import media +import logging +from . import events +from . import jsonhandler + + +class EventsResource(object): + + def __init__(self): + self.logger = logging.getLogger('api.' + __name__) + + def on_get(self, req, resp): + """ + Loads an ical Calendar and returns the next events + """ + ical_url = req.get_param("ical_url", required=True) + charset = req.get_param("charset") + num = int(req.get_param("num", required=False, default="10")) + + client = events.Client(url=ical_url, charset=charset) + next_events = client.next_events(num) + resp.media = next_events + + +handlers = media.Handlers({ + 'application/json': jsonhandler.JSONHandler(), +}) + +app = falcon.API() + +app.req_options.media_handlers = handlers +app.resp_options.media_handlers = handlers + +app.add_route('/events/', EventsResource()) diff --git a/service/requirements.txt b/service/requirements.txt new file mode 100644 index 0000000..c96d51c --- /dev/null +++ b/service/requirements.txt @@ -0,0 +1,17 @@ +attrs==17.4.0 +certifi==2018.1.18 +chardet==3.0.4 +falcon==1.4.1 +funcsigs==1.0.2 +gunicorn==19.7.1 +icalendar==4.0.0 +idna==2.6 +pluggy==0.6.0 +py==1.5.2 +pytest==3.4.0 +python-dateutil==2.6.1 +python-mimeparse==1.6.0 +pytz==2017.3 +requests==2.18.4 +six==1.11.0 +urllib3==1.22 diff --git a/service/tests/__init__.py b/service/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/service/tests/test_main.py b/service/tests/test_main.py new file mode 100644 index 0000000..d82174e --- /dev/null +++ b/service/tests/test_main.py @@ -0,0 +1,23 @@ +import falcon +from falcon import testing +import pytest +from api.main import app + +@pytest.fixture +def client(): + return testing.TestClient(app) + + +def test_get_events_no_ical_url(client): + """ + No ical URL given bad request + """ + response = client.simulate_get('/events/') + assert response.status == falcon.HTTP_BAD_REQUEST + # TODO: assertion for response format + +def test_get_events(client): + response = client.simulate_get('/events/', params={ + "ical_url": "http://www.webcal.fi/cal.php?id=75&rid=ics&wrn=0&wp=12&wf=55" + }) + assert response.status == falcon.HTTP_OK From addbc6b1407eb634527f79f0fcb961b0a95b57ac Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Sun, 4 Feb 2018 18:13:34 +0100 Subject: [PATCH 03/12] Adding Dockerfile --- service/Dockerfile | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 service/Dockerfile diff --git a/service/Dockerfile b/service/Dockerfile new file mode 100644 index 0000000..6bb1251 --- /dev/null +++ b/service/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.6-alpine3.6 + +WORKDIR /app + +COPY . /app + +RUN pip install -r requirements.txt + +EXPOSE 5000 + +ENTRYPOINT ["gunicorn", "--bind=0.0.0.0:5000", "api.main:app"] From 3620a53ed7e242048a1efc1e2e7c6cc513a74f85 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Sun, 4 Feb 2018 22:22:19 +0100 Subject: [PATCH 04/12] Enable CORS --- service/api/main.py | 5 ++++- service/requirements.txt | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/service/api/main.py b/service/api/main.py index a3f5df1..bec04fe 100644 --- a/service/api/main.py +++ b/service/api/main.py @@ -3,6 +3,7 @@ import falcon from falcon import media import logging +from falcon_cors import CORS from . import events from . import jsonhandler @@ -29,7 +30,9 @@ handlers = media.Handlers({ 'application/json': jsonhandler.JSONHandler(), }) -app = falcon.API() +cors = CORS(allow_origins_list=['*']) + +app = falcon.API(middleware=[cors.middleware]) app.req_options.media_handlers = handlers app.resp_options.media_handlers = handlers diff --git a/service/requirements.txt b/service/requirements.txt index c96d51c..423b056 100644 --- a/service/requirements.txt +++ b/service/requirements.txt @@ -2,6 +2,7 @@ attrs==17.4.0 certifi==2018.1.18 chardet==3.0.4 falcon==1.4.1 +falcon-cors==1.1.7 funcsigs==1.0.2 gunicorn==19.7.1 icalendar==4.0.0 From b945a0382cd3d014f7ee41f593365a2375ef4e97 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Sun, 4 Feb 2018 22:22:29 +0100 Subject: [PATCH 05/12] Adjust port --- service/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/Makefile b/service/Makefile index 00f4509..2acb447 100644 --- a/service/Makefile +++ b/service/Makefile @@ -1,6 +1,6 @@ serve: - gunicorn --reload api.main:app + gunicorn --reload -b 0.0.0.0:5000 api.main:app test: pytest tests From 0adfd7588a994e5f279371f8f4228aa180b76cd2 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Sun, 4 Feb 2018 22:22:34 +0100 Subject: [PATCH 06/12] More docs --- service/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/service/README.md b/service/README.md index 952a223..5f926ef 100644 --- a/service/README.md +++ b/service/README.md @@ -40,3 +40,13 @@ Liste mit Terminen als JSON Array. Jeder Termin enthält: - `title`: Titel des Termins - `start`: Start-Datum (oder Datum/Uhrzeit) des Termins - `end`: (optional) Enddatum (oder Datum/Uhrzeit) des Termins + +Beispiele: + +- [Der nächste Termin beim Grünen Ortsverband Rösrath](https://schaufenster-service.now.sh/events/?charset=utf8&num=1&ical_url=https%3A%2F%2Fgruene-roesrath.de%2Ftermine%2Fcal%2Fics%2F%3Ftype%3D150%26tx_cal_controller%255Bcalendar%255D%3D649) + +- [Die nächsten 3 Feiertage in Deutschland](https://schaufenster-service.now.sh/events/?num=3&ical_url=http%3A%2F%2Fwww.webcal.fi%2Fcal.php%3Fid%3D75%26rid%3Dics%26wrn%3D0%26wp%3D12%26wf%3D55) + +## Live Demo + +Der Service ist erreichbar unter https://schaufenster-service.now.sh/events/ From 7371c52ec534c4d77b7db7534c542f78358ef85f Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Sun, 4 Feb 2018 22:29:29 +0100 Subject: [PATCH 07/12] CORS: make access public --- service/api/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/api/main.py b/service/api/main.py index bec04fe..6b718a4 100644 --- a/service/api/main.py +++ b/service/api/main.py @@ -30,7 +30,7 @@ handlers = media.Handlers({ 'application/json': jsonhandler.JSONHandler(), }) -cors = CORS(allow_origins_list=['*']) +cors = CORS(allow_all_origins=True) app = falcon.API(middleware=[cors.middleware]) From 2a602686c2201529009dc0eae5a2d2b8235b9706 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Sun, 4 Feb 2018 22:34:59 +0100 Subject: [PATCH 08/12] CORS: allow all headers --- service/api/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/service/api/main.py b/service/api/main.py index 6b718a4..c7e16b4 100644 --- a/service/api/main.py +++ b/service/api/main.py @@ -30,7 +30,8 @@ handlers = media.Handlers({ 'application/json': jsonhandler.JSONHandler(), }) -cors = CORS(allow_all_origins=True) +cors = CORS(allow_all_origins=True, + allow_all_headers=True) app = falcon.API(middleware=[cors.middleware]) From 1c538eb445e8baeaf93fae88fc36cba4e2f4f7a7 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Mon, 5 Feb 2018 09:46:40 +0100 Subject: [PATCH 09/12] Little changes --- service/api/events.py | 4 +++- service/api/main.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/service/api/events.py b/service/api/events.py index 212a8a2..9874a7a 100644 --- a/service/api/events.py +++ b/service/api/events.py @@ -5,6 +5,7 @@ import icalendar from datetime import datetime from datetime import date + class Client(object): def __init__(self, url, charset=None): @@ -12,9 +13,10 @@ class Client(object): self.charset = charset self.events = [] self.__load() + self.timeout = 20 def __load(self): - r = requests.get(self.url) + r = requests.get(self.url, timeout=self.timeout) r.raise_for_status() # requests normally uses encoding returned by "Content-type" header. diff --git a/service/api/main.py b/service/api/main.py index c7e16b4..39b0443 100644 --- a/service/api/main.py +++ b/service/api/main.py @@ -23,6 +23,8 @@ class EventsResource(object): client = events.Client(url=ical_url, charset=charset) next_events = client.next_events(num) + del client + resp.media = next_events From acffd32511e45e0e42fcc39b1c366c4a955c3eca Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Wed, 7 Feb 2018 17:53:35 +0100 Subject: [PATCH 10/12] Little bug fix --- service/api/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/api/events.py b/service/api/events.py index 9874a7a..2e208c1 100644 --- a/service/api/events.py +++ b/service/api/events.py @@ -12,8 +12,8 @@ class Client(object): self.url = url self.charset = charset self.events = [] - self.__load() self.timeout = 20 + self.__load() def __load(self): r = requests.get(self.url, timeout=self.timeout) From 69f6fba133555e8d2ff346cc85a7346408583d02 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Thu, 15 Feb 2018 20:57:06 +0100 Subject: [PATCH 11/12] Adding proxy method for a single luftdaten.info sensor --- service/README.md | 12 +++++++++++- service/api/main.py | 33 +++++++++++++++++++++++++++------ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/service/README.md b/service/README.md index 5f926ef..53db6bc 100644 --- a/service/README.md +++ b/service/README.md @@ -47,6 +47,16 @@ Beispiele: - [Die nächsten 3 Feiertage in Deutschland](https://schaufenster-service.now.sh/events/?num=3&ical_url=http%3A%2F%2Fwww.webcal.fi%2Fcal.php%3Fid%3D75%26rid%3Dics%26wrn%3D0%26wp%3D12%26wf%3D55) -## Live Demo +### Live Demo Der Service ist erreichbar unter https://schaufenster-service.now.sh/events/ + + +### `GET /luftdaten.info/v1/sensor/{sensor_id}/` - Aktuelle Messwerte eines luftdaten.info Sensors ausgeben + +Mit dieser Methode können Feinstaub-Messwerte eines luftdaten.info Sensors +abgerufen werden. + +Beispiel: + +- https://schaufenster-service.now.sh/luftdateninfo/v1/sensor/6316/ diff --git a/service/api/main.py b/service/api/main.py index 39b0443..bfadcee 100644 --- a/service/api/main.py +++ b/service/api/main.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- -import falcon -from falcon import media -import logging -from falcon_cors import CORS from . import events from . import jsonhandler +from datetime import datetime +from falcon import media +from falcon_cors import CORS +import falcon +import logging +import requests class EventsResource(object): @@ -24,9 +26,27 @@ class EventsResource(object): client = events.Client(url=ical_url, charset=charset) next_events = client.next_events(num) del client - - resp.media = next_events + resp.media = next_events + maxage = 60 * 60 # 1 hour + resp.cache_control(["max_age=%d" % maxage]) + +class ParticleSensorResource(object): + + def on_get(self, req, resp, sensor_id): + """ + Delivers data for a particular luftdaten.info sensor + """ + url = "http://api.luftdaten.info/v1/sensor/%s/" % sensor_id + r = requests.get(url) + + if r.status_code == 200: + maxage = 60 * 5 # 5 minutes + resp.cache_control = ["max_age=%d" % maxage] + resp.media = r.json() + else: + resp.media = r.text + resp.status = str(r.status_code) + " Unknown Error" handlers = media.Handlers({ 'application/json': jsonhandler.JSONHandler(), @@ -41,3 +61,4 @@ app.req_options.media_handlers = handlers app.resp_options.media_handlers = handlers app.add_route('/events/', EventsResource()) +app.add_route('/luftdaten.info/v1/sensor/{sensor_id}/', ParticleSensorResource()) From d5f4eae7012e2dfd3da584862ca740ad64828ad9 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Thu, 15 Feb 2018 20:58:16 +0100 Subject: [PATCH 12/12] Fixing cache_control header for events API --- service/api/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/api/main.py b/service/api/main.py index bfadcee..9f83eea 100644 --- a/service/api/main.py +++ b/service/api/main.py @@ -29,7 +29,7 @@ class EventsResource(object): resp.media = next_events maxage = 60 * 60 # 1 hour - resp.cache_control(["max_age=%d" % maxage]) + resp.cache_control = ["max_age=%d" % maxage] class ParticleSensorResource(object):