photolib/photoapp/daemon.py

525 lines
20 KiB
Python
Raw Normal View History

2018-09-09 12:05:13 -07:00
import os
import cherrypy
import logging
2018-09-11 20:35:04 -07:00
from datetime import datetime, timedelta
2018-09-09 12:05:13 -07:00
from photoapp.library import PhotoLibrary
2018-09-22 18:01:33 -07:00
from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus
2018-09-09 12:05:13 -07:00
from jinja2 import Environment, FileSystemLoader, select_autoescape
2018-09-10 21:19:02 -07:00
from sqlalchemy import desc
from sqlalchemy import func, and_, or_
2018-09-09 23:43:17 -07:00
import math
2018-09-23 14:58:42 -07:00
from urllib.parse import urlparse
2018-09-09 12:05:13 -07:00
2018-09-09 16:44:10 -07:00
APPROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
2018-09-22 15:12:01 -07:00
def auth():
2018-09-22 18:01:33 -07:00
"""
Return the currently authorized username (per request) or None
"""
2018-09-22 15:12:01 -07:00
return cherrypy.session.get('authed', None)
def mime2ext(mime):
2018-09-22 18:01:33 -07:00
"""
Given a mime type return the canonical file extension
"""
return {"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"application/octet-stream-xmp": "xmp",
"image/x-canon-cr2": "cr2",
"video/mp4": "mp4",
"video/quicktime": "mov"}[mime]
def require_auth(func):
"""
Decorator: raise 403 unless session is authed
"""
def wrapped(*args, **kwargs):
if not auth():
raise cherrypy.HTTPError(403)
return func(*args, **kwargs)
return wrapped
def photo_auth_filter(query):
"""
Sqlalchemy helper: filter the given PhotoSet query to items that match the authorized user's PhotoStatus access
level. Currently, authed users can access ALL photos, and unauthed users can access only PhotoStatus.public
status items.
"""
return query.filter(PhotoSet.status == PhotoStatus.public) if not auth() else query
2018-09-22 15:12:01 -07:00
2018-09-23 15:00:54 -07:00
def slugify(words):
return ''.join(letter for letter in '-'.join(words.lower().split())
if ('a' <= letter <= 'z') or ('0' <= letter <= '9') or letter == '-')
2018-09-09 12:05:13 -07:00
class PhotosWeb(object):
2018-09-09 16:44:10 -07:00
def __init__(self, library, template_dir):
2018-09-09 12:05:13 -07:00
self.library = library
2018-09-09 16:44:10 -07:00
self.tpl = Environment(loader=FileSystemLoader(template_dir),
2018-09-09 12:05:13 -07:00
autoescape=select_autoescape(['html', 'xml']))
2018-09-23 15:00:54 -07:00
self.tpl.filters.update(mime2ext=mime2ext,
basename=os.path.basename,
ceil=math.ceil,
statusstr=lambda x: str(x).split(".")[-1])
2018-09-22 15:12:01 -07:00
2018-09-09 12:05:13 -07:00
self.thumb = ThumbnailView(self)
self.photo = PhotoView(self)
self.download = DownloadView(self)
2018-09-11 20:35:04 -07:00
self.date = DateView(self)
2018-09-13 22:59:37 -07:00
self.tag = TagView(self)
self.album = self.tag
2018-09-09 12:05:13 -07:00
2018-09-09 17:21:23 -07:00
def render(self, template, **kwargs):
2018-09-22 15:12:01 -07:00
"""
Render a template
"""
2018-09-09 17:21:23 -07:00
return self.tpl.get_template(template).render(**kwargs, **self.get_default_vars())
def get_default_vars(self):
2018-09-22 15:12:01 -07:00
"""
Return a dict containing variables expected to be on every page
"""
2018-09-09 17:21:23 -07:00
s = self.session()
2018-09-22 18:01:33 -07:00
# all tags / albums with photos visible under the current auth context
tagq = s.query(Tag).join(TagItem).join(PhotoSet)
if not auth():
tagq = tagq.filter(PhotoSet.status == PhotoStatus.public)
2018-09-23 15:00:54 -07:00
tagq = tagq.filter(Tag.is_album == False).order_by(Tag.name).all() # pragma: manual auth
2018-09-22 18:01:33 -07:00
albumq = s.query(Tag).join(TagItem).join(PhotoSet)
if not auth():
albumq = albumq.filter(PhotoSet.status == PhotoStatus.public)
2018-09-23 15:00:54 -07:00
albumq = albumq.filter(Tag.is_album == True).order_by(Tag.name).all() # pragma: manual auth
2018-09-22 18:01:33 -07:00
2018-09-09 17:21:23 -07:00
ret = {
2018-09-22 18:01:33 -07:00
"all_tags": tagq,
"all_albums": albumq,
2018-09-22 15:12:23 -07:00
"path": cherrypy.request.path_info,
2018-09-22 18:01:33 -07:00
"auth": auth(),
"PhotoStatus": PhotoStatus
2018-09-09 17:21:23 -07:00
}
2018-09-22 15:12:01 -07:00
s.close()
2018-09-09 17:21:23 -07:00
return ret
2018-09-09 12:05:13 -07:00
def session(self):
2018-09-22 15:12:01 -07:00
"""
Get a database session
"""
2018-09-09 12:05:13 -07:00
return self.library.session()
@cherrypy.expose
def index(self):
2018-09-22 15:12:01 -07:00
"""
Home page - redirect to the photo feed
"""
2018-09-09 12:05:13 -07:00
raise cherrypy.HTTPRedirect('feed', 302)
@cherrypy.expose
def feed(self, page=0, pgsize=25):
2018-09-22 15:12:01 -07:00
"""
/feed - main photo feed - show photos sorted by date, newest first
"""
2018-09-09 12:05:13 -07:00
s = self.session()
page, pgsize = int(page), int(pgsize)
2018-09-22 18:01:33 -07:00
total_sets = photo_auth_filter(s.query(func.count(PhotoSet.id))).first()[0]
images = photo_auth_filter(s.query(PhotoSet)).order_by(PhotoSet.date.desc()). \
offset(pgsize * page).limit(pgsize).all()
2018-09-13 22:59:37 -07:00
yield self.render("feed.html", images=[i for i in images], page=page, pgsize=int(pgsize), total_sets=total_sets)
2018-09-09 12:05:13 -07:00
@cherrypy.expose
2018-09-22 15:12:01 -07:00
def stats(self):
"""
/stats - show server statistics
"""
2018-09-09 12:05:13 -07:00
s = self.session()
2018-09-22 18:01:33 -07:00
images = photo_auth_filter(s.query(func.count(PhotoSet.uuid),
func.strftime('%Y', PhotoSet.date).label('year'),
func.strftime('%m', PhotoSet.date).label('month'))). \
2018-09-10 21:19:02 -07:00
group_by('year', 'month').order_by(desc('year'), desc('month')).all()
2018-09-22 18:01:33 -07:00
tsize = photo_auth_filter(s.query(func.sum(Photo.size)).join(PhotoSet)).scalar() # pragma: manual auth
2018-09-10 21:19:02 -07:00
yield self.render("monthly.html", images=images, tsize=tsize)
2018-09-09 12:05:13 -07:00
2018-09-09 13:45:26 -07:00
@cherrypy.expose
def map(self, i=None, zoom=3):
2018-09-22 15:12:01 -07:00
"""
/map - show all photos on the a map, or a single point if $i is passed
TODO using so many coordinates is slow in the browser. dedupe them somehow.
"""
2018-09-09 13:45:26 -07:00
s = self.session()
2018-09-22 18:01:33 -07:00
query = photo_auth_filter(s.query(PhotoSet)).filter(PhotoSet.lat != 0, PhotoSet.lon != 0)
2018-09-09 13:45:26 -07:00
if i:
query = query.filter(PhotoSet.uuid == i)
2018-09-09 17:21:23 -07:00
yield self.render("map.html", images=query.all(), zoom=int(zoom))
2018-09-09 13:45:26 -07:00
2018-09-13 22:59:22 -07:00
@cherrypy.expose
2018-09-22 18:01:33 -07:00
@require_auth
2018-09-22 15:12:23 -07:00
def create_tags(self, fromdate=None, uuid=None, tag=None, newtag=None, remove=None):
2018-09-13 22:59:22 -07:00
"""
2018-09-22 15:12:23 -07:00
/create_tags - tag multiple items selected by day of photo
:param fromdate: act upon photos taken on this day
:param uuid: act upon a single photo with this uuid
:param tag: target photos will have a tag specified by this uuid added
:param remove: target photos will have the tag specified by this uuid removed
:param newtag: new tag name to create
2018-09-13 22:59:22 -07:00
"""
s = self.session()
2018-09-22 15:12:23 -07:00
def get_photos():
if fromdate:
dt = datetime.strptime(fromdate, "%Y-%m-%d")
dt_end = dt + timedelta(days=1)
photos = s.query(PhotoSet).filter(and_(PhotoSet.date >= dt,
PhotoSet.date < dt_end)).order_by(PhotoSet.date)
num_photos = s.query(func.count(PhotoSet.id)). \
filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).order_by(PhotoSet.date).scalar()
if uuid:
photos = s.query(PhotoSet).filter(PhotoSet.uuid == uuid)
num_photos = s.query(func.count(PhotoSet.id)).filter(PhotoSet.uuid == uuid).scalar()
return photos, num_photos
if remove:
rmtag = s.query(Tag).filter(Tag.uuid == remove).first()
photoq, _ = get_photos()
for photo in photoq:
s.query(TagItem).filter(TagItem.tag_id == rmtag.id, TagItem.set_id == photo.id).delete()
s.commit()
2018-09-13 22:59:22 -07:00
if newtag:
2018-09-23 15:26:41 -07:00
s.add(Tag(title=newtag.capitalize(), name=newtag, slug=slugify(newtag)))
2018-09-23 15:00:54 -07:00
s.commit()
2018-09-13 22:59:22 -07:00
2018-09-22 15:12:23 -07:00
photos, num_photos = get_photos()
2018-09-13 22:59:22 -07:00
if tag: # Create the tag on all the photos
tag = s.query(Tag).filter(Tag.uuid == tag).first()
for photo in photos.all():
if 0 == s.query(func.count(TagItem.id)).filter(TagItem.tag_id == tag.id,
TagItem.set_id == photo.id).scalar():
s.add(TagItem(tag_id=tag.id, set_id=photo.id))
s.commit()
2018-09-23 15:00:54 -07:00
alltags = s.query(Tag).order_by(Tag.name).all()
2018-09-22 15:12:23 -07:00
yield self.render("create_tags.html", images=photos, alltags=alltags,
num_photos=num_photos, fromdate=fromdate, uuid=uuid)
@cherrypy.expose
def login(self):
"""
/login - enable super features by logging into the app
"""
cherrypy.session['authed'] = cherrypy.request.login
2018-09-23 14:58:42 -07:00
dest = "/feed" if "Referer" not in cherrypy.request.headers \
else urlparse(cherrypy.request.headers["Referer"]).path
raise cherrypy.HTTPRedirect(dest, 302)
2018-09-22 15:12:23 -07:00
@cherrypy.expose
2018-09-22 18:01:33 -07:00
def logout(self):
2018-09-22 15:12:23 -07:00
"""
2018-09-23 14:58:42 -07:00
/logout
2018-09-22 15:12:23 -07:00
"""
2018-09-22 18:01:33 -07:00
cherrypy.session.clear()
2018-09-23 14:58:42 -07:00
dest = "/feed" if "Referer" not in cherrypy.request.headers \
else urlparse(cherrypy.request.headers["Referer"]).path
raise cherrypy.HTTPRedirect(dest, 302)
2018-09-13 22:59:22 -07:00
2018-09-23 15:26:41 -07:00
@cherrypy.expose
def error(self, status, message, traceback, version):
yield self.render("error.html", status=status, message=message, traceback=traceback)
2018-09-09 12:05:13 -07:00
2018-09-11 20:35:04 -07:00
@cherrypy.popargs('date')
class DateView(object):
2018-09-13 22:59:22 -07:00
"""
View all the photos shot on a given date
"""
2018-09-11 20:35:04 -07:00
def __init__(self, master):
self.master = master
@cherrypy.expose
def index(self, date=None, page=0):
s = self.master.session()
if date:
page = int(page)
pgsize = 100
dt = datetime.strptime(date, "%Y-%m-%d")
dt_end = dt + timedelta(days=1)
2018-09-22 18:01:33 -07:00
total_sets = photo_auth_filter(s.query(func.count(PhotoSet.id))). \
2018-09-11 20:35:04 -07:00
filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).first()[0]
2018-09-22 18:01:33 -07:00
images = photo_auth_filter(s.query(PhotoSet)).filter(and_(PhotoSet.date >= dt,
PhotoSet.date < dt_end)).order_by(PhotoSet.date). \
2018-09-11 20:35:04 -07:00
offset(page * pgsize).limit(pgsize).all()
2018-09-13 22:59:37 -07:00
yield self.master.render("date.html", page=page, pgsize=pgsize, total_sets=total_sets,
images=[i for i in images], date=dt)
2018-09-11 20:35:04 -07:00
return
2018-09-22 18:01:33 -07:00
images = photo_auth_filter(s.query(PhotoSet, func.strftime('%Y-%m-%d',
PhotoSet.date).label('gdate'),
func.count('photos.id'),
func.strftime('%Y', PhotoSet.date).label('year'),
func.strftime('%m', PhotoSet.date).label('month'),
func.strftime('%d', PhotoSet.date).label('day'))). \
2018-09-11 20:35:04 -07:00
group_by('gdate').order_by(desc('year'), 'month', 'day').all()
yield self.master.render("dates.html", images=images)
2018-09-09 12:05:13 -07:00
@cherrypy.popargs('item_type', 'thumb_size', 'uuid')
class ThumbnailView(object):
2018-09-22 15:12:01 -07:00
"""
Generate and serve thumbnails on-demand
"""
2018-09-09 12:05:13 -07:00
def __init__(self, master):
self.master = master
self._cp_config = {"tools.trailing_slash.on": False}
@cherrypy.expose
def index(self, item_type, thumb_size, uuid):
uuid = uuid.split(".")[0]
s = self.master.session()
2018-09-22 18:01:33 -07:00
query = photo_auth_filter(s.query(Photo)).filter(Photo.set.has(uuid=uuid)) if item_type == "set" \
else photo_auth_filter(s.query(Photo)).filter(Photo.uuid == uuid) if item_type == "one" \
2018-09-09 12:05:13 -07:00
else None
2018-09-22 18:01:33 -07:00
assert query
2018-09-09 12:05:13 -07:00
# prefer making thumbs from jpeg to avoid loading large raws
first = None
best = None
for photo in query.all():
if first is None:
first = photo
if photo.format == "image/jpeg":
best = photo
break
thumb_from = best or first
if not thumb_from:
2018-09-22 18:01:33 -07:00
raise Exception("404")
2018-09-09 12:05:13 -07:00
# TODO some lock around calls to this based on uuid
thumb_path = self.master.library.make_thumb(thumb_from, thumb_size)
if thumb_path:
2018-09-09 13:45:26 -07:00
return cherrypy.lib.static.serve_file(thumb_path, "image/jpeg")
2018-09-09 12:05:13 -07:00
else:
2018-09-09 16:44:10 -07:00
return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "styles/dist/unknown.svg"), "image/svg+xml")
2018-09-09 12:05:13 -07:00
@cherrypy.popargs('item_type', 'uuid')
class DownloadView(object):
2018-09-22 15:12:01 -07:00
"""
View original files or force-download them
"""
2018-09-09 12:05:13 -07:00
def __init__(self, master):
self.master = master
self._cp_config = {"tools.trailing_slash.on": False}
@cherrypy.expose
def index(self, item_type, uuid, preview=False):
uuid = uuid.split(".")[0]
s = self.master.session()
query = None if item_type == "set" \
2018-09-22 18:01:33 -07:00
else photo_auth_filter(s.query(Photo)).filter(Photo.uuid == uuid) if item_type == "one" \
2018-09-09 12:05:13 -07:00
else None # TODO set download query
item = query.first()
extra = {}
if not preview:
extra.update(disposition="attachement", name=os.path.basename(item.path))
return cherrypy.lib.static.serve_file(os.path.abspath(os.path.join(self.master.library.path, item.path)),
content_type=item.format, **extra)
@cherrypy.popargs('uuid')
class PhotoView(object):
2018-09-22 15:12:01 -07:00
"""
View a single photo
"""
2018-09-09 12:05:13 -07:00
def __init__(self, master):
self.master = master
self._cp_config = {"tools.trailing_slash.on": False}
@cherrypy.expose
def index(self, uuid):
# uuid = uuid.split(".")[0]
2018-09-09 12:05:13 -07:00
s = self.master.session()
photo = photo_auth_filter(s.query(PhotoSet)).filter(or_(PhotoSet.uuid == uuid, PhotoSet.slug == uuid)).first()
2018-09-23 15:26:41 -07:00
if not photo:
raise cherrypy.HTTPError(404)
2018-09-09 17:21:23 -07:00
yield self.master.render("photo.html", image=photo)
2018-09-09 12:05:13 -07:00
2018-09-22 18:01:33 -07:00
@cherrypy.expose
@require_auth
def op(self, uuid, op, title=None, description=None, offset=None):
2018-09-23 15:26:41 -07:00
"""
Modify a photo
:param op: operation to perform:
* "Make public":
* "Make private":
* "Save": update the photo's title, description, and date_offset fields
"""
2018-09-22 18:01:33 -07:00
s = self.master.session()
photo = s.query(PhotoSet).filter(PhotoSet.uuid == uuid).first()
if op == "Make public":
photo.status = PhotoStatus.public
elif op == "Make private":
photo.status = PhotoStatus.private
elif op == "Save":
photo.title = title
photo.description = description
photo.slug = slugify(title) or None
photo.date_offset = int(offset) if offset else 0
2018-09-22 18:01:33 -07:00
s.commit()
raise cherrypy.HTTPRedirect('/photo/{}'.format(photo.slug or photo.uuid), 302)
@cherrypy.expose
@require_auth
def edit(self, uuid):
s = self.master.session()
photo = photo_auth_filter(s.query(PhotoSet)).filter(PhotoSet.uuid == uuid).first()
yield self.master.render("photo_edit.html", image=photo)
2018-09-22 18:01:33 -07:00
2018-09-09 12:05:13 -07:00
2018-09-13 22:59:22 -07:00
@cherrypy.popargs('uuid')
class TagView(object):
"""
View the photos associated with a single tag
"""
def __init__(self, master):
self.master = master
# self._cp_config = {"tools.trailing_slash.on": False}
@cherrypy.expose
def index(self, uuid, page=0):
page = int(page)
pgsize = 100
s = self.master.session()
2018-09-22 15:12:23 -07:00
if uuid == "untagged":
2018-09-22 18:01:33 -07:00
numphotos = photo_auth_filter(s.query(func.count(PhotoSet.id))). \
filter(~PhotoSet.id.in_(s.query(TagItem.set_id))).scalar()
photos = photo_auth_filter(s.query(PhotoSet)).filter(~PhotoSet.id.in_(s.query(TagItem.set_id))).\
offset(page * pgsize). \
limit(pgsize).all()
2018-09-22 15:12:23 -07:00
yield self.master.render("untagged.html", images=photos, total_items=numphotos, pgsize=pgsize, page=page)
else:
tag = s.query(Tag).filter(or_(Tag.uuid == uuid, Tag.slug == uuid)).first()
2018-09-22 18:01:33 -07:00
numphotos = photo_auth_filter(s.query(func.count(Tag.id)).join(TagItem).join(PhotoSet)). \
filter(Tag.id == tag.id).scalar()
photos = photo_auth_filter(s.query(PhotoSet)).join(TagItem).join(Tag). \
filter(Tag.id == tag.id). \
order_by(PhotoSet.date.desc()). \
offset(page * pgsize). \
limit(pgsize).all()
2018-09-22 15:12:23 -07:00
yield self.master.render("album.html", tag=tag, images=photos,
total_items=numphotos, pgsize=pgsize, page=page)
2018-09-13 22:59:22 -07:00
@cherrypy.expose
2018-09-22 18:01:33 -07:00
@require_auth
2018-09-13 22:59:22 -07:00
def op(self, uuid, op):
2018-09-23 13:04:50 -07:00
"""
Perform some action on this tag
- Promote: label this tag an album
- Demote: label this tag as only a tag and not an album
- Delete: remove this tag
- Make all public: mark all photos under this tag as public
- Make all private: mark all photos under this tag as private
"""
2018-09-13 22:59:22 -07:00
s = self.master.session()
tag = s.query(Tag).filter(or_(Tag.uuid == uuid, Tag.slug == uuid)).first()
2018-09-13 22:59:22 -07:00
if op == "Demote to tag":
tag.is_album = 0
elif op == "Promote to album":
tag.is_album = 1
2018-09-22 15:12:35 -07:00
elif op == "Delete tag":
s.query(TagItem).filter(TagItem.tag_id == tag.id).delete()
s.delete(tag)
s.commit()
raise cherrypy.HTTPRedirect('/', 302)
2018-09-23 13:04:50 -07:00
elif op == "Make all public":
# TODO smarter query
for photo in s.query(PhotoSet).join(TagItem).join(Tag).filter(Tag.id == tag.id).all():
2018-09-23 13:04:50 -07:00
photo.status = PhotoStatus.public
elif op == "Make all private":
# TODO smarter query
for photo in s.query(PhotoSet).join(TagItem).join(Tag).filter(Tag.id == tag.id).all():
2018-09-23 13:04:50 -07:00
photo.status = PhotoStatus.private
2018-09-13 22:59:22 -07:00
else:
2018-09-22 15:12:35 -07:00
raise Exception("Invalid op: '{}'".format(op))
2018-09-13 22:59:22 -07:00
s.commit()
raise cherrypy.HTTPRedirect('/tag/{}'.format(tag.uuid), 302)
2018-09-09 12:05:13 -07:00
def main():
import argparse
import signal
parser = argparse.ArgumentParser(description="Photod photo server")
parser.add_argument('-p', '--port', default=8080, type=int, help="tcp port to listen on")
parser.add_argument('-l', '--library', default="./library", help="library path")
2018-09-11 22:25:09 -07:00
parser.add_argument('-c', '--cache', default="./cache", help="cache path")
parser.add_argument('-s', '--database', default="./photos.db", help="path to persistent sqlite database")
2018-09-09 12:05:13 -07:00
parser.add_argument('--debug', action="store_true", help="enable development options")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING,
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
2018-09-11 22:25:09 -07:00
library = PhotoLibrary(args.database, args.library, args.cache)
2018-09-09 12:05:13 -07:00
2018-09-09 16:44:10 -07:00
tpl_dir = os.path.join(APPROOT, "templates") if not args.debug else "templates"
web = PhotosWeb(library, tpl_dir)
2018-09-23 15:26:41 -07:00
web_config = {'error_page.403': web.error,
'error_page.404': web.error}
2018-09-09 12:05:13 -07:00
2018-09-22 15:12:23 -07:00
def validate_password(realm, username, password):
print("I JUST VALIDATED {}:{} ({})".format(username, password, realm))
return True
2018-09-09 12:05:13 -07:00
cherrypy.tree.mount(web, '/', {'/': web_config,
'/static': {"tools.staticdir.on": True,
2018-09-22 15:12:23 -07:00
"tools.staticdir.dir": os.path.join(APPROOT, "styles/dist")
if not args.debug else os.path.abspath("styles/dist")},
'/login': {'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'photolib',
'tools.auth_basic.checkpassword': validate_password}})
2018-09-09 12:05:13 -07:00
cherrypy.config.update({
2018-09-22 15:12:23 -07:00
'tools.sessions.on': True,
'tools.sessions.locking': 'explicit',
'tools.sessions.timeout': 525600,
2018-09-09 12:05:13 -07:00
'request.show_tracebacks': True,
'server.socket_port': args.port,
'server.thread_pool': 25,
'server.socket_host': '0.0.0.0',
'server.show_tracebacks': True,
'log.screen': False,
'engine.autoreload.on': args.debug
})
def signal_handler(signum, stack):
logging.critical('Got sig {}, exiting...'.format(signum))
cherrypy.engine.exit()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
cherrypy.engine.start()
cherrypy.engine.block()
finally:
logging.info("API has shut down")
cherrypy.engine.exit()
if __name__ == '__main__':
main()