diff --git a/app.py b/app.py
index faaa4ce..da5e29a 100644
--- a/app.py
+++ b/app.py
@@ -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""
+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""
+
+
################################################################################
# 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({})
diff --git a/migration_scripts/01_visibility_column.py b/migration_scripts/01_visibility_column.py
new file mode 100644
index 0000000..c33a494
--- /dev/null
+++ b/migration_scripts/01_visibility_column.py
@@ -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()
diff --git a/templates/add_scheduled_action.html.j2 b/templates/add_scheduled_action.html.j2
new file mode 100644
index 0000000..4f938fe
--- /dev/null
+++ b/templates/add_scheduled_action.html.j2
@@ -0,0 +1,28 @@
+
+{% extends 'base.html.j2' %}
+
+{% block title %}Add Scheduled Action{% endblock %}
+
+{% block content %}
+Add Scheduled Action for "{{ media.title or media.filename }}"
+
+
+
+Back to Media Details
+{% endblock %}
diff --git a/templates/base.html.j2 b/templates/base.html.j2
index dc2ae5f..e9d9edb 100644
--- a/templates/base.html.j2
+++ b/templates/base.html.j2
@@ -1,3 +1,4 @@
+
@@ -127,6 +128,9 @@
Home
{% if request.user and request.user.is_verified %}
Create Namespace
+ {% if request.namespace %}
+ Manage Namespace
+ {% endif %}
Profile
-This application allows environments and agents to centrally manage and share files within namespaces. You can create namespaces to organize files and control access.
+
+
+ 🚀 What is PyraFiles?
+ 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.
+
{% if request.user and request.user.is_verified %}
- Your Namespaces
- {% if user_namespaces %}
+
+ Your Namespaces
+ {% if user_namespaces %}
+
+ {% for ns in user_namespaces %}
+
+ {{ ns.name }} ({{ ns.role }})
+ {% if ns.role == "owner" %}
+ | Manage
+ {% endif %}
+
+ {% endfor %}
+
+ {% else %}
+ You are not a member of any namespaces. Create your first namespace to get started!
+ {% endif %}
+
+{% endif %}
+
+
+ Public Namespaces
+ {% if public_namespaces %}
- {% for ns in user_namespaces %}
+ {% for ns in public_namespaces %}
- {{ ns.name }} ({{ ns.role }})
- {% if ns.role == "owner" %}
- | Manage
- {% endif %}
+ {{ ns.name }}
{% endfor %}
{% else %}
- You are not a member of any namespaces.
+ No public namespaces available.
{% endif %}
+
+
+{% if not request.user or not request.user.is_verified %}
+
{% endif %}
-Public Namespaces
-{% if public_namespaces %}
+
+ ✨ Key Features
+
+ 🔐 Advanced Authentication & Authorization
- {% for ns in public_namespaces %}
-
- {{ ns.name }}
-
- {% endfor %}
+ Email-based authentication with secure 6-digit verification codes
+ Dual access modes: Human users via web interface and automated agents via JWT tokens
+ Role-based permissions: Owner, Editor, and Reader roles with granular access control
+ Namespace isolation: Complete separation of data between different organizations
-{% else %}
- No public namespaces available.
-{% endif %}
+
+ 📁 Flexible File Management
+
+ Multi-format support: Images, audio, video, documents, and text files up to 310MB
+ Three visibility modes:
+
+ Public: Visible in listings and accessible to all
+ Private: Only accessible to namespace members
+ Unlisted: Accessible via direct link but hidden from listings
+
+
+ Built-in text editor: Edit ASCII files (JSON, TXT, YAML, etc.) directly in the browser
+ File previews: View first 100 lines of text files inline
+
+
+ ⏰ Scheduled Actions
+
+ Automated visibility changes: Schedule files to become public, private, or unlisted
+ Automated deletion: Set expiration dates for temporary files
+ Background processing: Reliable scheduler ensures actions execute on time
+ Audit trail: Track all scheduled actions and their status
+
+
+ 🤖 Agent & API Support
+
+ JWT authentication: Secure token-based access for automated systems
+ Agent management: Create, revoke, and manage API access per namespace
+ RESTful API: Full programmatic access to all file operations
+ CORS support: Cross-origin requests enabled for web applications
+
+
+ 👥 Team Collaboration
+
+ User invitations: Invite team members via email with specific roles
+ Namespace sharing: Control who can view, edit, or manage your files
+ Public namespaces: Share collections openly while maintaining private spaces
+ Gravatar integration: Personalized user profiles
+
+
+ 🛡️ Security & Reliability
+
+ Secure file storage: Base64 encoding with SQLite backend
+ CSRF protection: Built-in protection against cross-site request forgery
+ Session management: Secure, long-lived sessions with proper timeouts
+ Input validation: Comprehensive validation of all user inputs
+ Error handling: Graceful error handling with user-friendly messages
+
+
+
+
+ 🎯 Perfect For
+
+ Development Teams: Share code snippets, documentation, and assets
+ Content Creators: Manage media files with scheduled publishing
+ Automated Systems: Integrate file storage into your applications via API
+ Organizations: Secure file sharing with proper access controls
+ Temporary Sharing: Share files with automatic expiration
+
+
{% endblock %}
diff --git a/templates/upload_media.html.j2 b/templates/upload_media.html.j2
index d676d2b..36136f9 100644
--- a/templates/upload_media.html.j2
+++ b/templates/upload_media.html.j2
@@ -1,3 +1,4 @@
+
{% extends 'base.html.j2' %}
{% block title %}Upload Media{% endblock %}
@@ -6,16 +7,43 @@
Upload Media to Namespace '{{ namespace.name }}'
+
Media File:
-
+
Title (optional):
-
-
- Make Media Public
-
+
+ Visibility
+
+
+ Public - Visible in listings and accessible to all
+
+
+
+ Private - Only accessible to namespace members
+
+
+
+ Unlisted - Accessible via direct link but hidden from listings
+
+
+
+
+ Scheduled Actions (Optional)
+ Schedule to become Public:
+
+
+ Schedule to become Private:
+
+
+ Schedule to become Unlisted:
+
+
+ Schedule for Deletion:
+
+
Upload
diff --git a/templates/view_media_details.html.j2 b/templates/view_media_details.html.j2
index 828ef29..da35050 100644
--- a/templates/view_media_details.html.j2
+++ b/templates/view_media_details.html.j2
@@ -1,3 +1,4 @@
+
{% extends 'base.html.j2' %}
{% block title %}Media Details{% endblock %}
@@ -9,7 +10,39 @@
Type: {{ media.media_type }}
Size: {{ media.size|filesizeformat }}
Uploaded: {{ media.upload_date.strftime('%Y-%m-%d %H:%M:%S') }}
-Public: {{ 'Yes' if media.is_public else 'No' }}
+Visibility: {{ media.visibility|title }}
+
+{% if scheduled_actions %}
+Scheduled Actions
+
+ {% for action in scheduled_actions %}
+
+ {{ action.action_type.replace('set_', '').replace('_', ' ')|title }} - {{ action.scheduled_date.strftime('%Y-%m-%d %H:%M:%S') }} ({{ action.status }})
+ {% if is_owner_or_editor %}
+ | Edit
+ |
+
+ Delete
+
+ {% endif %}
+
+ {% endfor %}
+
+{% if is_owner_or_editor %}
+ Add Scheduled Action
+{% endif %}
+{% else %}
+{% if is_owner_or_editor %}
+Scheduled Actions
+No scheduled actions for this media.
+Add Scheduled Action
+{% endif %}
+{% endif %}
+
+{% if ascii_preview %}
+Text Preview (First 100 lines)
+{{ ascii_preview }}
+{% endif %}
Preview
{% if media.media_type == 'image' %}
@@ -30,7 +63,8 @@
Actions
- Download
+ View
+ | Download
{% if is_owner_or_editor %}
| Edit
|