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

736
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 os
import base64 import base64
@ -15,6 +15,10 @@ import mimetypes
import json import json
import logging import logging
import unicodedata import unicodedata
import threading
import time
import signal
import sys
from email.mime.text import MIMEText from email.mime.text import MIMEText
from pyramid.config import Configurator from pyramid.config import Configurator
@ -33,6 +37,7 @@ from sqlalchemy import (
ForeignKey, ForeignKey,
Text, Text,
or_, or_,
inspect,
) )
from sqlalchemy.orm import ( from sqlalchemy.orm import (
declarative_base, declarative_base,
@ -181,6 +186,19 @@ def get_mime_type(filename):
return mime_type 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): def uuid_to_short_id(u):
"""Encode UUID to a URL-safe base64 string without padding.""" """Encode UUID to a URL-safe base64 string without padding."""
return base64.urlsafe_b64encode(u.bytes).decode("ascii").rstrip("=") 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 # Namespace Database Models
################################################################################ ################################################################################
@ -365,15 +392,33 @@ class Media(NamespaceBase):
filename = Column(String, nullable=False) filename = Column(String, nullable=False)
title = Column(String, nullable=True) # Optional title title = Column(String, nullable=True) # Optional title
media_type = Column(String, nullable=False) # 'image', 'audio', 'video' 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) media_b64 = Column(Text, nullable=False)
upload_date = Column(DateTime, default=datetime.datetime.utcnow) 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 size = Column(Integer, nullable=False) # Size in bytes
def __repr__(self): def __repr__(self):
return f"<Media(id='{self.id}', filename='{self.filename}')>" 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 # Jinja2 Environment and Custom Filters
################################################################################ ################################################################################
@ -572,6 +617,208 @@ def reader_required(view_func):
return wrapper 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 # Routes and Views
################################################################################ ################################################################################
@ -1202,19 +1449,93 @@ def upload_media_post_view(request):
media_id = str(media_uuid) media_id = str(media_uuid)
media_short_id = uuid_to_short_id(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( media = Media(
id=media_id, id=media_id,
short_id=media_short_id, short_id=media_short_id,
filename=filename, filename=filename,
title=title, # now has a fallback of filename title=title,
media_type=media_type, media_type=media_type,
mime_type=mime_type,
media_b64=encoded_str, media_b64=encoded_str,
is_public=is_public, visibility=visibility,
size=file_size, size=file_size,
) )
namespace_dbsession.add(media) 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() namespace_dbsession.flush()
return HTTPFound( return HTTPFound(
@ -1236,9 +1557,8 @@ def list_media_view(request):
return HTTPForbidden("You do not have access to this namespace.") return HTTPForbidden("You do not have access to this namespace.")
# Get the media items, excluding the media_b64 column # Get the media items, excluding the media_b64 column
media_items = ( # Only show public media unless user has editor+ access
namespace_dbsession.query(Media) query = namespace_dbsession.query(Media).options(
.options(
load_only( load_only(
Media.id, Media.id,
Media.short_id, Media.short_id,
@ -1246,10 +1566,24 @@ def list_media_view(request):
Media.title, Media.title,
Media.media_type, Media.media_type,
Media.upload_date, Media.upload_date,
Media.is_public, Media.visibility,
Media.size, Media.size,
) )
) )
# 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()) .order_by(Media.upload_date.desc())
.all() .all()
) )
@ -1274,15 +1608,58 @@ def view_media_details_view(request):
role = get_user_or_agent_namespace_role(request) role = get_user_or_agent_namespace_role(request)
is_owner_or_editor = role in ["owner", "editor"] is_owner_or_editor = role in ["owner", "editor"]
# Check if media is public or user has access # Check visibility permissions
if not media.is_public and not check_namespace_permission(request, "reader"): if media.visibility == "private" and not check_namespace_permission(
request, "reader"
):
return Response("Media not available.", status=403) 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 { return {
"request": request, "request": request,
"media": media, "media": media,
"namespace": namespace, "namespace": namespace,
"is_owner_or_editor": is_owner_or_editor, "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: if not media:
return Response("Media not found.", status=404) 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.delete(media)
namespace_dbsession.flush() namespace_dbsession.flush()
@ -1318,10 +1709,20 @@ def edit_media_get_view(request):
if not media: if not media:
return Response("Media not found.", status=404) 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 { return {
"request": request, "request": request,
"media": media, "media": media,
"namespace": namespace, "namespace": namespace,
"ascii_content": ascii_content,
} }
@ -1340,6 +1741,20 @@ def edit_media_post_view(request):
new_title = request.POST.get("title", "").strip() new_title = request.POST.get("title", "").strip()
media.title = new_title 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 # Handle media file update
new_media_file = request.POST.get("media_file") new_media_file = request.POST.get("media_file")
if new_media_file is not None and getattr(new_media_file, "filename", "").strip(): 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.filename = filename
media.media_type = media_type media.media_type = media_type
media.mime_type = mime_type # Update mime_type too
media.media_b64 = encoded_str media.media_b64 = encoded_str
media.size = file_size media.size = file_size
# Handle public/private update # Handle visibility update
is_public = request.POST.get("is_public") == "on" visibility = request.POST.get("visibility", "public")
media.is_public = is_public if visibility in ["public", "private", "unlisted"]:
media.visibility = visibility
namespace_dbsession.flush() namespace_dbsession.flush()
@ -1391,9 +1808,21 @@ def view_media_view(request):
if not media: if not media:
return Response("Media not found.", status=404) return Response("Media not found.", status=404)
# Check if media is public or user has access role = get_user_or_agent_namespace_role(request)
if not media.is_public and not check_namespace_permission(request, "reader"): 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) 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) media_data = base64.b64decode(media.media_b64)
mime_type = get_mime_type(media.filename) mime_type = get_mime_type(media.filename)
@ -1423,6 +1852,248 @@ def view_media_view(request):
return response 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 # Main
################################################################################ ################################################################################
@ -1471,6 +2142,9 @@ def main(*config, **settings):
session_factory_ = sessionmaker(bind=engine) session_factory_ = sessionmaker(bind=engine)
Base.metadata.bind = engine Base.metadata.bind = engine
# Create tables if they don't exist
Base.metadata.create_all(engine)
DBSession = scoped_session(session_factory_) DBSession = scoped_session(session_factory_)
# Register with zope.sqlalchemy # Register with zope.sqlalchemy
register(DBSession) register(DBSession)
@ -1539,10 +2213,40 @@ def main(*config, **settings):
"/namespace/{namespace_short_id}/media/{media_short_id}", "/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() config.scan()
# Start the scheduler
start_scheduler()
return config.make_wsgi_app() 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 # Expose the WSGI application callable for uwsgi
uwsgi_app = main({}) 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -127,6 +128,9 @@
<li><a href="{{ request.route_url('home') }}">Home</a></li> <li><a href="{{ request.route_url('home') }}">Home</a></li>
{% if request.user and request.user.is_verified %} {% if request.user and request.user.is_verified %}
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li> <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><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li> <li>
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form"> <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> <li><a href="{{ request.route_url('home') }}">Home</a></li>
{% if request.user and request.user.is_verified %} {% if request.user and request.user.is_verified %}
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li> <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><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li> <li>
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form"> <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' %} {% extends 'base.html.j2' %}
{% block title %}Edit Media{% endblock %} {% block title %}Edit Media{% endblock %}
@ -6,16 +7,34 @@
<h1>Edit Media</h1> <h1>Edit Media</h1>
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label for="title">Title:</label> <label for="title">Title:</label>
<input type="text" name="title" value="{{ media.title }}"><br><br> <input type="text" name="title" value="{{ media.title }}"><br><br>
<label for="media_file">Replace Media File (optional):</label> {% if ascii_content %}
<input type="file" name="media_file" accept="image/*,audio/*,video/*"><br><br> <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 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> <label>
<input type="checkbox" name="is_public" {% if media.is_public %}checked{% endif %}> <input type="radio" name="visibility" value="public" {% if media.visibility == 'public' %}checked{% endif %}>
Make Media Public Public - Visible in listings and accessible to all
</label><br><br> </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> <button type="submit">Update Media</button>
</form> </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' %} {% extends 'base.html.j2' %}
{% block title %}Home{% endblock %} {% block title %}Home{% endblock %}
@ -11,9 +12,14 @@
Welcome, Guest! Welcome, Guest!
{% endif %} {% endif %}
</p> </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 %} {% if request.user and request.user.is_verified %}
<section>
<h2>Your Namespaces</h2> <h2>Your Namespaces</h2>
{% if user_namespaces %} {% if user_namespaces %}
<ul> <ul>
@ -27,12 +33,14 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p>You are not a member of any namespaces.</p> <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 %} {% endif %}
</section>
{% endif %} {% endif %}
<h2>Public Namespaces</h2> <section>
{% if public_namespaces %} <h2>Public Namespaces</h2>
{% if public_namespaces %}
<ul> <ul>
{% for ns in public_namespaces %} {% for ns in public_namespaces %}
<li> <li>
@ -40,8 +48,86 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p>No public namespaces available.</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 %} {% endif %}
<section>
<h2>✨ Key Features</h2>
<h3>🔐 Advanced Authentication & Authorization</h3>
<ul>
<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>
<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 %} {% endblock %}

