Merge branch 'optimize-media-list-query' into 'main'

Introduced support for scheduling actions

See merge request engineering/unturf/upload.unturf.com!8
This commit is contained in:
Russell Ballestrini 2025-06-09 01:09:25 +00:00
commit 1d855a966c
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({})

View file

@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
PyraFiles Migration Script: is_public to visibility column
=========================================================
This script migrates existing PyraFiles databases from the old is_public boolean column
to the new visibility string column with three states: 'public', 'private', 'unlisted'.
Migration logic:
- is_public = True visibility = 'public'
- is_public = False visibility = 'private'
- All is_public values are then set to FALSE (for compatibility)
The is_public column is kept in the database but set to a consistent value.
Usage:
python migrate_visibility.py [--data-dir /path/to/data] [--dry-run]
Options:
--data-dir Path to PyraFiles data directory (default: ./data)
--dry-run Show what would be migrated without making changes
--help Show this help message
"""
import os
import sys
import argparse
import sqlite3
import glob
import logging
from pathlib import Path
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
log = logging.getLogger(__name__)
def check_table_structure(cursor, table_name):
"""Check if table exists and get its column information."""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = cursor.fetchall()
column_names = [col[1] for col in columns]
return columns, column_names
def migrate_database(db_path, dry_run=False):
"""Migrate a single database file."""
log.info(f"Processing database: {db_path}")
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check if media table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='media'")
if not cursor.fetchone():
log.info(f" No media table found in {db_path}, skipping")
conn.close()
return True
# Get current table structure
columns, column_names = check_table_structure(cursor, 'media')
has_is_public = 'is_public' in column_names
has_visibility = 'visibility' in column_names
log.info(f" Table structure: has_is_public={has_is_public}, has_visibility={has_visibility}")
if not has_is_public and has_visibility:
log.info(f" Database already migrated, skipping")
conn.close()
return True
if not has_is_public:
log.warning(f" No is_public column found in {db_path}, skipping")
conn.close()
return True
# Get count of records to migrate
cursor.execute("SELECT COUNT(*) FROM media")
total_records = cursor.fetchone()[0]
if total_records == 0:
log.info(f" No records to migrate in {db_path}")
conn.close()
return True
log.info(f" Found {total_records} records to migrate")
if dry_run:
# Show what would be migrated
cursor.execute("SELECT id, filename, is_public FROM media LIMIT 10")
sample_records = cursor.fetchall()
log.info(f" Sample records that would be migrated:")
for record in sample_records:
id_val, filename, is_public = record
new_visibility = 'public' if is_public else 'private'
log.info(f" {filename}: is_public={is_public} → visibility='{new_visibility}'")
if total_records > 10:
log.info(f" ... and {total_records - 10} more records")
conn.close()
return True
# Perform the actual migration
log.info(f" Starting migration...")
# Step 1: Add visibility column if it doesn't exist
if not has_visibility:
log.info(f" Adding visibility column...")
cursor.execute("ALTER TABLE media ADD COLUMN visibility TEXT DEFAULT 'public'")
# Step 2: Migrate data
log.info(f" Migrating data...")
cursor.execute("""
UPDATE media
SET visibility = CASE
WHEN is_public = 1 THEN 'public'
WHEN is_public = 0 THEN 'private'
ELSE 'public'
END
""")
migrated_count = cursor.rowcount
log.info(f" Updated {migrated_count} records")
# Step 3: Verify migration
cursor.execute("SELECT visibility, COUNT(*) FROM media GROUP BY visibility")
visibility_counts = cursor.fetchall()
log.info(f" Post-migration visibility distribution:")
for visibility, count in visibility_counts:
log.info(f" {visibility}: {count} records")
# Step 4: Set is_public to FALSE (0) for all records
# We'll keep the column but just set it to a consistent state
log.info(f" Setting is_public to FALSE for all records...")
cursor.execute("UPDATE media SET is_public = 0")
# Commit changes
conn.commit()
log.info(f" ✅ Migration completed successfully!")
except Exception as e:
log.error(f" ❌ Error migrating {db_path}: {e}")
if 'conn' in locals():
conn.rollback()
return False
finally:
if 'conn' in locals():
conn.close()
return True
def find_namespace_databases(data_dir):
"""Find all namespace database files."""
pattern = os.path.join(data_dir, "namespace_*.db")
namespace_dbs = glob.glob(pattern)
return namespace_dbs
def main():
parser = argparse.ArgumentParser(
description='Migrate PyraFiles databases from is_public to visibility column',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
'--data-dir',
default='./data',
help='Path to PyraFiles data directory (default: ./data)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be migrated without making changes'
)
args = parser.parse_args()
data_dir = Path(args.data_dir)
if not data_dir.exists():
log.error(f"Data directory does not exist: {data_dir}")
sys.exit(1)
if not data_dir.is_dir():
log.error(f"Data directory is not a directory: {data_dir}")
sys.exit(1)
log.info(f"Starting PyraFiles migration...")
log.info(f"Data directory: {data_dir.absolute()}")
log.info(f"Dry run: {args.dry_run}")
# Find all namespace databases
namespace_dbs = find_namespace_databases(str(data_dir))
if not namespace_dbs:
log.info("No namespace databases found. Nothing to migrate.")
sys.exit(0)
log.info(f"Found {len(namespace_dbs)} namespace databases to check:")
for db_path in sorted(namespace_dbs):
log.info(f" {os.path.basename(db_path)}")
if args.dry_run:
log.info("\n" + "="*50)
log.info("DRY RUN MODE - No changes will be made")
log.info("="*50)
# Migrate each database
success_count = 0
error_count = 0
for db_path in sorted(namespace_dbs):
if migrate_database(db_path, dry_run=args.dry_run):
success_count += 1
else:
error_count += 1
# Summary
log.info("\n" + "="*50)
log.info("MIGRATION SUMMARY")
log.info("="*50)
log.info(f"Total databases: {len(namespace_dbs)}")
log.info(f"Successfully processed: {success_count}")
log.info(f"Errors: {error_count}")
if args.dry_run:
log.info("\nThis was a dry run. No changes were made.")
log.info("Run without --dry-run to perform the actual migration.")
elif error_count == 0:
log.info("\n✅ All databases migrated successfully!")
else:
log.warning(f"\n⚠️ {error_count} databases had errors. Check logs above.")
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,28 @@
<!-- add_scheduled_action.html.j2 -->
{% extends 'base.html.j2' %}
{% block title %}Add Scheduled Action{% endblock %}
{% block content %}
<h1>Add Scheduled Action for "{{ media.title or media.filename }}"</h1>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label for="action_type">Action Type:</label>
<select name="action_type" required>
<option value="">Select an action...</option>
<option value="set_public">Set Public</option>
<option value="set_private">Set Private</option>
<option value="set_unlisted">Set Unlisted</option>
<option value="delete">Delete</option>
</select><br><br>
<label for="scheduled_date">Scheduled Date & Time:</label>
<input type="datetime-local" name="scheduled_date" required><br><br>
<button type="submit">Add Scheduled Action</button>
</form>
<p><a href="{{ request.route_url('view_media_details', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Back to Media Details</a></p>
{% endblock %}

View file

@ -1,3 +1,4 @@
<!-- base.html.j2 -->
<!DOCTYPE html>
<html lang="en">
<head>
@ -127,6 +128,9 @@
<li><a href="{{ request.route_url('home') }}">Home</a></li>
{% if request.user and request.user.is_verified %}
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li>
{% if request.namespace %}
<li><a href="{{ request.route_url('manage_namespace', namespace_short_id=request.namespace.short_id) }}">Manage Namespace</a></li>
{% endif %}
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li>
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form">
@ -149,6 +153,9 @@
<li><a href="{{ request.route_url('home') }}">Home</a></li>
{% if request.user and request.user.is_verified %}
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li>
{% if request.namespace %}
<li><a href="{{ request.route_url('manage_namespace', namespace_short_id=request.namespace.short_id) }}">Manage Namespace</a></li>
{% endif %}
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li>
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form">

View file

@ -1,3 +1,4 @@
<!-- edit_media.html.j2 -->
{% extends 'base.html.j2' %}
{% block title %}Edit Media{% endblock %}
@ -6,16 +7,34 @@
<h1>Edit Media</h1>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label for="title">Title:</label>
<input type="text" name="title" value="{{ media.title }}"><br><br>
<label for="media_file">Replace Media File (optional):</label>
<input type="file" name="media_file" accept="image/*,audio/*,video/*"><br><br>
{% if ascii_content %}
<label for="ascii_content">Text Content:</label>
<textarea name="ascii_content" rows="20" cols="80" style="font-family: monospace;">{{ ascii_content }}</textarea><br><br>
{% endif %}
<label>
<input type="checkbox" name="is_public" {% if media.is_public %}checked{% endif %}>
Make Media Public
</label><br><br>
<label for="media_file">Replace Media File (optional):</label>
<input type="file" name="media_file"
accept="image/*,audio/*,video/*,text/*,application/pdf,application/json,application/zip"><br><br>
<fieldset>
<legend>Visibility</legend>
<label>
<input type="radio" name="visibility" value="public" {% if media.visibility == 'public' %}checked{% endif %}>
Public - Visible in listings and accessible to all
</label><br>
<label>
<input type="radio" name="visibility" value="private" {% if media.visibility == 'private' %}checked{% endif %}>
Private - Only accessible to namespace members
</label><br>
<label>
<input type="radio" name="visibility" value="unlisted" {% if media.visibility == 'unlisted' %}checked{% endif %}>
Unlisted - Accessible via direct link but hidden from listings
</label>
</fieldset><br>
<button type="submit">Update Media</button>
</form>

View file

@ -0,0 +1,30 @@
<!-- edit_scheduled_action.html.j2 -->
{% extends 'base.html.j2' %}
{% block title %}Edit Scheduled Action{% endblock %}
{% block content %}
<h1>Edit Scheduled Action for "{{ media.title or media.filename }}"</h1>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label for="action_type">Action Type:</label>
<select name="action_type" required>
<option value="set_public" {% if scheduled_action.action_type == 'set_public' %}selected{% endif %}>Set Public</option>
<option value="set_private" {% if scheduled_action.action_type == 'set_private' %}selected{% endif %}>Set Private</option>
<option value="set_unlisted" {% if scheduled_action.action_type == 'set_unlisted' %}selected{% endif %}>Set Unlisted</option>
<option value="delete" {% if scheduled_action.action_type == 'delete' %}selected{% endif %}>Delete</option>
</select><br><br>
<label for="scheduled_date">Scheduled Date & Time:</label>
<input type="datetime-local" name="scheduled_date"
value="{{ scheduled_action.scheduled_date.strftime('%Y-%m-%dT%H:%M') }}"
required><br><br>
<button type="submit">Update Scheduled Action</button>
</form>
<p><a href="{{ request.route_url('view_media_details', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Back to Media Details</a></p>
{% endblock %}

View file

@ -1,3 +1,4 @@
<!-- home.html.j2 -->
{% extends 'base.html.j2' %}
{% block title %}Home{% endblock %}
@ -11,37 +12,122 @@
Welcome, Guest!
{% endif %}
</p>
<p>This application allows environments and agents to centrally manage and share files within namespaces. You can create namespaces to organize files and control access.</p>
<section>
<h2>🚀 What is PyraFiles?</h2>
<p>PyraFiles is a powerful, multi-tenant file sharing and management platform designed for teams, organizations, and automated systems. Built with security and flexibility in mind, it provides a comprehensive solution for managing digital assets across different environments.</p>
</section>
{% if request.user and request.user.is_verified %}
<h2>Your Namespaces</h2>
{% if user_namespaces %}
<section>
<h2>Your Namespaces</h2>
{% if user_namespaces %}
<ul>
{% for ns in user_namespaces %}
<li>
<a href="{{ request.route_url('list_media', namespace_short_id=ns.short_id) }}">{{ ns.name }}</a> ({{ ns.role }})
{% if ns.role == "owner" %}
| <a href="{{ request.route_url('manage_namespace', namespace_short_id=ns.short_id) }}">Manage</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>You are not a member of any namespaces. <a href="{{ request.route_url('create_namespace') }}">Create your first namespace</a> to get started!</p>
{% endif %}
</section>
{% endif %}
<section>
<h2>Public Namespaces</h2>
{% if public_namespaces %}
<ul>
{% for ns in user_namespaces %}
{% for ns in public_namespaces %}
<li>
<a href="{{ request.route_url('list_media', namespace_short_id=ns.short_id) }}">{{ ns.name }}</a> ({{ ns.role }})
{% if ns.role == "owner" %}
| <a href="{{ request.route_url('manage_namespace', namespace_short_id=ns.short_id) }}">Manage</a>
{% endif %}
<a href="{{ request.route_url('list_media', namespace_short_id=ns.short_id) }}">{{ ns.name }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>You are not a member of any namespaces.</p>
<p>No public namespaces available.</p>
{% endif %}
</section>
{% if not request.user or not request.user.is_verified %}
<section>
<h2>Get Started</h2>
<p><a href="{{ request.route_url('login') }}">Login or create an account</a> to start managing your files with PyraFiles!</p>
</section>
{% endif %}
<h2>Public Namespaces</h2>
{% if public_namespaces %}
<section>
<h2>✨ Key Features</h2>
<h3>🔐 Advanced Authentication & Authorization</h3>
<ul>
{% for ns in public_namespaces %}
<li>
<a href="{{ request.route_url('list_media', namespace_short_id=ns.short_id) }}">{{ ns.name }}</a>
</li>
{% endfor %}
<li><strong>Email-based authentication</strong> with secure 6-digit verification codes</li>
<li><strong>Dual access modes:</strong> Human users via web interface and automated agents via JWT tokens</li>
<li><strong>Role-based permissions:</strong> Owner, Editor, and Reader roles with granular access control</li>
<li><strong>Namespace isolation:</strong> Complete separation of data between different organizations</li>
</ul>
{% else %}
<p>No public namespaces available.</p>
{% endif %}
<h3>📁 Flexible File Management</h3>
<ul>
<li><strong>Multi-format support:</strong> Images, audio, video, documents, and text files up to 310MB</li>
<li><strong>Three visibility modes:</strong>
<ul>
<li><em>Public:</em> Visible in listings and accessible to all</li>
<li><em>Private:</em> Only accessible to namespace members</li>
<li><em>Unlisted:</em> Accessible via direct link but hidden from listings</li>
</ul>
</li>
<li><strong>Built-in text editor:</strong> Edit ASCII files (JSON, TXT, YAML, etc.) directly in the browser</li>
<li><strong>File previews:</strong> View first 100 lines of text files inline</li>
</ul>
<h3>⏰ Scheduled Actions</h3>
<ul>
<li><strong>Automated visibility changes:</strong> Schedule files to become public, private, or unlisted</li>
<li><strong>Automated deletion:</strong> Set expiration dates for temporary files</li>
<li><strong>Background processing:</strong> Reliable scheduler ensures actions execute on time</li>
<li><strong>Audit trail:</strong> Track all scheduled actions and their status</li>
</ul>
<h3>🤖 Agent & API Support</h3>
<ul>
<li><strong>JWT authentication:</strong> Secure token-based access for automated systems</li>
<li><strong>Agent management:</strong> Create, revoke, and manage API access per namespace</li>
<li><strong>RESTful API:</strong> Full programmatic access to all file operations</li>
<li><strong>CORS support:</strong> Cross-origin requests enabled for web applications</li>
</ul>
<h3>👥 Team Collaboration</h3>
<ul>
<li><strong>User invitations:</strong> Invite team members via email with specific roles</li>
<li><strong>Namespace sharing:</strong> Control who can view, edit, or manage your files</li>
<li><strong>Public namespaces:</strong> Share collections openly while maintaining private spaces</li>
<li><strong>Gravatar integration:</strong> Personalized user profiles</li>
</ul>
<h3>🛡️ Security & Reliability</h3>
<ul>
<li><strong>Secure file storage:</strong> Base64 encoding with SQLite backend</li>
<li><strong>CSRF protection:</strong> Built-in protection against cross-site request forgery</li>
<li><strong>Session management:</strong> Secure, long-lived sessions with proper timeouts</li>
<li><strong>Input validation:</strong> Comprehensive validation of all user inputs</li>
<li><strong>Error handling:</strong> Graceful error handling with user-friendly messages</li>
</ul>
</section>
<section>
<h2>🎯 Perfect For</h2>
<ul>
<li><strong>Development Teams:</strong> Share code snippets, documentation, and assets</li>
<li><strong>Content Creators:</strong> Manage media files with scheduled publishing</li>
<li><strong>Automated Systems:</strong> Integrate file storage into your applications via API</li>
<li><strong>Organizations:</strong> Secure file sharing with proper access controls</li>
<li><strong>Temporary Sharing:</strong> Share files with automatic expiration</li>
</ul>
</section>
{% endblock %}

View file

@ -1,3 +1,4 @@
<!-- upload_media.html.j2 -->
{% extends 'base.html.j2' %}
{% block title %}Upload Media{% endblock %}
@ -6,16 +7,43 @@
<h1>Upload Media to Namespace '{{ namespace.name }}'</h1>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label for="media_file">Media File:</label>
<input type="file" name="media_file" accept="image/*,audio/*,video/*" required><br><br>
<input type="file" name="media_file" accept="image/*,audio/*,video/*,text/*,application/pdf,application/json,application/zip" required><br><br>
<label for="title">Title (optional):</label>
<input type="text" name="title"><br><br>
<label>
<input type="checkbox" name="is_public" checked>
Make Media Public
</label><br><br>
<fieldset>
<legend>Visibility</legend>
<label>
<input type="radio" name="visibility" value="public" checked>
Public - Visible in listings and accessible to all
</label><br>
<label>
<input type="radio" name="visibility" value="private">
Private - Only accessible to namespace members
</label><br>
<label>
<input type="radio" name="visibility" value="unlisted">
Unlisted - Accessible via direct link but hidden from listings
</label>
</fieldset><br>
<fieldset>
<legend>Scheduled Actions (Optional)</legend>
<label for="scheduled_public">Schedule to become Public:</label>
<input type="datetime-local" name="scheduled_public"><br><br>
<label for="scheduled_private">Schedule to become Private:</label>
<input type="datetime-local" name="scheduled_private"><br><br>
<label for="scheduled_unlisted">Schedule to become Unlisted:</label>
<input type="datetime-local" name="scheduled_unlisted"><br><br>
<label for="scheduled_delete">Schedule for Deletion:</label>
<input type="datetime-local" name="scheduled_delete"><br><br>
</fieldset>
<button type="submit">Upload</button>
</form>

View file

@ -1,3 +1,4 @@
<!-- view_media_details.html.j2 -->
{% extends 'base.html.j2' %}
{% block title %}Media Details{% endblock %}
@ -9,7 +10,39 @@
<p><strong>Type:</strong> {{ media.media_type }}</p>
<p><strong>Size:</strong> {{ media.size|filesizeformat }}</p>
<p><strong>Uploaded:</strong> {{ media.upload_date.strftime('%Y-%m-%d %H:%M:%S') }}</p>
<p><strong>Public:</strong> {{ 'Yes' if media.is_public else 'No' }}</p>
<p><strong>Visibility:</strong> {{ media.visibility|title }}</p>
{% if scheduled_actions %}
<h2>Scheduled Actions</h2>
<ul>
{% for action in scheduled_actions %}
<li>
{{ action.action_type.replace('set_', '').replace('_', ' ')|title }} - {{ action.scheduled_date.strftime('%Y-%m-%d %H:%M:%S') }} ({{ action.status }})
{% if is_owner_or_editor %}
| <a href="{{ request.route_url('edit_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id, action_id=action.id) }}">Edit</a>
| <form method="POST" action="{{ request.route_url('delete_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id, action_id=action.id) }}" class="inline-form" onsubmit="return confirm('Delete this scheduled action?');">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<button type="submit" class="link-button">Delete</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% if is_owner_or_editor %}
<p><a href="{{ request.route_url('add_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Add Scheduled Action</a></p>
{% endif %}
{% else %}
{% if is_owner_or_editor %}
<h2>Scheduled Actions</h2>
<p>No scheduled actions for this media.</p>
<p><a href="{{ request.route_url('add_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Add Scheduled Action</a></p>
{% endif %}
{% endif %}
{% if ascii_preview %}
<h2>Text Preview (First 100 lines)</h2>
<pre style="background: #f5f5f5; padding: 1em; border-radius: 4px; overflow-x: auto;">{{ ascii_preview }}</pre>
{% endif %}
<h2>Preview</h2>
{% if media.media_type == 'image' %}
@ -30,7 +63,8 @@
<h2>Actions</h2>
<p>
<a href="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}?download=true">Download</a>
<a href="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">View</a>
| <a href="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}?download=true">Download</a>
{% if is_owner_or_editor %}
| <a href="{{ request.route_url('edit_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Edit</a>
| <form method="POST" action="{{ request.route_url('delete_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}" class="inline-form" onsubmit="return confirm('Are you sure you want to delete this media?');">