Introduced support for scheduling actions

This commit is contained in:
Russell Ballestrini 2025-06-09 01:09:25 +00:00
parent cddee2604f
commit 4d899d367f
9 changed files with 1238 additions and 59 deletions

758
app.py
View file

@ -1,5 +1,5 @@
###############################################################################
# app.py - PyraFiles Application with Proper Agent and User Separation
# app.py - Enhanced PyraFiles Application with Scheduled Actions and More
###############################################################################
import os
import base64
@ -15,6 +15,10 @@ import mimetypes
import json
import logging
import unicodedata
import threading
import time
import signal
import sys
from email.mime.text import MIMEText
from pyramid.config import Configurator
@ -33,6 +37,7 @@ from sqlalchemy import (
ForeignKey,
Text,
or_,
inspect,
)
from sqlalchemy.orm import (
declarative_base,
@ -181,6 +186,19 @@ def get_mime_type(filename):
return mime_type
def is_ascii_file(mime_type):
"""Check if the file is ASCII-based (text files)."""
ascii_types = [
"text/",
"application/json",
"application/xml",
"application/javascript",
"application/x-yaml",
"application/yaml",
]
return any(mime_type.startswith(t) for t in ascii_types)
def uuid_to_short_id(u):
"""Encode UUID to a URL-safe base64 string without padding."""
return base64.urlsafe_b64encode(u.bytes).decode("ascii").rstrip("=")
@ -351,6 +369,15 @@ class Agent(Base):
)
# New table for scheduler lock
class SchedulerLock(Base):
__tablename__ = "scheduler_lock"
id = Column(Integer, primary_key=True)
locked = Column(Boolean, default=False)
locked_at = Column(DateTime, nullable=True)
process_id = Column(String, nullable=True)
################################################################################
# Namespace Database Models
################################################################################
@ -365,15 +392,33 @@ class Media(NamespaceBase):
filename = Column(String, nullable=False)
title = Column(String, nullable=True) # Optional title
media_type = Column(String, nullable=False) # 'image', 'audio', 'video'
mime_type = Column(String, nullable=True) # MIME type for compatibility
media_b64 = Column(Text, nullable=False)
upload_date = Column(DateTime, default=datetime.datetime.utcnow)
is_public = Column(Boolean, default=True)
visibility = Column(String, default="public") # 'public', 'private', 'unlisted'
size = Column(Integer, nullable=False) # Size in bytes
def __repr__(self):
return f"<Media(id='{self.id}', filename='{self.filename}')>"
class ScheduledAction(NamespaceBase):
__tablename__ = "scheduled_actions"
id = Column(String, primary_key=True) # UUID
media_id = Column(String, ForeignKey("media.id"), nullable=False)
action_type = Column(
String, nullable=False
) # 'set_public', 'set_private', 'set_unlisted', 'delete'
scheduled_date = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
status = Column(String, default="pending") # 'pending', 'completed', 'failed'
media = relationship("Media")
def __repr__(self):
return f"<ScheduledAction(media_id='{self.media_id}', action='{self.action_type}', date='{self.scheduled_date}')>"
################################################################################
# Jinja2 Environment and Custom Filters
################################################################################
@ -572,6 +617,208 @@ def reader_required(view_func):
return wrapper
################################################################################
# Scheduler Functions
################################################################################
def acquire_scheduler_lock(dbsession):
"""Acquire the scheduler lock."""
process_id = f"{os.getpid()}_{int(time.time())}"
# Try to get existing lock
lock = dbsession.query(SchedulerLock).first()
if not lock:
# Create new lock
lock = SchedulerLock(
locked=True, locked_at=datetime.datetime.utcnow(), process_id=process_id
)
dbsession.add(lock)
dbsession.commit()
return True
# Check if lock is stale (older than 5 minutes)
if lock.locked and lock.locked_at:
if datetime.datetime.utcnow() - lock.locked_at > datetime.timedelta(minutes=5):
log.warning("Detected stale scheduler lock, releasing it")
lock.locked = False
lock.locked_at = None
lock.process_id = None
dbsession.commit()
if not lock.locked:
lock.locked = True
lock.locked_at = datetime.datetime.utcnow()
lock.process_id = process_id
dbsession.commit()
return True
return False
def release_scheduler_lock(dbsession):
"""Release the scheduler lock."""
lock = dbsession.query(SchedulerLock).first()
if lock and lock.locked:
lock.locked = False
lock.locked_at = None
lock.process_id = None
dbsession.commit()
def process_scheduled_actions():
"""Process all scheduled actions across all namespaces."""
main_engine = create_engine(
DB_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
MainSessionFactory = sessionmaker(bind=main_engine)
main_dbsession = MainSessionFactory()
try:
if not acquire_scheduler_lock(main_dbsession):
return # Another process is already running
log.info("Scheduler acquired lock, processing scheduled actions...")
# Get all namespaces
namespaces = main_dbsession.query(Namespace).all()
for namespace in namespaces:
process_namespace_scheduled_actions(namespace.id)
log.info("Finished processing scheduled actions")
except Exception as e:
log.error(f"Error in scheduler: {e}")
finally:
release_scheduler_lock(main_dbsession)
main_dbsession.close()
main_engine.dispose()
def process_namespace_scheduled_actions(namespace_id):
"""Process scheduled actions for a specific namespace."""
namespace_db_url = get_namespace_db_url(namespace_id)
try:
namespace_engine = create_engine(
namespace_db_url,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
NamespaceSessionFactory = sessionmaker(bind=namespace_engine)
namespace_dbsession = NamespaceSessionFactory()
# Check if scheduled_actions table exists
inspector = inspect(namespace_engine)
if "scheduled_actions" not in inspector.get_table_names():
log.debug(
f"No scheduled_actions table in namespace {namespace_id}, skipping"
)
return
# Get pending actions that are due
now = datetime.datetime.utcnow()
pending_actions = (
namespace_dbsession.query(ScheduledAction)
.filter(
ScheduledAction.status == "pending",
ScheduledAction.scheduled_date <= now,
)
.all()
)
for action in pending_actions:
try:
media = (
namespace_dbsession.query(Media)
.filter_by(id=action.media_id)
.first()
)
if not media:
action.status = "failed"
continue
if action.action_type == "delete":
namespace_dbsession.delete(media)
log.info(f"Scheduled delete executed for media {media.filename}")
elif action.action_type == "set_public":
media.visibility = "public"
log.info(
f"Scheduled set_public executed for media {media.filename}"
)
elif action.action_type == "set_private":
media.visibility = "private"
log.info(
f"Scheduled set_private executed for media {media.filename}"
)
elif action.action_type == "set_unlisted":
media.visibility = "unlisted"
log.info(
f"Scheduled set_unlisted executed for media {media.filename}"
)
action.status = "completed"
except Exception as e:
log.error(f"Error processing scheduled action {action.id}: {e}")
action.status = "failed"
namespace_dbsession.commit()
except Exception as e:
log.error(f"Error processing namespace {namespace_id}: {e}")
finally:
if "namespace_dbsession" in locals():
namespace_dbsession.close()
if "namespace_engine" in locals():
namespace_engine.dispose()
# Global variables for scheduler thread
scheduler_thread = None
scheduler_running = False
def scheduler_worker():
"""Background scheduler worker that runs every minute."""
global scheduler_running
while scheduler_running:
try:
process_scheduled_actions()
except Exception as e:
log.error(f"Scheduler worker error: {e}")
# Sleep for 60 seconds
for i in range(60):
if not scheduler_running:
break
time.sleep(1)
def start_scheduler():
"""Start the background scheduler."""
global scheduler_thread, scheduler_running
if not scheduler_running:
scheduler_running = True
scheduler_thread = threading.Thread(target=scheduler_worker, daemon=True)
scheduler_thread.start()
log.info("Scheduler started")
def stop_scheduler():
"""Stop the background scheduler."""
global scheduler_thread, scheduler_running
if scheduler_running:
scheduler_running = False
if scheduler_thread:
scheduler_thread.join(timeout=5)
log.info("Scheduler stopped")
################################################################################
# Routes and Views
################################################################################
@ -1202,19 +1449,93 @@ def upload_media_post_view(request):
media_id = str(media_uuid)
media_short_id = uuid_to_short_id(media_uuid)
is_public = request.POST.get("is_public") == "on"
visibility = request.POST.get("visibility", "public")
if visibility not in ["public", "private", "unlisted"]:
visibility = "public"
media = Media(
id=media_id,
short_id=media_short_id,
filename=filename,
title=title, # now has a fallback of filename
title=title,
media_type=media_type,
mime_type=mime_type,
media_b64=encoded_str,
is_public=is_public,
visibility=visibility,
size=file_size,
)
namespace_dbsession.add(media)
# Handle scheduled actions
scheduled_public = request.POST.get("scheduled_public")
scheduled_private = request.POST.get("scheduled_private")
scheduled_unlisted = request.POST.get("scheduled_unlisted")
scheduled_delete = request.POST.get("scheduled_delete")
# Check if scheduled_actions table exists before adding scheduled actions
try:
inspector = inspect(namespace_dbsession.bind)
has_scheduled_actions = "scheduled_actions" in inspector.get_table_names()
except:
has_scheduled_actions = False
if has_scheduled_actions:
if scheduled_public:
try:
scheduled_date = datetime.datetime.fromisoformat(scheduled_public)
action_uuid = uuid.uuid4()
action = ScheduledAction(
id=str(action_uuid),
media_id=media_id,
action_type="set_public",
scheduled_date=scheduled_date,
)
namespace_dbsession.add(action)
except ValueError:
pass
if scheduled_private:
try:
scheduled_date = datetime.datetime.fromisoformat(scheduled_private)
action_uuid = uuid.uuid4()
action = ScheduledAction(
id=str(action_uuid),
media_id=media_id,
action_type="set_private",
scheduled_date=scheduled_date,
)
namespace_dbsession.add(action)
except ValueError:
pass
if scheduled_unlisted:
try:
scheduled_date = datetime.datetime.fromisoformat(scheduled_unlisted)
action_uuid = uuid.uuid4()
action = ScheduledAction(
id=str(action_uuid),
media_id=media_id,
action_type="set_unlisted",
scheduled_date=scheduled_date,
)
namespace_dbsession.add(action)
except ValueError:
pass
if scheduled_delete:
try:
scheduled_date = datetime.datetime.fromisoformat(scheduled_delete)
action_uuid = uuid.uuid4()
action = ScheduledAction(
id=str(action_uuid),
media_id=media_id,
action_type="delete",
scheduled_date=scheduled_date,
)
namespace_dbsession.add(action)
except ValueError:
pass
namespace_dbsession.flush()
return HTTPFound(
@ -1236,24 +1557,37 @@ def list_media_view(request):
return HTTPForbidden("You do not have access to this namespace.")
# Get the media items, excluding the media_b64 column
media_items = (
namespace_dbsession.query(Media)
.options(
load_only(
Media.id,
Media.short_id,
Media.filename,
Media.title,
Media.media_type,
Media.upload_date,
Media.is_public,
Media.size,
)
# Only show public media unless user has editor+ access
query = namespace_dbsession.query(Media).options(
load_only(
Media.id,
Media.short_id,
Media.filename,
Media.title,
Media.media_type,
Media.upload_date,
Media.visibility,
Media.size,
)
.order_by(Media.upload_date.desc())
.all()
)
# Filter based on user permissions
user_role = get_user_or_agent_namespace_role(request)
if user_role in ["owner", "editor"]:
# Owners and editors can see all media except unlisted
media_items = (
query.filter(Media.visibility != "unlisted")
.order_by(Media.upload_date.desc())
.all()
)
else:
# Readers and public users can only see public media
media_items = (
query.filter(Media.visibility == "public")
.order_by(Media.upload_date.desc())
.all()
)
return {
"request": request,
"namespace": namespace,
@ -1274,15 +1608,58 @@ def view_media_details_view(request):
role = get_user_or_agent_namespace_role(request)
is_owner_or_editor = role in ["owner", "editor"]
# Check if media is public or user has access
if not media.is_public and not check_namespace_permission(request, "reader"):
# Check visibility permissions
if media.visibility == "private" and not check_namespace_permission(
request, "reader"
):
return Response("Media not available.", status=403)
elif media.visibility == "unlisted" and not is_owner_or_editor:
return Response("Media not available.", status=403)
elif media.visibility == "public":
# Public media is always accessible
pass
# Check if this is an ASCII file and get preview content
ascii_preview = None
mime_type = get_mime_type(media.filename)
if is_ascii_file(mime_type):
try:
raw_content = base64.b64decode(media.media_b64).decode("utf-8")
lines = raw_content.split("\n")
if len(lines) > 100:
ascii_preview = "\n".join(lines[:100]) + "\n... (truncated)"
else:
ascii_preview = raw_content
except (UnicodeDecodeError, Exception):
ascii_preview = None
# Get scheduled actions for this media
scheduled_actions = []
if is_owner_or_editor:
try:
# Check if scheduled_actions table exists
inspector = inspect(namespace_dbsession.bind)
if "scheduled_actions" in inspector.get_table_names():
scheduled_actions = (
namespace_dbsession.query(ScheduledAction)
.filter(
ScheduledAction.media_id == media.id,
ScheduledAction.status == "pending",
)
.order_by(ScheduledAction.scheduled_date)
.all()
)
except Exception as e:
log.debug(f"Could not query scheduled actions: {e}")
scheduled_actions = []
return {
"request": request,
"media": media,
"namespace": namespace,
"is_owner_or_editor": is_owner_or_editor,
"ascii_preview": ascii_preview,
"scheduled_actions": scheduled_actions,
}
@ -1297,6 +1674,20 @@ def delete_media_view(request):
if not media:
return Response("Media not found.", status=404)
# Delete associated scheduled actions if table exists
try:
inspector = inspect(namespace_dbsession.bind)
if "scheduled_actions" in inspector.get_table_names():
scheduled_actions = (
namespace_dbsession.query(ScheduledAction)
.filter_by(media_id=media.id)
.all()
)
for action in scheduled_actions:
namespace_dbsession.delete(action)
except Exception as e:
log.debug(f"Could not delete scheduled actions: {e}")
namespace_dbsession.delete(media)
namespace_dbsession.flush()
@ -1318,10 +1709,20 @@ def edit_media_get_view(request):
if not media:
return Response("Media not found.", status=404)
# Check if this is an ASCII file and get editable content
ascii_content = None
mime_type = get_mime_type(media.filename)
if is_ascii_file(mime_type):
try:
ascii_content = base64.b64decode(media.media_b64).decode("utf-8")
except (UnicodeDecodeError, Exception):
ascii_content = None
return {
"request": request,
"media": media,
"namespace": namespace,
"ascii_content": ascii_content,
}
@ -1340,6 +1741,20 @@ def edit_media_post_view(request):
new_title = request.POST.get("title", "").strip()
media.title = new_title
# Handle ASCII content update
ascii_content = request.POST.get("ascii_content", "").strip()
mime_type = get_mime_type(media.filename)
if ascii_content and is_ascii_file(mime_type):
try:
# Update the media content with the new ASCII content
encoded_str = base64.b64encode(ascii_content.encode("utf-8")).decode(
"utf-8"
)
media.media_b64 = encoded_str
media.size = len(ascii_content.encode("utf-8"))
except Exception as e:
return Response(f"Error updating content: {e}", status=400)
# Handle media file update
new_media_file = request.POST.get("media_file")
if new_media_file is not None and getattr(new_media_file, "filename", "").strip():
@ -1358,12 +1773,14 @@ def edit_media_post_view(request):
media.filename = filename
media.media_type = media_type
media.mime_type = mime_type # Update mime_type too
media.media_b64 = encoded_str
media.size = file_size
# Handle public/private update
is_public = request.POST.get("is_public") == "on"
media.is_public = is_public
# Handle visibility update
visibility = request.POST.get("visibility", "public")
if visibility in ["public", "private", "unlisted"]:
media.visibility = visibility
namespace_dbsession.flush()
@ -1391,9 +1808,21 @@ def view_media_view(request):
if not media:
return Response("Media not found.", status=404)
# Check if media is public or user has access
if not media.is_public and not check_namespace_permission(request, "reader"):
role = get_user_or_agent_namespace_role(request)
is_owner_or_editor = role in ["owner", "editor"]
# Check visibility permissions
if media.visibility == "private" and not check_namespace_permission(
request, "reader"
):
return Response("Media not available.", status=403)
elif media.visibility == "unlisted":
# Unlisted media is accessible via direct link but not listed
if not is_owner_or_editor and not check_namespace_permission(request, "reader"):
return Response("Media not available.", status=403)
elif media.visibility == "public":
# Public media is always accessible via direct link
pass
media_data = base64.b64decode(media.media_b64)
mime_type = get_mime_type(media.filename)
@ -1423,6 +1852,248 @@ def view_media_view(request):
return response
################################################################################
# Scheduled Action Management
################################################################################
@view_config(route_name="delete_scheduled_action", request_method="POST")
@editor_required
def delete_scheduled_action_view(request):
namespace = request.namespace
namespace_dbsession = request.namespace_dbsession
media_short_id = request.matchdict.get("media_short_id")
action_id = request.matchdict.get("action_id")
# Verify media exists
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
if not media:
return Response("Media not found.", status=404)
# Check if scheduled_actions table exists
try:
inspector = inspect(namespace_dbsession.bind)
if "scheduled_actions" not in inspector.get_table_names():
return Response("Scheduled actions not supported.", status=404)
except:
return Response("Scheduled actions not supported.", status=404)
# Find and delete the scheduled action
scheduled_action = (
namespace_dbsession.query(ScheduledAction)
.filter(ScheduledAction.id == action_id, ScheduledAction.media_id == media.id)
.first()
)
if not scheduled_action:
return Response("Scheduled action not found.", status=404)
namespace_dbsession.delete(scheduled_action)
namespace_dbsession.flush()
request.session.flash(
f"Scheduled {scheduled_action.action_type.replace('set_', '').replace('_', ' ')} action deleted."
)
return HTTPFound(
location=request.route_url(
"view_media_details",
namespace_short_id=namespace.short_id,
media_short_id=media.short_id,
)
)
@view_config(
route_name="edit_scheduled_action",
request_method="GET",
renderer="edit_scheduled_action.html.j2",
)
@editor_required
def edit_scheduled_action_get_view(request):
namespace = request.namespace
namespace_dbsession = request.namespace_dbsession
media_short_id = request.matchdict.get("media_short_id")
action_id = request.matchdict.get("action_id")
# Verify media exists
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
if not media:
return Response("Media not found.", status=404)
# Check if scheduled_actions table exists
try:
inspector = inspect(namespace_dbsession.bind)
if "scheduled_actions" not in inspector.get_table_names():
return Response("Scheduled actions not supported.", status=404)
except:
return Response("Scheduled actions not supported.", status=404)
# Find the scheduled action
scheduled_action = (
namespace_dbsession.query(ScheduledAction)
.filter(ScheduledAction.id == action_id, ScheduledAction.media_id == media.id)
.first()
)
if not scheduled_action:
return Response("Scheduled action not found.", status=404)
return {
"request": request,
"namespace": namespace,
"media": media,
"scheduled_action": scheduled_action,
}
@view_config(route_name="edit_scheduled_action", request_method="POST")
@editor_required
def edit_scheduled_action_post_view(request):
namespace = request.namespace
namespace_dbsession = request.namespace_dbsession
media_short_id = request.matchdict.get("media_short_id")
action_id = request.matchdict.get("action_id")
# Verify media exists
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
if not media:
return Response("Media not found.", status=404)
# Check if scheduled_actions table exists
try:
inspector = inspect(namespace_dbsession.bind)
if "scheduled_actions" not in inspector.get_table_names():
return Response("Scheduled actions not supported.", status=404)
except:
return Response("Scheduled actions not supported.", status=404)
# Find the scheduled action
scheduled_action = (
namespace_dbsession.query(ScheduledAction)
.filter(ScheduledAction.id == action_id, ScheduledAction.media_id == media.id)
.first()
)
if not scheduled_action:
return Response("Scheduled action not found.", status=404)
# Update the scheduled action
new_action_type = request.POST.get("action_type")
new_scheduled_date = request.POST.get("scheduled_date")
if new_action_type not in ["set_public", "set_private", "set_unlisted", "delete"]:
return Response("Invalid action type.", status=400)
try:
scheduled_date = datetime.datetime.fromisoformat(new_scheduled_date)
except ValueError:
return Response("Invalid date format.", status=400)
scheduled_action.action_type = new_action_type
scheduled_action.scheduled_date = scheduled_date
namespace_dbsession.flush()
request.session.flash(
f"Scheduled action updated to {new_action_type.replace('set_', '').replace('_', ' ')} on {scheduled_date.strftime('%Y-%m-%d %H:%M')}."
)
return HTTPFound(
location=request.route_url(
"view_media_details",
namespace_short_id=namespace.short_id,
media_short_id=media.short_id,
)
)
@view_config(
route_name="add_scheduled_action",
request_method="GET",
renderer="add_scheduled_action.html.j2",
)
@editor_required
def add_scheduled_action_get_view(request):
namespace = request.namespace
namespace_dbsession = request.namespace_dbsession
media_short_id = request.matchdict.get("media_short_id")
# Verify media exists
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
if not media:
return Response("Media not found.", status=404)
# Check if scheduled_actions table exists
try:
inspector = inspect(namespace_dbsession.bind)
if "scheduled_actions" not in inspector.get_table_names():
return Response("Scheduled actions not supported.", status=404)
except:
return Response("Scheduled actions not supported.", status=404)
return {
"request": request,
"namespace": namespace,
"media": media,
}
@view_config(route_name="add_scheduled_action", request_method="POST")
@editor_required
def add_scheduled_action_post_view(request):
namespace = request.namespace
namespace_dbsession = request.namespace_dbsession
media_short_id = request.matchdict.get("media_short_id")
# Verify media exists
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
if not media:
return Response("Media not found.", status=404)
# Check if scheduled_actions table exists
try:
inspector = inspect(namespace_dbsession.bind)
if "scheduled_actions" not in inspector.get_table_names():
return Response("Scheduled actions not supported.", status=404)
except:
return Response("Scheduled actions not supported.", status=404)
# Get form data
action_type = request.POST.get("action_type")
scheduled_date_str = request.POST.get("scheduled_date")
if action_type not in ["set_public", "set_private", "set_unlisted", "delete"]:
return Response("Invalid action type.", status=400)
try:
scheduled_date = datetime.datetime.fromisoformat(scheduled_date_str)
except ValueError:
return Response("Invalid date format.", status=400)
# Create new scheduled action
action_uuid = uuid.uuid4()
scheduled_action = ScheduledAction(
id=str(action_uuid),
media_id=media.id,
action_type=action_type,
scheduled_date=scheduled_date,
)
namespace_dbsession.add(scheduled_action)
namespace_dbsession.flush()
request.session.flash(
f"Scheduled {action_type.replace('set_', '').replace('_', ' ')} action added for {scheduled_date.strftime('%Y-%m-%d %H:%M')}."
)
return HTTPFound(
location=request.route_url(
"view_media_details",
namespace_short_id=namespace.short_id,
media_short_id=media.short_id,
)
)
################################################################################
# Main
################################################################################
@ -1471,6 +2142,9 @@ def main(*config, **settings):
session_factory_ = sessionmaker(bind=engine)
Base.metadata.bind = engine
# Create tables if they don't exist
Base.metadata.create_all(engine)
DBSession = scoped_session(session_factory_)
# Register with zope.sqlalchemy
register(DBSession)
@ -1539,10 +2213,40 @@ def main(*config, **settings):
"/namespace/{namespace_short_id}/media/{media_short_id}",
)
# Scheduled Actions Management
config.add_route(
"delete_scheduled_action",
"/namespace/{namespace_short_id}/media/{media_short_id}/scheduled_action/{action_id}/delete",
)
config.add_route(
"edit_scheduled_action",
"/namespace/{namespace_short_id}/media/{media_short_id}/scheduled_action/{action_id}/edit",
)
config.add_route(
"add_scheduled_action",
"/namespace/{namespace_short_id}/media/{media_short_id}/add_scheduled_action",
)
config.scan()
# Start the scheduler
start_scheduler()
return config.make_wsgi_app()
# Signal handler for graceful shutdown
def signal_handler(signum, frame):
log.info("Received shutdown signal, stopping scheduler...")
stop_scheduler()
sys.exit(0)
# Register signal handlers
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Expose the WSGI application callable for uwsgi
uwsgi_app = main({})