Merge branch 'optimize-media-list-query' into 'main'
Introduced support for scheduling actions See merge request engineering/unturf/upload.unturf.com!8
This commit is contained in:
commit
1d855a966c
9 changed files with 1238 additions and 59 deletions
736
app.py
736
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"<Media(id='{self.id}', filename='{self.filename}')>"
|
||||
|
||||
|
||||
class ScheduledAction(NamespaceBase):
|
||||
__tablename__ = "scheduled_actions"
|
||||
id = Column(String, primary_key=True) # UUID
|
||||
media_id = Column(String, ForeignKey("media.id"), nullable=False)
|
||||
action_type = Column(
|
||||
String, nullable=False
|
||||
) # 'set_public', 'set_private', 'set_unlisted', 'delete'
|
||||
scheduled_date = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
status = Column(String, default="pending") # 'pending', 'completed', 'failed'
|
||||
|
||||
media = relationship("Media")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScheduledAction(media_id='{self.media_id}', action='{self.action_type}', date='{self.scheduled_date}')>"
|
||||
|
||||
|
||||
################################################################################
|
||||
# Jinja2 Environment and Custom Filters
|
||||
################################################################################
|
||||
|
|
@ -572,6 +617,208 @@ def reader_required(view_func):
|
|||
return wrapper
|
||||
|
||||
|
||||
################################################################################
|
||||
# Scheduler Functions
|
||||
################################################################################
|
||||
|
||||
|
||||
def acquire_scheduler_lock(dbsession):
|
||||
"""Acquire the scheduler lock."""
|
||||
process_id = f"{os.getpid()}_{int(time.time())}"
|
||||
|
||||
# Try to get existing lock
|
||||
lock = dbsession.query(SchedulerLock).first()
|
||||
|
||||
if not lock:
|
||||
# Create new lock
|
||||
lock = SchedulerLock(
|
||||
locked=True, locked_at=datetime.datetime.utcnow(), process_id=process_id
|
||||
)
|
||||
dbsession.add(lock)
|
||||
dbsession.commit()
|
||||
return True
|
||||
|
||||
# Check if lock is stale (older than 5 minutes)
|
||||
if lock.locked and lock.locked_at:
|
||||
if datetime.datetime.utcnow() - lock.locked_at > datetime.timedelta(minutes=5):
|
||||
log.warning("Detected stale scheduler lock, releasing it")
|
||||
lock.locked = False
|
||||
lock.locked_at = None
|
||||
lock.process_id = None
|
||||
dbsession.commit()
|
||||
|
||||
if not lock.locked:
|
||||
lock.locked = True
|
||||
lock.locked_at = datetime.datetime.utcnow()
|
||||
lock.process_id = process_id
|
||||
dbsession.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def release_scheduler_lock(dbsession):
|
||||
"""Release the scheduler lock."""
|
||||
lock = dbsession.query(SchedulerLock).first()
|
||||
if lock and lock.locked:
|
||||
lock.locked = False
|
||||
lock.locked_at = None
|
||||
lock.process_id = None
|
||||
dbsession.commit()
|
||||
|
||||
|
||||
def process_scheduled_actions():
|
||||
"""Process all scheduled actions across all namespaces."""
|
||||
main_engine = create_engine(
|
||||
DB_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
MainSessionFactory = sessionmaker(bind=main_engine)
|
||||
main_dbsession = MainSessionFactory()
|
||||
|
||||
try:
|
||||
if not acquire_scheduler_lock(main_dbsession):
|
||||
return # Another process is already running
|
||||
|
||||
log.info("Scheduler acquired lock, processing scheduled actions...")
|
||||
|
||||
# Get all namespaces
|
||||
namespaces = main_dbsession.query(Namespace).all()
|
||||
|
||||
for namespace in namespaces:
|
||||
process_namespace_scheduled_actions(namespace.id)
|
||||
|
||||
log.info("Finished processing scheduled actions")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error in scheduler: {e}")
|
||||
finally:
|
||||
release_scheduler_lock(main_dbsession)
|
||||
main_dbsession.close()
|
||||
main_engine.dispose()
|
||||
|
||||
|
||||
def process_namespace_scheduled_actions(namespace_id):
|
||||
"""Process scheduled actions for a specific namespace."""
|
||||
namespace_db_url = get_namespace_db_url(namespace_id)
|
||||
|
||||
try:
|
||||
namespace_engine = create_engine(
|
||||
namespace_db_url,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
NamespaceSessionFactory = sessionmaker(bind=namespace_engine)
|
||||
namespace_dbsession = NamespaceSessionFactory()
|
||||
|
||||
# Check if scheduled_actions table exists
|
||||
inspector = inspect(namespace_engine)
|
||||
if "scheduled_actions" not in inspector.get_table_names():
|
||||
log.debug(
|
||||
f"No scheduled_actions table in namespace {namespace_id}, skipping"
|
||||
)
|
||||
return
|
||||
|
||||
# Get pending actions that are due
|
||||
now = datetime.datetime.utcnow()
|
||||
pending_actions = (
|
||||
namespace_dbsession.query(ScheduledAction)
|
||||
.filter(
|
||||
ScheduledAction.status == "pending",
|
||||
ScheduledAction.scheduled_date <= now,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
for action in pending_actions:
|
||||
try:
|
||||
media = (
|
||||
namespace_dbsession.query(Media)
|
||||
.filter_by(id=action.media_id)
|
||||
.first()
|
||||
)
|
||||
if not media:
|
||||
action.status = "failed"
|
||||
continue
|
||||
|
||||
if action.action_type == "delete":
|
||||
namespace_dbsession.delete(media)
|
||||
log.info(f"Scheduled delete executed for media {media.filename}")
|
||||
elif action.action_type == "set_public":
|
||||
media.visibility = "public"
|
||||
log.info(
|
||||
f"Scheduled set_public executed for media {media.filename}"
|
||||
)
|
||||
elif action.action_type == "set_private":
|
||||
media.visibility = "private"
|
||||
log.info(
|
||||
f"Scheduled set_private executed for media {media.filename}"
|
||||
)
|
||||
elif action.action_type == "set_unlisted":
|
||||
media.visibility = "unlisted"
|
||||
log.info(
|
||||
f"Scheduled set_unlisted executed for media {media.filename}"
|
||||
)
|
||||
|
||||
action.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error processing scheduled action {action.id}: {e}")
|
||||
action.status = "failed"
|
||||
|
||||
namespace_dbsession.commit()
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error processing namespace {namespace_id}: {e}")
|
||||
finally:
|
||||
if "namespace_dbsession" in locals():
|
||||
namespace_dbsession.close()
|
||||
if "namespace_engine" in locals():
|
||||
namespace_engine.dispose()
|
||||
|
||||
|
||||
# Global variables for scheduler thread
|
||||
scheduler_thread = None
|
||||
scheduler_running = False
|
||||
|
||||
|
||||
def scheduler_worker():
|
||||
"""Background scheduler worker that runs every minute."""
|
||||
global scheduler_running
|
||||
while scheduler_running:
|
||||
try:
|
||||
process_scheduled_actions()
|
||||
except Exception as e:
|
||||
log.error(f"Scheduler worker error: {e}")
|
||||
|
||||
# Sleep for 60 seconds
|
||||
for i in range(60):
|
||||
if not scheduler_running:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""Start the background scheduler."""
|
||||
global scheduler_thread, scheduler_running
|
||||
if not scheduler_running:
|
||||
scheduler_running = True
|
||||
scheduler_thread = threading.Thread(target=scheduler_worker, daemon=True)
|
||||
scheduler_thread.start()
|
||||
log.info("Scheduler started")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""Stop the background scheduler."""
|
||||
global scheduler_thread, scheduler_running
|
||||
if scheduler_running:
|
||||
scheduler_running = False
|
||||
if scheduler_thread:
|
||||
scheduler_thread.join(timeout=5)
|
||||
log.info("Scheduler stopped")
|
||||
|
||||
|
||||
################################################################################
|
||||
# Routes and Views
|
||||
################################################################################
|
||||
|
|
@ -1202,19 +1449,93 @@ def upload_media_post_view(request):
|
|||
media_id = str(media_uuid)
|
||||
media_short_id = uuid_to_short_id(media_uuid)
|
||||
|
||||
is_public = request.POST.get("is_public") == "on"
|
||||
visibility = request.POST.get("visibility", "public")
|
||||
if visibility not in ["public", "private", "unlisted"]:
|
||||
visibility = "public"
|
||||
|
||||
media = Media(
|
||||
id=media_id,
|
||||
short_id=media_short_id,
|
||||
filename=filename,
|
||||
title=title, # now has a fallback of filename
|
||||
title=title,
|
||||
media_type=media_type,
|
||||
mime_type=mime_type,
|
||||
media_b64=encoded_str,
|
||||
is_public=is_public,
|
||||
visibility=visibility,
|
||||
size=file_size,
|
||||
)
|
||||
namespace_dbsession.add(media)
|
||||
|
||||
# Handle scheduled actions
|
||||
scheduled_public = request.POST.get("scheduled_public")
|
||||
scheduled_private = request.POST.get("scheduled_private")
|
||||
scheduled_unlisted = request.POST.get("scheduled_unlisted")
|
||||
scheduled_delete = request.POST.get("scheduled_delete")
|
||||
|
||||
# Check if scheduled_actions table exists before adding scheduled actions
|
||||
try:
|
||||
inspector = inspect(namespace_dbsession.bind)
|
||||
has_scheduled_actions = "scheduled_actions" in inspector.get_table_names()
|
||||
except:
|
||||
has_scheduled_actions = False
|
||||
|
||||
if has_scheduled_actions:
|
||||
if scheduled_public:
|
||||
try:
|
||||
scheduled_date = datetime.datetime.fromisoformat(scheduled_public)
|
||||
action_uuid = uuid.uuid4()
|
||||
action = ScheduledAction(
|
||||
id=str(action_uuid),
|
||||
media_id=media_id,
|
||||
action_type="set_public",
|
||||
scheduled_date=scheduled_date,
|
||||
)
|
||||
namespace_dbsession.add(action)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if scheduled_private:
|
||||
try:
|
||||
scheduled_date = datetime.datetime.fromisoformat(scheduled_private)
|
||||
action_uuid = uuid.uuid4()
|
||||
action = ScheduledAction(
|
||||
id=str(action_uuid),
|
||||
media_id=media_id,
|
||||
action_type="set_private",
|
||||
scheduled_date=scheduled_date,
|
||||
)
|
||||
namespace_dbsession.add(action)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if scheduled_unlisted:
|
||||
try:
|
||||
scheduled_date = datetime.datetime.fromisoformat(scheduled_unlisted)
|
||||
action_uuid = uuid.uuid4()
|
||||
action = ScheduledAction(
|
||||
id=str(action_uuid),
|
||||
media_id=media_id,
|
||||
action_type="set_unlisted",
|
||||
scheduled_date=scheduled_date,
|
||||
)
|
||||
namespace_dbsession.add(action)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if scheduled_delete:
|
||||
try:
|
||||
scheduled_date = datetime.datetime.fromisoformat(scheduled_delete)
|
||||
action_uuid = uuid.uuid4()
|
||||
action = ScheduledAction(
|
||||
id=str(action_uuid),
|
||||
media_id=media_id,
|
||||
action_type="delete",
|
||||
scheduled_date=scheduled_date,
|
||||
)
|
||||
namespace_dbsession.add(action)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
namespace_dbsession.flush()
|
||||
|
||||
return HTTPFound(
|
||||
|
|
@ -1236,9 +1557,8 @@ 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(
|
||||
# Only show public media unless user has editor+ access
|
||||
query = namespace_dbsession.query(Media).options(
|
||||
load_only(
|
||||
Media.id,
|
||||
Media.short_id,
|
||||
|
|
@ -1246,10 +1566,24 @@ def list_media_view(request):
|
|||
Media.title,
|
||||
Media.media_type,
|
||||
Media.upload_date,
|
||||
Media.is_public,
|
||||
Media.visibility,
|
||||
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())
|
||||
.all()
|
||||
)
|
||||
|
|
@ -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({})
|
||||
|
||||
|
|
|
|||
243
migration_scripts/01_visibility_column.py
Normal file
243
migration_scripts/01_visibility_column.py
Normal 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()
|
||||
28
templates/add_scheduled_action.html.j2
Normal file
28
templates/add_scheduled_action.html.j2
Normal 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 %}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
<!-- base.html.j2 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
@ -127,6 +128,9 @@
|
|||
<li><a href="{{ request.route_url('home') }}">Home</a></li>
|
||||
{% if request.user and request.user.is_verified %}
|
||||
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li>
|
||||
{% if request.namespace %}
|
||||
<li><a href="{{ request.route_url('manage_namespace', namespace_short_id=request.namespace.short_id) }}">Manage Namespace</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
|
||||
<li>
|
||||
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form">
|
||||
|
|
@ -149,6 +153,9 @@
|
|||
<li><a href="{{ request.route_url('home') }}">Home</a></li>
|
||||
{% if request.user and request.user.is_verified %}
|
||||
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li>
|
||||
{% if request.namespace %}
|
||||
<li><a href="{{ request.route_url('manage_namespace', namespace_short_id=request.namespace.short_id) }}">Manage Namespace</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
|
||||
<li>
|
||||
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<!-- edit_media.html.j2 -->
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}Edit Media{% endblock %}
|
||||
|
|
@ -6,16 +7,34 @@
|
|||
<h1>Edit Media</h1>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
|
||||
|
||||
<label for="title">Title:</label>
|
||||
<input type="text" name="title" value="{{ media.title }}"><br><br>
|
||||
|
||||
<label for="media_file">Replace Media File (optional):</label>
|
||||
<input type="file" name="media_file" accept="image/*,audio/*,video/*"><br><br>
|
||||
{% if ascii_content %}
|
||||
<label for="ascii_content">Text Content:</label>
|
||||
<textarea name="ascii_content" rows="20" cols="80" style="font-family: monospace;">{{ ascii_content }}</textarea><br><br>
|
||||
{% endif %}
|
||||
|
||||
<label for="media_file">Replace Media File (optional):</label>
|
||||
<input type="file" name="media_file"
|
||||
accept="image/*,audio/*,video/*,text/*,application/pdf,application/json,application/zip"><br><br>
|
||||
|
||||
<fieldset>
|
||||
<legend>Visibility</legend>
|
||||
<label>
|
||||
<input type="checkbox" name="is_public" {% if media.is_public %}checked{% endif %}>
|
||||
Make Media Public
|
||||
</label><br><br>
|
||||
<input type="radio" name="visibility" value="public" {% if media.visibility == 'public' %}checked{% endif %}>
|
||||
Public - Visible in listings and accessible to all
|
||||
</label><br>
|
||||
<label>
|
||||
<input type="radio" name="visibility" value="private" {% if media.visibility == 'private' %}checked{% endif %}>
|
||||
Private - Only accessible to namespace members
|
||||
</label><br>
|
||||
<label>
|
||||
<input type="radio" name="visibility" value="unlisted" {% if media.visibility == 'unlisted' %}checked{% endif %}>
|
||||
Unlisted - Accessible via direct link but hidden from listings
|
||||
</label>
|
||||
</fieldset><br>
|
||||
|
||||
<button type="submit">Update Media</button>
|
||||
</form>
|
||||
|
|
|
|||
30
templates/edit_scheduled_action.html.j2
Normal file
30
templates/edit_scheduled_action.html.j2
Normal 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 %}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
<!-- home.html.j2 -->
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}Home{% endblock %}
|
||||
|
|
@ -11,9 +12,14 @@
|
|||
Welcome, Guest!
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>This application allows environments and agents to centrally manage and share files within namespaces. You can create namespaces to organize files and control access.</p>
|
||||
|
||||
<section>
|
||||
<h2>🚀 What is PyraFiles?</h2>
|
||||
<p>PyraFiles is a powerful, multi-tenant file sharing and management platform designed for teams, organizations, and automated systems. Built with security and flexibility in mind, it provides a comprehensive solution for managing digital assets across different environments.</p>
|
||||
</section>
|
||||
|
||||
{% if request.user and request.user.is_verified %}
|
||||
<section>
|
||||
<h2>Your Namespaces</h2>
|
||||
{% if user_namespaces %}
|
||||
<ul>
|
||||
|
|
@ -27,10 +33,12 @@
|
|||
{% endfor %}
|
||||
</ul>
|
||||
{% 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 %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h2>Public Namespaces</h2>
|
||||
{% if public_namespaces %}
|
||||
<ul>
|
||||
|
|
@ -43,5 +51,83 @@
|
|||
{% else %}
|
||||
<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 %}
|
||||
|
||||
<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 %}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<!-- upload_media.html.j2 -->
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}Upload Media{% endblock %}
|
||||
|
|
@ -6,16 +7,43 @@
|
|||
<h1>Upload Media to Namespace '{{ namespace.name }}'</h1>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
|
||||
|
||||
<label for="media_file">Media File:</label>
|
||||
<input type="file" name="media_file" accept="image/*,audio/*,video/*" required><br><br>
|
||||
<input type="file" name="media_file" accept="image/*,audio/*,video/*,text/*,application/pdf,application/json,application/zip" required><br><br>
|
||||
|
||||
<label for="title">Title (optional):</label>
|
||||
<input type="text" name="title"><br><br>
|
||||
|
||||
<fieldset>
|
||||
<legend>Visibility</legend>
|
||||
<label>
|
||||
<input type="checkbox" name="is_public" checked>
|
||||
Make Media Public
|
||||
</label><br><br>
|
||||
<input type="radio" name="visibility" value="public" checked>
|
||||
Public - Visible in listings and accessible to all
|
||||
</label><br>
|
||||
<label>
|
||||
<input type="radio" name="visibility" value="private">
|
||||
Private - Only accessible to namespace members
|
||||
</label><br>
|
||||
<label>
|
||||
<input type="radio" name="visibility" value="unlisted">
|
||||
Unlisted - Accessible via direct link but hidden from listings
|
||||
</label>
|
||||
</fieldset><br>
|
||||
|
||||
<fieldset>
|
||||
<legend>Scheduled Actions (Optional)</legend>
|
||||
<label for="scheduled_public">Schedule to become Public:</label>
|
||||
<input type="datetime-local" name="scheduled_public"><br><br>
|
||||
|
||||
<label for="scheduled_private">Schedule to become Private:</label>
|
||||
<input type="datetime-local" name="scheduled_private"><br><br>
|
||||
|
||||
<label for="scheduled_unlisted">Schedule to become Unlisted:</label>
|
||||
<input type="datetime-local" name="scheduled_unlisted"><br><br>
|
||||
|
||||
<label for="scheduled_delete">Schedule for Deletion:</label>
|
||||
<input type="datetime-local" name="scheduled_delete"><br><br>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<!-- view_media_details.html.j2 -->
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}Media Details{% endblock %}
|
||||
|
|
@ -9,7 +10,39 @@
|
|||
<p><strong>Type:</strong> {{ media.media_type }}</p>
|
||||
<p><strong>Size:</strong> {{ media.size|filesizeformat }}</p>
|
||||
<p><strong>Uploaded:</strong> {{ media.upload_date.strftime('%Y-%m-%d %H:%M:%S') }}</p>
|
||||
<p><strong>Public:</strong> {{ 'Yes' if media.is_public else 'No' }}</p>
|
||||
<p><strong>Visibility:</strong> {{ media.visibility|title }}</p>
|
||||
|
||||
{% if scheduled_actions %}
|
||||
<h2>Scheduled Actions</h2>
|
||||
<ul>
|
||||
{% for action in scheduled_actions %}
|
||||
<li>
|
||||
{{ action.action_type.replace('set_', '').replace('_', ' ')|title }} - {{ action.scheduled_date.strftime('%Y-%m-%d %H:%M:%S') }} ({{ action.status }})
|
||||
{% if is_owner_or_editor %}
|
||||
| <a href="{{ request.route_url('edit_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id, action_id=action.id) }}">Edit</a>
|
||||
| <form method="POST" action="{{ request.route_url('delete_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id, action_id=action.id) }}" class="inline-form" onsubmit="return confirm('Delete this scheduled action?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
|
||||
<button type="submit" class="link-button">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if is_owner_or_editor %}
|
||||
<p><a href="{{ request.route_url('add_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Add Scheduled Action</a></p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if is_owner_or_editor %}
|
||||
<h2>Scheduled Actions</h2>
|
||||
<p>No scheduled actions for this media.</p>
|
||||
<p><a href="{{ request.route_url('add_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Add Scheduled Action</a></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if ascii_preview %}
|
||||
<h2>Text Preview (First 100 lines)</h2>
|
||||
<pre style="background: #f5f5f5; padding: 1em; border-radius: 4px; overflow-x: auto;">{{ ascii_preview }}</pre>
|
||||
{% endif %}
|
||||
|
||||
<h2>Preview</h2>
|
||||
{% if media.media_type == 'image' %}
|
||||
|
|
@ -30,7 +63,8 @@
|
|||
|
||||
<h2>Actions</h2>
|
||||
<p>
|
||||
<a href="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}?download=true">Download</a>
|
||||
<a href="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">View</a>
|
||||
| <a href="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}?download=true">Download</a>
|
||||
{% if is_owner_or_editor %}
|
||||
| <a href="{{ request.route_url('edit_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Edit</a>
|
||||
| <form method="POST" action="{{ request.route_url('delete_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}" class="inline-form" onsubmit="return confirm('Are you sure you want to delete this media?');">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue