Merge pull request #3 from netzbegruenung/python-service
Python webservice to support digital signage HTML/JS apps
This commit is contained in:
commit
e046972f9a
|
@ -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.
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
venv
|
||||
.pytest_cache
|
||||
*.pyc
|
|
@ -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"]
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
serve:
|
||||
gunicorn --reload -b 0.0.0.0:5000 api.main:app
|
||||
|
||||
test:
|
||||
pytest tests
|
|
@ -0,0 +1,62 @@
|
|||
# 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
|
||||
|
||||
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/
|
||||
|
||||
|
||||
### `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/
|
|
@ -0,0 +1,63 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import requests
|
||||
import icalendar
|
||||
from datetime import datetime
|
||||
from datetime import date
|
||||
|
||||
|
||||
class Client(object):
|
||||
|
||||
def __init__(self, url, charset=None):
|
||||
self.url = url
|
||||
self.charset = charset
|
||||
self.events = []
|
||||
self.timeout = 20
|
||||
self.__load()
|
||||
|
||||
def __load(self):
|
||||
r = requests.get(self.url, timeout=self.timeout)
|
||||
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
|
||||
if "SUMMARY" in event:
|
||||
title = event["SUMMARY"]
|
||||
dtstart = event["DTSTART"].dt
|
||||
dtend = event["DTEND"].dt
|
||||
self.events.append({
|
||||
"title": title,
|
||||
"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
|
||||
"""
|
||||
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)
|
||||
if len(out) >= num:
|
||||
break
|
||||
return out
|
|
@ -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
|
|
@ -0,0 +1,64 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
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):
|
||||
|
||||
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)
|
||||
del client
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
cors = CORS(allow_all_origins=True,
|
||||
allow_all_headers=True)
|
||||
|
||||
app = falcon.API(middleware=[cors.middleware])
|
||||
|
||||
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())
|
|
@ -0,0 +1,18 @@
|
|||
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
|
||||
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
|
|
@ -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
|
Loading…
Reference in New Issue