View file

@ -1,3 +1,4 @@
<!-- upload_media.html.j2 -->
{% extends 'base.html.j2' %} {% extends 'base.html.j2' %}
{% block title %}Upload Media{% endblock %} {% block title %}Upload Media{% endblock %}
@ -6,16 +7,43 @@
<h1>Upload Media to Namespace '{{ namespace.name }}'</h1> <h1>Upload Media to Namespace '{{ namespace.name }}'</h1>
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label for="media_file">Media File:</label> <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> <label for="title">Title (optional):</label>
<input type="text" name="title"><br><br> <input type="text" name="title"><br><br>
<fieldset>
<legend>Visibility</legend>
<label> <label>
<input type="checkbox" name="is_public" checked> <input type="radio" name="visibility" value="public" checked>
Make Media Public Public - Visible in listings and accessible to all
</label><br><br> </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> <button type="submit">Upload</button>
</form> </form>

View file

@ -1,3 +1,4 @@
<!-- view_media_details.html.j2 -->
{% extends 'base.html.j2' %} {% extends 'base.html.j2' %}
{% block title %}Media Details{% endblock %} {% block title %}Media Details{% endblock %}
@ -9,7 +10,39 @@
<p><strong>Type:</strong> {{ media.media_type }}</p> <p><strong>Type:</strong> {{ media.media_type }}</p>
<p><strong>Size:</strong> {{ media.size|filesizeformat }}</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>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> <h2>Preview</h2>
{% if media.media_type == 'image' %} {% if media.media_type == 'image' %}
@ -30,7 +63,8 @@
<h2>Actions</h2> <h2>Actions</h2>
<p> <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 %} {% 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> | <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?');"> | <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?');">