Introduced support for scheduling actions
This commit is contained in:
parent
cddee2604f
commit
4d899d367f
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 os
|
||||||
import base64
|
import base64
|
||||||
|
|
@ -15,6 +15,10 @@ import mimetypes
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
from pyramid.config import Configurator
|
from pyramid.config import Configurator
|
||||||
|
|
@ -33,6 +37,7 @@ from sqlalchemy import (
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Text,
|
Text,
|
||||||
or_,
|
or_,
|
||||||
|
inspect,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import (
|
from sqlalchemy.orm import (
|
||||||
declarative_base,
|
declarative_base,
|
||||||
|
|
@ -181,6 +186,19 @@ def get_mime_type(filename):
|
||||||
return mime_type
|
return mime_type
|
||||||
|
|
||||||
|
|
||||||
|
def is_ascii_file(mime_type):
|
||||||
|
"""Check if the file is ASCII-based (text files)."""
|
||||||
|
ascii_types = [
|
||||||
|
"text/",
|
||||||
|
"application/json",
|
||||||
|
"application/xml",
|
||||||
|
"application/javascript",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/yaml",
|
||||||
|
]
|
||||||
|
return any(mime_type.startswith(t) for t in ascii_types)
|
||||||
|
|
||||||
|
|
||||||
def uuid_to_short_id(u):
|
def uuid_to_short_id(u):
|
||||||
"""Encode UUID to a URL-safe base64 string without padding."""
|
"""Encode UUID to a URL-safe base64 string without padding."""
|
||||||
return base64.urlsafe_b64encode(u.bytes).decode("ascii").rstrip("=")
|
return base64.urlsafe_b64encode(u.bytes).decode("ascii").rstrip("=")
|
||||||
|
|
@ -351,6 +369,15 @@ class Agent(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# New table for scheduler lock
|
||||||
|
class SchedulerLock(Base):
|
||||||
|
__tablename__ = "scheduler_lock"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
locked = Column(Boolean, default=False)
|
||||||
|
locked_at = Column(DateTime, nullable=True)
|
||||||
|
process_id = Column(String, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Namespace Database Models
|
# Namespace Database Models
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
@ -365,15 +392,33 @@ class Media(NamespaceBase):
|
||||||
filename = Column(String, nullable=False)
|
filename = Column(String, nullable=False)
|
||||||
title = Column(String, nullable=True) # Optional title
|
title = Column(String, nullable=True) # Optional title
|
||||||
media_type = Column(String, nullable=False) # 'image', 'audio', 'video'
|
media_type = Column(String, nullable=False) # 'image', 'audio', 'video'
|
||||||
|
mime_type = Column(String, nullable=True) # MIME type for compatibility
|
||||||
media_b64 = Column(Text, nullable=False)
|
media_b64 = Column(Text, nullable=False)
|
||||||
upload_date = Column(DateTime, default=datetime.datetime.utcnow)
|
upload_date = Column(DateTime, default=datetime.datetime.utcnow)
|
||||||
is_public = Column(Boolean, default=True)
|
visibility = Column(String, default="public") # 'public', 'private', 'unlisted'
|
||||||
size = Column(Integer, nullable=False) # Size in bytes
|
size = Column(Integer, nullable=False) # Size in bytes
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Media(id='{self.id}', filename='{self.filename}')>"
|
return f"<Media(id='{self.id}', filename='{self.filename}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledAction(NamespaceBase):
|
||||||
|
__tablename__ = "scheduled_actions"
|
||||||
|
id = Column(String, primary_key=True) # UUID
|
||||||
|
media_id = Column(String, ForeignKey("media.id"), nullable=False)
|
||||||
|
action_type = Column(
|
||||||
|
String, nullable=False
|
||||||
|
) # 'set_public', 'set_private', 'set_unlisted', 'delete'
|
||||||
|
scheduled_date = Column(DateTime, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||||
|
status = Column(String, default="pending") # 'pending', 'completed', 'failed'
|
||||||
|
|
||||||
|
media = relationship("Media")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ScheduledAction(media_id='{self.media_id}', action='{self.action_type}', date='{self.scheduled_date}')>"
|
||||||
|
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Jinja2 Environment and Custom Filters
|
# Jinja2 Environment and Custom Filters
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
@ -572,6 +617,208 @@ def reader_required(view_func):
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Scheduler Functions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_scheduler_lock(dbsession):
|
||||||
|
"""Acquire the scheduler lock."""
|
||||||
|
process_id = f"{os.getpid()}_{int(time.time())}"
|
||||||
|
|
||||||
|
# Try to get existing lock
|
||||||
|
lock = dbsession.query(SchedulerLock).first()
|
||||||
|
|
||||||
|
if not lock:
|
||||||
|
# Create new lock
|
||||||
|
lock = SchedulerLock(
|
||||||
|
locked=True, locked_at=datetime.datetime.utcnow(), process_id=process_id
|
||||||
|
)
|
||||||
|
dbsession.add(lock)
|
||||||
|
dbsession.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if lock is stale (older than 5 minutes)
|
||||||
|
if lock.locked and lock.locked_at:
|
||||||
|
if datetime.datetime.utcnow() - lock.locked_at > datetime.timedelta(minutes=5):
|
||||||
|
log.warning("Detected stale scheduler lock, releasing it")
|
||||||
|
lock.locked = False
|
||||||
|
lock.locked_at = None
|
||||||
|
lock.process_id = None
|
||||||
|
dbsession.commit()
|
||||||
|
|
||||||
|
if not lock.locked:
|
||||||
|
lock.locked = True
|
||||||
|
lock.locked_at = datetime.datetime.utcnow()
|
||||||
|
lock.process_id = process_id
|
||||||
|
dbsession.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def release_scheduler_lock(dbsession):
|
||||||
|
"""Release the scheduler lock."""
|
||||||
|
lock = dbsession.query(SchedulerLock).first()
|
||||||
|
if lock and lock.locked:
|
||||||
|
lock.locked = False
|
||||||
|
lock.locked_at = None
|
||||||
|
lock.process_id = None
|
||||||
|
dbsession.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def process_scheduled_actions():
|
||||||
|
"""Process all scheduled actions across all namespaces."""
|
||||||
|
main_engine = create_engine(
|
||||||
|
DB_URL,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
MainSessionFactory = sessionmaker(bind=main_engine)
|
||||||
|
main_dbsession = MainSessionFactory()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not acquire_scheduler_lock(main_dbsession):
|
||||||
|
return # Another process is already running
|
||||||
|
|
||||||
|
log.info("Scheduler acquired lock, processing scheduled actions...")
|
||||||
|
|
||||||
|
# Get all namespaces
|
||||||
|
namespaces = main_dbsession.query(Namespace).all()
|
||||||
|
|
||||||
|
for namespace in namespaces:
|
||||||
|
process_namespace_scheduled_actions(namespace.id)
|
||||||
|
|
||||||
|
log.info("Finished processing scheduled actions")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error in scheduler: {e}")
|
||||||
|
finally:
|
||||||
|
release_scheduler_lock(main_dbsession)
|
||||||
|
main_dbsession.close()
|
||||||
|
main_engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def process_namespace_scheduled_actions(namespace_id):
|
||||||
|
"""Process scheduled actions for a specific namespace."""
|
||||||
|
namespace_db_url = get_namespace_db_url(namespace_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
namespace_engine = create_engine(
|
||||||
|
namespace_db_url,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
NamespaceSessionFactory = sessionmaker(bind=namespace_engine)
|
||||||
|
namespace_dbsession = NamespaceSessionFactory()
|
||||||
|
|
||||||
|
# Check if scheduled_actions table exists
|
||||||
|
inspector = inspect(namespace_engine)
|
||||||
|
if "scheduled_actions" not in inspector.get_table_names():
|
||||||
|
log.debug(
|
||||||
|
f"No scheduled_actions table in namespace {namespace_id}, skipping"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get pending actions that are due
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
pending_actions = (
|
||||||
|
namespace_dbsession.query(ScheduledAction)
|
||||||
|
.filter(
|
||||||
|
ScheduledAction.status == "pending",
|
||||||
|
ScheduledAction.scheduled_date <= now,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for action in pending_actions:
|
||||||
|
try:
|
||||||
|
media = (
|
||||||
|
namespace_dbsession.query(Media)
|
||||||
|
.filter_by(id=action.media_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not media:
|
||||||
|
action.status = "failed"
|
||||||
|
continue
|
||||||
|
|
||||||
|
if action.action_type == "delete":
|
||||||
|
namespace_dbsession.delete(media)
|
||||||
|
log.info(f"Scheduled delete executed for media {media.filename}")
|
||||||
|
elif action.action_type == "set_public":
|
||||||
|
media.visibility = "public"
|
||||||
|
log.info(
|
||||||
|
f"Scheduled set_public executed for media {media.filename}"
|
||||||
|
)
|
||||||
|
elif action.action_type == "set_private":
|
||||||
|
media.visibility = "private"
|
||||||
|
log.info(
|
||||||
|
f"Scheduled set_private executed for media {media.filename}"
|
||||||
|
)
|
||||||
|
elif action.action_type == "set_unlisted":
|
||||||
|
media.visibility = "unlisted"
|
||||||
|
log.info(
|
||||||
|
f"Scheduled set_unlisted executed for media {media.filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
action.status = "completed"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error processing scheduled action {action.id}: {e}")
|
||||||
|
action.status = "failed"
|
||||||
|
|
||||||
|
namespace_dbsession.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error processing namespace {namespace_id}: {e}")
|
||||||
|
finally:
|
||||||
|
if "namespace_dbsession" in locals():
|
||||||
|
namespace_dbsession.close()
|
||||||
|
if "namespace_engine" in locals():
|
||||||
|
namespace_engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# Global variables for scheduler thread
|
||||||
|
scheduler_thread = None
|
||||||
|
scheduler_running = False
|
||||||
|
|
||||||
|
|
||||||
|
def scheduler_worker():
|
||||||
|
"""Background scheduler worker that runs every minute."""
|
||||||
|
global scheduler_running
|
||||||
|
while scheduler_running:
|
||||||
|
try:
|
||||||
|
process_scheduled_actions()
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Scheduler worker error: {e}")
|
||||||
|
|
||||||
|
# Sleep for 60 seconds
|
||||||
|
for i in range(60):
|
||||||
|
if not scheduler_running:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
"""Start the background scheduler."""
|
||||||
|
global scheduler_thread, scheduler_running
|
||||||
|
if not scheduler_running:
|
||||||
|
scheduler_running = True
|
||||||
|
scheduler_thread = threading.Thread(target=scheduler_worker, daemon=True)
|
||||||
|
scheduler_thread.start()
|
||||||
|
log.info("Scheduler started")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler():
|
||||||
|
"""Stop the background scheduler."""
|
||||||
|
global scheduler_thread, scheduler_running
|
||||||
|
if scheduler_running:
|
||||||
|
scheduler_running = False
|
||||||
|
if scheduler_thread:
|
||||||
|
scheduler_thread.join(timeout=5)
|
||||||
|
log.info("Scheduler stopped")
|
||||||
|
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Routes and Views
|
# Routes and Views
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
@ -1202,19 +1449,93 @@ def upload_media_post_view(request):
|
||||||
media_id = str(media_uuid)
|
media_id = str(media_uuid)
|
||||||
media_short_id = uuid_to_short_id(media_uuid)
|
media_short_id = uuid_to_short_id(media_uuid)
|
||||||
|
|
||||||
is_public = request.POST.get("is_public") == "on"
|
visibility = request.POST.get("visibility", "public")
|
||||||
|
if visibility not in ["public", "private", "unlisted"]:
|
||||||
|
visibility = "public"
|
||||||
|
|
||||||
media = Media(
|
media = Media(
|
||||||
id=media_id,
|
id=media_id,
|
||||||
short_id=media_short_id,
|
short_id=media_short_id,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
title=title, # now has a fallback of filename
|
title=title,
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
|
mime_type=mime_type,
|
||||||
media_b64=encoded_str,
|
media_b64=encoded_str,
|
||||||
is_public=is_public,
|
visibility=visibility,
|
||||||
size=file_size,
|
size=file_size,
|
||||||
)
|
)
|
||||||
namespace_dbsession.add(media)
|
namespace_dbsession.add(media)
|
||||||
|
|
||||||
|
# Handle scheduled actions
|
||||||
|
scheduled_public = request.POST.get("scheduled_public")
|
||||||
|
scheduled_private = request.POST.get("scheduled_private")
|
||||||
|
scheduled_unlisted = request.POST.get("scheduled_unlisted")
|
||||||
|
scheduled_delete = request.POST.get("scheduled_delete")
|
||||||
|
|
||||||
|
# Check if scheduled_actions table exists before adding scheduled actions
|
||||||
|
try:
|
||||||
|
inspector = inspect(namespace_dbsession.bind)
|
||||||
|
has_scheduled_actions = "scheduled_actions" in inspector.get_table_names()
|
||||||
|
except:
|
||||||
|
has_scheduled_actions = False
|
||||||
|
|
||||||
|
if has_scheduled_actions:
|
||||||
|
if scheduled_public:
|
||||||
|
try:
|
||||||
|
scheduled_date = datetime.datetime.fromisoformat(scheduled_public)
|
||||||
|
action_uuid = uuid.uuid4()
|
||||||
|
action = ScheduledAction(
|
||||||
|
id=str(action_uuid),
|
||||||
|
media_id=media_id,
|
||||||
|
action_type="set_public",
|
||||||
|
scheduled_date=scheduled_date,
|
||||||
|
)
|
||||||
|
namespace_dbsession.add(action)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if scheduled_private:
|
||||||
|
try:
|
||||||
|
scheduled_date = datetime.datetime.fromisoformat(scheduled_private)
|
||||||
|
action_uuid = uuid.uuid4()
|
||||||
|
action = ScheduledAction(
|
||||||
|
id=str(action_uuid),
|
||||||
|
media_id=media_id,
|
||||||
|
action_type="set_private",
|
||||||
|
scheduled_date=scheduled_date,
|
||||||
|
)
|
||||||
|
namespace_dbsession.add(action)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if scheduled_unlisted:
|
||||||
|
try:
|
||||||
|
scheduled_date = datetime.datetime.fromisoformat(scheduled_unlisted)
|
||||||
|
action_uuid = uuid.uuid4()
|
||||||
|
action = ScheduledAction(
|
||||||
|
id=str(action_uuid),
|
||||||
|
media_id=media_id,
|
||||||
|
action_type="set_unlisted",
|
||||||
|
scheduled_date=scheduled_date,
|
||||||
|
)
|
||||||
|
namespace_dbsession.add(action)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if scheduled_delete:
|
||||||
|
try:
|
||||||
|
scheduled_date = datetime.datetime.fromisoformat(scheduled_delete)
|
||||||
|
action_uuid = uuid.uuid4()
|
||||||
|
action = ScheduledAction(
|
||||||
|
id=str(action_uuid),
|
||||||
|
media_id=media_id,
|
||||||
|
action_type="delete",
|
||||||
|
scheduled_date=scheduled_date,
|
||||||
|
)
|
||||||
|
namespace_dbsession.add(action)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
namespace_dbsession.flush()
|
namespace_dbsession.flush()
|
||||||
|
|
||||||
return HTTPFound(
|
return HTTPFound(
|
||||||
|
|
@ -1236,9 +1557,8 @@ def list_media_view(request):
|
||||||
return HTTPForbidden("You do not have access to this namespace.")
|
return HTTPForbidden("You do not have access to this namespace.")
|
||||||
|
|
||||||
# Get the media items, excluding the media_b64 column
|
# Get the media items, excluding the media_b64 column
|
||||||
media_items = (
|
# Only show public media unless user has editor+ access
|
||||||
namespace_dbsession.query(Media)
|
query = namespace_dbsession.query(Media).options(
|
||||||
.options(
|
|
||||||
load_only(
|
load_only(
|
||||||
Media.id,
|
Media.id,
|
||||||
Media.short_id,
|
Media.short_id,
|
||||||
|
|
@ -1246,10 +1566,24 @@ def list_media_view(request):
|
||||||
Media.title,
|
Media.title,
|
||||||
Media.media_type,
|
Media.media_type,
|
||||||
Media.upload_date,
|
Media.upload_date,
|
||||||
Media.is_public,
|
Media.visibility,
|
||||||
Media.size,
|
Media.size,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter based on user permissions
|
||||||
|
user_role = get_user_or_agent_namespace_role(request)
|
||||||
|
if user_role in ["owner", "editor"]:
|
||||||
|
# Owners and editors can see all media except unlisted
|
||||||
|
media_items = (
|
||||||
|
query.filter(Media.visibility != "unlisted")
|
||||||
|
.order_by(Media.upload_date.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Readers and public users can only see public media
|
||||||
|
media_items = (
|
||||||
|
query.filter(Media.visibility == "public")
|
||||||
.order_by(Media.upload_date.desc())
|
.order_by(Media.upload_date.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
@ -1274,15 +1608,58 @@ def view_media_details_view(request):
|
||||||
role = get_user_or_agent_namespace_role(request)
|
role = get_user_or_agent_namespace_role(request)
|
||||||
is_owner_or_editor = role in ["owner", "editor"]
|
is_owner_or_editor = role in ["owner", "editor"]
|
||||||
|
|
||||||
# Check if media is public or user has access
|
# Check visibility permissions
|
||||||
if not media.is_public and not check_namespace_permission(request, "reader"):
|
if media.visibility == "private" and not check_namespace_permission(
|
||||||
|
request, "reader"
|
||||||
|
):
|
||||||
return Response("Media not available.", status=403)
|
return Response("Media not available.", status=403)
|
||||||
|
elif media.visibility == "unlisted" and not is_owner_or_editor:
|
||||||
|
return Response("Media not available.", status=403)
|
||||||
|
elif media.visibility == "public":
|
||||||
|
# Public media is always accessible
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check if this is an ASCII file and get preview content
|
||||||
|
ascii_preview = None
|
||||||
|
mime_type = get_mime_type(media.filename)
|
||||||
|
if is_ascii_file(mime_type):
|
||||||
|
try:
|
||||||
|
raw_content = base64.b64decode(media.media_b64).decode("utf-8")
|
||||||
|
lines = raw_content.split("\n")
|
||||||
|
if len(lines) > 100:
|
||||||
|
ascii_preview = "\n".join(lines[:100]) + "\n... (truncated)"
|
||||||
|
else:
|
||||||
|
ascii_preview = raw_content
|
||||||
|
except (UnicodeDecodeError, Exception):
|
||||||
|
ascii_preview = None
|
||||||
|
|
||||||
|
# Get scheduled actions for this media
|
||||||
|
scheduled_actions = []
|
||||||
|
if is_owner_or_editor:
|
||||||
|
try:
|
||||||
|
# Check if scheduled_actions table exists
|
||||||
|
inspector = inspect(namespace_dbsession.bind)
|
||||||
|
if "scheduled_actions" in inspector.get_table_names():
|
||||||
|
scheduled_actions = (
|
||||||
|
namespace_dbsession.query(ScheduledAction)
|
||||||
|
.filter(
|
||||||
|
ScheduledAction.media_id == media.id,
|
||||||
|
ScheduledAction.status == "pending",
|
||||||
|
)
|
||||||
|
.order_by(ScheduledAction.scheduled_date)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(f"Could not query scheduled actions: {e}")
|
||||||
|
scheduled_actions = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"request": request,
|
"request": request,
|
||||||
"media": media,
|
"media": media,
|
||||||
"namespace": namespace,
|
"namespace": namespace,
|
||||||
"is_owner_or_editor": is_owner_or_editor,
|
"is_owner_or_editor": is_owner_or_editor,
|
||||||
|
"ascii_preview": ascii_preview,
|
||||||
|
"scheduled_actions": scheduled_actions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1297,6 +1674,20 @@ def delete_media_view(request):
|
||||||
if not media:
|
if not media:
|
||||||
return Response("Media not found.", status=404)
|
return Response("Media not found.", status=404)
|
||||||
|
|
||||||
|
# Delete associated scheduled actions if table exists
|
||||||
|
try:
|
||||||
|
inspector = inspect(namespace_dbsession.bind)
|
||||||
|
if "scheduled_actions" in inspector.get_table_names():
|
||||||
|
scheduled_actions = (
|
||||||
|
namespace_dbsession.query(ScheduledAction)
|
||||||
|
.filter_by(media_id=media.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for action in scheduled_actions:
|
||||||
|
namespace_dbsession.delete(action)
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(f"Could not delete scheduled actions: {e}")
|
||||||
|
|
||||||
namespace_dbsession.delete(media)
|
namespace_dbsession.delete(media)
|
||||||
namespace_dbsession.flush()
|
namespace_dbsession.flush()
|
||||||
|
|
||||||
|
|
@ -1318,10 +1709,20 @@ def edit_media_get_view(request):
|
||||||
if not media:
|
if not media:
|
||||||
return Response("Media not found.", status=404)
|
return Response("Media not found.", status=404)
|
||||||
|
|
||||||
|
# Check if this is an ASCII file and get editable content
|
||||||
|
ascii_content = None
|
||||||
|
mime_type = get_mime_type(media.filename)
|
||||||
|
if is_ascii_file(mime_type):
|
||||||
|
try:
|
||||||
|
ascii_content = base64.b64decode(media.media_b64).decode("utf-8")
|
||||||
|
except (UnicodeDecodeError, Exception):
|
||||||
|
ascii_content = None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"request": request,
|
"request": request,
|
||||||
"media": media,
|
"media": media,
|
||||||
"namespace": namespace,
|
"namespace": namespace,
|
||||||
|
"ascii_content": ascii_content,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1340,6 +1741,20 @@ def edit_media_post_view(request):
|
||||||
new_title = request.POST.get("title", "").strip()
|
new_title = request.POST.get("title", "").strip()
|
||||||
media.title = new_title
|
media.title = new_title
|
||||||
|
|
||||||
|
# Handle ASCII content update
|
||||||
|
ascii_content = request.POST.get("ascii_content", "").strip()
|
||||||
|
mime_type = get_mime_type(media.filename)
|
||||||
|
if ascii_content and is_ascii_file(mime_type):
|
||||||
|
try:
|
||||||
|
# Update the media content with the new ASCII content
|
||||||
|
encoded_str = base64.b64encode(ascii_content.encode("utf-8")).decode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
media.media_b64 = encoded_str
|
||||||
|
media.size = len(ascii_content.encode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
return Response(f"Error updating content: {e}", status=400)
|
||||||
|
|
||||||
# Handle media file update
|
# Handle media file update
|
||||||
new_media_file = request.POST.get("media_file")
|
new_media_file = request.POST.get("media_file")
|
||||||
if new_media_file is not None and getattr(new_media_file, "filename", "").strip():
|
if new_media_file is not None and getattr(new_media_file, "filename", "").strip():
|
||||||
|
|
@ -1358,12 +1773,14 @@ def edit_media_post_view(request):
|
||||||
|
|
||||||
media.filename = filename
|
media.filename = filename
|
||||||
media.media_type = media_type
|
media.media_type = media_type
|
||||||
|
media.mime_type = mime_type # Update mime_type too
|
||||||
media.media_b64 = encoded_str
|
media.media_b64 = encoded_str
|
||||||
media.size = file_size
|
media.size = file_size
|
||||||
|
|
||||||
# Handle public/private update
|
# Handle visibility update
|
||||||
is_public = request.POST.get("is_public") == "on"
|
visibility = request.POST.get("visibility", "public")
|
||||||
media.is_public = is_public
|
if visibility in ["public", "private", "unlisted"]:
|
||||||
|
media.visibility = visibility
|
||||||
|
|
||||||
namespace_dbsession.flush()
|
namespace_dbsession.flush()
|
||||||
|
|
||||||
|
|
@ -1391,9 +1808,21 @@ def view_media_view(request):
|
||||||
if not media:
|
if not media:
|
||||||
return Response("Media not found.", status=404)
|
return Response("Media not found.", status=404)
|
||||||
|
|
||||||
# Check if media is public or user has access
|
role = get_user_or_agent_namespace_role(request)
|
||||||
if not media.is_public and not check_namespace_permission(request, "reader"):
|
is_owner_or_editor = role in ["owner", "editor"]
|
||||||
|
|
||||||
|
# Check visibility permissions
|
||||||
|
if media.visibility == "private" and not check_namespace_permission(
|
||||||
|
request, "reader"
|
||||||
|
):
|
||||||
return Response("Media not available.", status=403)
|
return Response("Media not available.", status=403)
|
||||||
|
elif media.visibility == "unlisted":
|
||||||
|
# Unlisted media is accessible via direct link but not listed
|
||||||
|
if not is_owner_or_editor and not check_namespace_permission(request, "reader"):
|
||||||
|
return Response("Media not available.", status=403)
|
||||||
|
elif media.visibility == "public":
|
||||||
|
# Public media is always accessible via direct link
|
||||||
|
pass
|
||||||
|
|
||||||
media_data = base64.b64decode(media.media_b64)
|
media_data = base64.b64decode(media.media_b64)
|
||||||
mime_type = get_mime_type(media.filename)
|
mime_type = get_mime_type(media.filename)
|
||||||
|
|
@ -1423,6 +1852,248 @@ def view_media_view(request):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Scheduled Action Management
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
|
||||||
|
@view_config(route_name="delete_scheduled_action", request_method="POST")
|
||||||
|
@editor_required
|
||||||
|
def delete_scheduled_action_view(request):
|
||||||
|
namespace = request.namespace
|
||||||
|
namespace_dbsession = request.namespace_dbsession
|
||||||
|
media_short_id = request.matchdict.get("media_short_id")
|
||||||
|
action_id = request.matchdict.get("action_id")
|
||||||
|
|
||||||
|
# Verify media exists
|
||||||
|
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
|
||||||
|
if not media:
|
||||||
|
return Response("Media not found.", status=404)
|
||||||
|
|
||||||
|
# Check if scheduled_actions table exists
|
||||||
|
try:
|
||||||
|
inspector = inspect(namespace_dbsession.bind)
|
||||||
|
if "scheduled_actions" not in inspector.get_table_names():
|
||||||
|
return Response("Scheduled actions not supported.", status=404)
|
||||||
|
except:
|
||||||
|
return Response("Scheduled actions not supported.", status=404)
|
||||||
|
|
||||||
|
# Find and delete the scheduled action
|
||||||
|
scheduled_action = (
|
||||||
|
namespace_dbsession.query(ScheduledAction)
|
||||||
|
.filter(ScheduledAction.id == action_id, ScheduledAction.media_id == media.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not scheduled_action:
|
||||||
|
return Response("Scheduled action not found.", status=404)
|
||||||
|
|
||||||
|
namespace_dbsession.delete(scheduled_action)
|
||||||
|
namespace_dbsession.flush()
|
||||||
|
|
||||||
|
request.session.flash(
|
||||||
|
f"Scheduled {scheduled_action.action_type.replace('set_', '').replace('_', ' ')} action deleted."
|
||||||
|
)
|
||||||
|
|
||||||
|
return HTTPFound(
|
||||||
|
location=request.route_url(
|
||||||
|
"view_media_details",
|
||||||
|
namespace_short_id=namespace.short_id,
|
||||||
|
media_short_id=media.short_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@view_config(
|
||||||
|
route_name="edit_scheduled_action",
|
||||||
|
request_method="GET",
|
||||||
|
renderer="edit_scheduled_action.html.j2",
|
||||||
|
)
|
||||||
|
@editor_required
|
||||||
|
def edit_scheduled_action_get_view(request):
|
||||||
|
namespace = request.namespace
|
||||||
|
namespace_dbsession = request.namespace_dbsession
|
||||||
|
media_short_id = request.matchdict.get("media_short_id")
|
||||||
|
action_id = request.matchdict.get("action_id")
|
||||||
|
|
||||||
|
# Verify media exists
|
||||||
|
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
|
||||||
|
if not media:
|
||||||
|
return Response("Media not found.", status=404)
|
||||||
|
|
||||||
|
# Check if scheduled_actions table exists
|
||||||
|
try:
|
||||||
|
inspector = inspect(namespace_dbsession.bind)
|
||||||
|
if "scheduled_actions" not in inspector.get_table_names():
|
||||||
|
return Response("Scheduled actions not supported.", status=404)
|
||||||
|
except:
|
||||||
|
return Response("Scheduled actions not supported.", status=404)
|
||||||
|
|
||||||
|
# Find the scheduled action
|
||||||
|
scheduled_action = (
|
||||||
|
namespace_dbsession.query(ScheduledAction)
|
||||||
|
.filter(ScheduledAction.id == action_id, ScheduledAction.media_id == media.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not scheduled_action:
|
||||||
|
return Response("Scheduled action not found.", status=404)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"request": request,
|
||||||
|
"namespace": namespace,
|
||||||
|
"media": media,
|
||||||
|
"scheduled_action": scheduled_action,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@view_config(route_name="edit_scheduled_action", request_method="POST")
|
||||||
|
@editor_required
|
||||||
|
def edit_scheduled_action_post_view(request):
|
||||||
|
namespace = request.namespace
|
||||||
|
namespace_dbsession = request.namespace_dbsession
|
||||||
|
media_short_id = request.matchdict.get("media_short_id")
|
||||||
|
action_id = request.matchdict.get("action_id")
|
||||||
|
|
||||||
|
# Verify media exists
|
||||||
|
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
|
||||||
|
if not media:
|
||||||
|
return Response("Media not found.", status=404)
|
||||||
|
|
||||||
|
# Check if scheduled_actions table exists
|
||||||
|
try:
|
||||||
|
inspector = inspect(namespace_dbsession.bind)
|
||||||
|
if "scheduled_actions" not in inspector.get_table_names():
|
||||||
|
return Response("Scheduled actions not supported.", status=404)
|
||||||
|
except:
|
||||||
|
return Response("Scheduled actions not supported.", status=404)
|
||||||
|
|
||||||
|
# Find the scheduled action
|
||||||
|
scheduled_action = (
|
||||||
|
namespace_dbsession.query(ScheduledAction)
|
||||||
|
.filter(ScheduledAction.id == action_id, ScheduledAction.media_id == media.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not scheduled_action:
|
||||||
|
return Response("Scheduled action not found.", status=404)
|
||||||
|
|
||||||
|
# Update the scheduled action
|
||||||
|
new_action_type = request.POST.get("action_type")
|
||||||
|
new_scheduled_date = request.POST.get("scheduled_date")
|
||||||
|
|
||||||
|
if new_action_type not in ["set_public", "set_private", "set_unlisted", "delete"]:
|
||||||
|
return Response("Invalid action type.", status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheduled_date = datetime.datetime.fromisoformat(new_scheduled_date)
|
||||||
|
except ValueError:
|
||||||
|
return Response("Invalid date format.", status=400)
|
||||||
|
|
||||||
|
scheduled_action.action_type = new_action_type
|
||||||
|
scheduled_action.scheduled_date = scheduled_date
|
||||||
|
namespace_dbsession.flush()
|
||||||
|
|
||||||
|
request.session.flash(
|
||||||
|
f"Scheduled action updated to {new_action_type.replace('set_', '').replace('_', ' ')} on {scheduled_date.strftime('%Y-%m-%d %H:%M')}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return HTTPFound(
|
||||||
|
location=request.route_url(
|
||||||
|
"view_media_details",
|
||||||
|
namespace_short_id=namespace.short_id,
|
||||||
|
media_short_id=media.short_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@view_config(
|
||||||
|
route_name="add_scheduled_action",
|
||||||
|
request_method="GET",
|
||||||
|
renderer="add_scheduled_action.html.j2",
|
||||||
|
)
|
||||||
|
@editor_required
|
||||||
|
def add_scheduled_action_get_view(request):
|
||||||
|
namespace = request.namespace
|
||||||
|
namespace_dbsession = request.namespace_dbsession
|
||||||
|
media_short_id = request.matchdict.get("media_short_id")
|
||||||
|
|
||||||
|
# Verify media exists
|
||||||
|
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
|
||||||
|
if not media:
|
||||||
|
return Response("Media not found.", status=404)
|
||||||
|
|
||||||
|
# Check if scheduled_actions table exists
|
||||||
|
try:
|
||||||
|
inspector = inspect(namespace_dbsession.bind)
|
||||||
|
if "scheduled_actions" not in inspector.get_table_names():
|
||||||
|
return Response("Scheduled actions not supported.", status=404)
|
||||||
|
except:
|
||||||
|
return Response("Scheduled actions not supported.", status=404)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"request": request,
|
||||||
|
"namespace": namespace,
|
||||||
|
"media": media,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@view_config(route_name="add_scheduled_action", request_method="POST")
|
||||||
|
@editor_required
|
||||||
|
def add_scheduled_action_post_view(request):
|
||||||
|
namespace = request.namespace
|
||||||
|
namespace_dbsession = request.namespace_dbsession
|
||||||
|
media_short_id = request.matchdict.get("media_short_id")
|
||||||
|
|
||||||
|
# Verify media exists
|
||||||
|
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
|
||||||
|
if not media:
|
||||||
|
return Response("Media not found.", status=404)
|
||||||
|
|
||||||
|
# Check if scheduled_actions table exists
|
||||||
|
try:
|
||||||
|
inspector = inspect(namespace_dbsession.bind)
|
||||||
|
if "scheduled_actions" not in inspector.get_table_names():
|
||||||
|
return Response("Scheduled actions not supported.", status=404)
|
||||||
|
except:
|
||||||
|
return Response("Scheduled actions not supported.", status=404)
|
||||||
|
|
||||||
|
# Get form data
|
||||||
|
action_type = request.POST.get("action_type")
|
||||||
|
scheduled_date_str = request.POST.get("scheduled_date")
|
||||||
|
|
||||||
|
if action_type not in ["set_public", "set_private", "set_unlisted", "delete"]:
|
||||||
|
return Response("Invalid action type.", status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheduled_date = datetime.datetime.fromisoformat(scheduled_date_str)
|
||||||
|
except ValueError:
|
||||||
|
return Response("Invalid date format.", status=400)
|
||||||
|
|
||||||
|
# Create new scheduled action
|
||||||
|
action_uuid = uuid.uuid4()
|
||||||
|
scheduled_action = ScheduledAction(
|
||||||
|
id=str(action_uuid),
|
||||||
|
media_id=media.id,
|
||||||
|
action_type=action_type,
|
||||||
|
scheduled_date=scheduled_date,
|
||||||
|
)
|
||||||
|
namespace_dbsession.add(scheduled_action)
|
||||||
|
namespace_dbsession.flush()
|
||||||
|
|
||||||
|
request.session.flash(
|
||||||
|
f"Scheduled {action_type.replace('set_', '').replace('_', ' ')} action added for {scheduled_date.strftime('%Y-%m-%d %H:%M')}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return HTTPFound(
|
||||||
|
location=request.route_url(
|
||||||
|
"view_media_details",
|
||||||
|
namespace_short_id=namespace.short_id,
|
||||||
|
media_short_id=media.short_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Main
|
# Main
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
@ -1471,6 +2142,9 @@ def main(*config, **settings):
|
||||||
session_factory_ = sessionmaker(bind=engine)
|
session_factory_ = sessionmaker(bind=engine)
|
||||||
Base.metadata.bind = engine
|
Base.metadata.bind = engine
|
||||||
|
|
||||||
|
# Create tables if they don't exist
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
DBSession = scoped_session(session_factory_)
|
DBSession = scoped_session(session_factory_)
|
||||||
# Register with zope.sqlalchemy
|
# Register with zope.sqlalchemy
|
||||||
register(DBSession)
|
register(DBSession)
|
||||||
|
|
@ -1539,10 +2213,40 @@ def main(*config, **settings):
|
||||||
"/namespace/{namespace_short_id}/media/{media_short_id}",
|
"/namespace/{namespace_short_id}/media/{media_short_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Scheduled Actions Management
|
||||||
|
config.add_route(
|
||||||
|
"delete_scheduled_action",
|
||||||
|
"/namespace/{namespace_short_id}/media/{media_short_id}/scheduled_action/{action_id}/delete",
|
||||||
|
)
|
||||||
|
config.add_route(
|
||||||
|
"edit_scheduled_action",
|
||||||
|
"/namespace/{namespace_short_id}/media/{media_short_id}/scheduled_action/{action_id}/edit",
|
||||||
|
)
|
||||||
|
config.add_route(
|
||||||
|
"add_scheduled_action",
|
||||||
|
"/namespace/{namespace_short_id}/media/{media_short_id}/add_scheduled_action",
|
||||||
|
)
|
||||||
|
|
||||||
config.scan()
|
config.scan()
|
||||||
|
|
||||||
|
# Start the scheduler
|
||||||
|
start_scheduler()
|
||||||
|
|
||||||
return config.make_wsgi_app()
|
return config.make_wsgi_app()
|
||||||
|
|
||||||
|
|
||||||
|
# Signal handler for graceful shutdown
|
||||||
|
def signal_handler(signum, frame):
|
||||||
|
log.info("Received shutdown signal, stopping scheduler...")
|
||||||
|
stop_scheduler()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
# Register signal handlers
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
|
||||||
# Expose the WSGI application callable for uwsgi
|
# Expose the WSGI application callable for uwsgi
|
||||||
uwsgi_app = main({})
|
uwsgi_app = main({})
|
||||||
|
|
||||||
|
|
|
||||||
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -127,6 +128,9 @@
|
||||||
<li><a href="{{ request.route_url('home') }}">Home</a></li>
|
<li><a href="{{ request.route_url('home') }}">Home</a></li>
|
||||||
{% if request.user and request.user.is_verified %}
|
{% if request.user and request.user.is_verified %}
|
||||||
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li>
|
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li>
|
||||||
|
{% if request.namespace %}
|
||||||
|
<li><a href="{{ request.route_url('manage_namespace', namespace_short_id=request.namespace.short_id) }}">Manage Namespace</a></li>
|
||||||
|
{% endif %}
|
||||||
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
|
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
|
||||||
<li>
|
<li>
|
||||||
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form">
|
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form">
|
||||||
|
|
@ -149,6 +153,9 @@
|
||||||
<li><a href="{{ request.route_url('home') }}">Home</a></li>
|
<li><a href="{{ request.route_url('home') }}">Home</a></li>
|
||||||
{% if request.user and request.user.is_verified %}
|
{% if request.user and request.user.is_verified %}
|
||||||
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li>
|
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li>
|
||||||
|
{% if request.namespace %}
|
||||||
|
<li><a href="{{ request.route_url('manage_namespace', namespace_short_id=request.namespace.short_id) }}">Manage Namespace</a></li>
|
||||||
|
{% endif %}
|
||||||
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
|
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
|
||||||
<li>
|
<li>
|
||||||
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form">
|
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<!-- edit_media.html.j2 -->
|
||||||
{% extends 'base.html.j2' %}
|
{% extends 'base.html.j2' %}
|
||||||
|
|
||||||
{% block title %}Edit Media{% endblock %}
|
{% block title %}Edit Media{% endblock %}
|
||||||
|
|
@ -6,16 +7,34 @@
|
||||||
<h1>Edit Media</h1>
|
<h1>Edit Media</h1>
|
||||||
<form method="POST" enctype="multipart/form-data">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
|
||||||
|
|
||||||
<label for="title">Title:</label>
|
<label for="title">Title:</label>
|
||||||
<input type="text" name="title" value="{{ media.title }}"><br><br>
|
<input type="text" name="title" value="{{ media.title }}"><br><br>
|
||||||
|
|
||||||
<label for="media_file">Replace Media File (optional):</label>
|
{% if ascii_content %}
|
||||||
<input type="file" name="media_file" accept="image/*,audio/*,video/*"><br><br>
|
<label for="ascii_content">Text Content:</label>
|
||||||
|
<textarea name="ascii_content" rows="20" cols="80" style="font-family: monospace;">{{ ascii_content }}</textarea><br><br>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<label for="media_file">Replace Media File (optional):</label>
|
||||||
|
<input type="file" name="media_file"
|
||||||
|
accept="image/*,audio/*,video/*,text/*,application/pdf,application/json,application/zip"><br><br>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Visibility</legend>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="is_public" {% if media.is_public %}checked{% endif %}>
|
<input type="radio" name="visibility" value="public" {% if media.visibility == 'public' %}checked{% endif %}>
|
||||||
Make Media Public
|
Public - Visible in listings and accessible to all
|
||||||
</label><br><br>
|
</label><br>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="visibility" value="private" {% if media.visibility == 'private' %}checked{% endif %}>
|
||||||
|
Private - Only accessible to namespace members
|
||||||
|
</label><br>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="visibility" value="unlisted" {% if media.visibility == 'unlisted' %}checked{% endif %}>
|
||||||
|
Unlisted - Accessible via direct link but hidden from listings
|
||||||
|
</label>
|
||||||
|
</fieldset><br>
|
||||||
|
|
||||||
<button type="submit">Update Media</button>
|
<button type="submit">Update Media</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
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' %}
|
{% extends 'base.html.j2' %}
|
||||||
|
|
||||||
{% block title %}Home{% endblock %}
|
{% block title %}Home{% endblock %}
|
||||||
|
|
@ -11,9 +12,14 @@
|
||||||
Welcome, Guest!
|
Welcome, Guest!
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p>This application allows environments and agents to centrally manage and share files within namespaces. You can create namespaces to organize files and control access.</p>
|
|
||||||
|
<section>
|
||||||
|
<h2>🚀 What is PyraFiles?</h2>
|
||||||
|
<p>PyraFiles is a powerful, multi-tenant file sharing and management platform designed for teams, organizations, and automated systems. Built with security and flexibility in mind, it provides a comprehensive solution for managing digital assets across different environments.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% if request.user and request.user.is_verified %}
|
{% if request.user and request.user.is_verified %}
|
||||||
|
<section>
|
||||||
<h2>Your Namespaces</h2>
|
<h2>Your Namespaces</h2>
|
||||||
{% if user_namespaces %}
|
{% if user_namespaces %}
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -27,12 +33,14 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>You are not a member of any namespaces.</p>
|
<p>You are not a member of any namespaces. <a href="{{ request.route_url('create_namespace') }}">Create your first namespace</a> to get started!</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h2>Public Namespaces</h2>
|
<section>
|
||||||
{% if public_namespaces %}
|
<h2>Public Namespaces</h2>
|
||||||
|
{% if public_namespaces %}
|
||||||
<ul>
|
<ul>
|
||||||
{% for ns in public_namespaces %}
|
{% for ns in public_namespaces %}
|
||||||
<li>
|
<li>
|
||||||
|
|
@ -40,8 +48,86 @@
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>No public namespaces available.</p>
|
<p>No public namespaces available.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if not request.user or not request.user.is_verified %}
|
||||||
|
<section>
|
||||||
|
<h2>Get Started</h2>
|
||||||
|
<p><a href="{{ request.route_url('login') }}">Login or create an account</a> to start managing your files with PyraFiles!</p>
|
||||||
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>✨ Key Features</h2>
|
||||||
|
|
||||||
|
<h3>🔐 Advanced Authentication & Authorization</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Email-based authentication</strong> with secure 6-digit verification codes</li>
|
||||||
|
<li><strong>Dual access modes:</strong> Human users via web interface and automated agents via JWT tokens</li>
|
||||||
|
<li><strong>Role-based permissions:</strong> Owner, Editor, and Reader roles with granular access control</li>
|
||||||
|
<li><strong>Namespace isolation:</strong> Complete separation of data between different organizations</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>📁 Flexible File Management</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Multi-format support:</strong> Images, audio, video, documents, and text files up to 310MB</li>
|
||||||
|
<li><strong>Three visibility modes:</strong>
|
||||||
|
<ul>
|
||||||
|
<li><em>Public:</em> Visible in listings and accessible to all</li>
|
||||||
|
<li><em>Private:</em> Only accessible to namespace members</li>
|
||||||
|
<li><em>Unlisted:</em> Accessible via direct link but hidden from listings</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Built-in text editor:</strong> Edit ASCII files (JSON, TXT, YAML, etc.) directly in the browser</li>
|
||||||
|
<li><strong>File previews:</strong> View first 100 lines of text files inline</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>⏰ Scheduled Actions</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Automated visibility changes:</strong> Schedule files to become public, private, or unlisted</li>
|
||||||
|
<li><strong>Automated deletion:</strong> Set expiration dates for temporary files</li>
|
||||||
|
<li><strong>Background processing:</strong> Reliable scheduler ensures actions execute on time</li>
|
||||||
|
<li><strong>Audit trail:</strong> Track all scheduled actions and their status</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>🤖 Agent & API Support</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>JWT authentication:</strong> Secure token-based access for automated systems</li>
|
||||||
|
<li><strong>Agent management:</strong> Create, revoke, and manage API access per namespace</li>
|
||||||
|
<li><strong>RESTful API:</strong> Full programmatic access to all file operations</li>
|
||||||
|
<li><strong>CORS support:</strong> Cross-origin requests enabled for web applications</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>👥 Team Collaboration</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>User invitations:</strong> Invite team members via email with specific roles</li>
|
||||||
|
<li><strong>Namespace sharing:</strong> Control who can view, edit, or manage your files</li>
|
||||||
|
<li><strong>Public namespaces:</strong> Share collections openly while maintaining private spaces</li>
|
||||||
|
<li><strong>Gravatar integration:</strong> Personalized user profiles</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>🛡️ Security & Reliability</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Secure file storage:</strong> Base64 encoding with SQLite backend</li>
|
||||||
|
<li><strong>CSRF protection:</strong> Built-in protection against cross-site request forgery</li>
|
||||||
|
<li><strong>Session management:</strong> Secure, long-lived sessions with proper timeouts</li>
|
||||||
|
<li><strong>Input validation:</strong> Comprehensive validation of all user inputs</li>
|
||||||
|
<li><strong>Error handling:</strong> Graceful error handling with user-friendly messages</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>🎯 Perfect For</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Development Teams:</strong> Share code snippets, documentation, and assets</li>
|
||||||
|
<li><strong>Content Creators:</strong> Manage media files with scheduled publishing</li>
|
||||||
|
<li><strong>Automated Systems:</strong> Integrate file storage into your applications via API</li>
|
||||||
|
<li><strong>Organizations:</strong> Secure file sharing with proper access controls</li>
|
||||||
|
<li><strong>Temporary Sharing:</strong> Share files with automatic expiration</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<!-- upload_media.html.j2 -->
|
||||||
{% extends 'base.html.j2' %}
|
{% extends 'base.html.j2' %}
|
||||||
|
|
||||||
{% block title %}Upload Media{% endblock %}
|
{% block title %}Upload Media{% endblock %}
|
||||||
|
|
@ -6,16 +7,43 @@
|
||||||
<h1>Upload Media to Namespace '{{ namespace.name }}'</h1>
|
<h1>Upload Media to Namespace '{{ namespace.name }}'</h1>
|
||||||
<form method="POST" enctype="multipart/form-data">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
|
||||||
|
|
||||||
<label for="media_file">Media File:</label>
|
<label for="media_file">Media File:</label>
|
||||||
<input type="file" name="media_file" accept="image/*,audio/*,video/*" required><br><br>
|
<input type="file" name="media_file" accept="image/*,audio/*,video/*,text/*,application/pdf,application/json,application/zip" required><br><br>
|
||||||
|
|
||||||
<label for="title">Title (optional):</label>
|
<label for="title">Title (optional):</label>
|
||||||
<input type="text" name="title"><br><br>
|
<input type="text" name="title"><br><br>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Visibility</legend>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="is_public" checked>
|
<input type="radio" name="visibility" value="public" checked>
|
||||||
Make Media Public
|
Public - Visible in listings and accessible to all
|
||||||
</label><br><br>
|
</label><br>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="visibility" value="private">
|
||||||
|
Private - Only accessible to namespace members
|
||||||
|
</label><br>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="visibility" value="unlisted">
|
||||||
|
Unlisted - Accessible via direct link but hidden from listings
|
||||||
|
</label>
|
||||||
|
</fieldset><br>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Scheduled Actions (Optional)</legend>
|
||||||
|
<label for="scheduled_public">Schedule to become Public:</label>
|
||||||
|
<input type="datetime-local" name="scheduled_public"><br><br>
|
||||||
|
|
||||||
|
<label for="scheduled_private">Schedule to become Private:</label>
|
||||||
|
<input type="datetime-local" name="scheduled_private"><br><br>
|
||||||
|
|
||||||
|
<label for="scheduled_unlisted">Schedule to become Unlisted:</label>
|
||||||
|
<input type="datetime-local" name="scheduled_unlisted"><br><br>
|
||||||
|
|
||||||
|
<label for="scheduled_delete">Schedule for Deletion:</label>
|
||||||
|
<input type="datetime-local" name="scheduled_delete"><br><br>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<button type="submit">Upload</button>
|
<button type="submit">Upload</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<!-- view_media_details.html.j2 -->
|
||||||
{% extends 'base.html.j2' %}
|
{% extends 'base.html.j2' %}
|
||||||
|
|
||||||
{% block title %}Media Details{% endblock %}
|
{% block title %}Media Details{% endblock %}
|
||||||
|
|
@ -9,7 +10,39 @@
|
||||||
<p><strong>Type:</strong> {{ media.media_type }}</p>
|
<p><strong>Type:</strong> {{ media.media_type }}</p>
|
||||||
<p><strong>Size:</strong> {{ media.size|filesizeformat }}</p>
|
<p><strong>Size:</strong> {{ media.size|filesizeformat }}</p>
|
||||||
<p><strong>Uploaded:</strong> {{ media.upload_date.strftime('%Y-%m-%d %H:%M:%S') }}</p>
|
<p><strong>Uploaded:</strong> {{ media.upload_date.strftime('%Y-%m-%d %H:%M:%S') }}</p>
|
||||||
<p><strong>Public:</strong> {{ 'Yes' if media.is_public else 'No' }}</p>
|
<p><strong>Visibility:</strong> {{ media.visibility|title }}</p>
|
||||||
|
|
||||||
|
{% if scheduled_actions %}
|
||||||
|
<h2>Scheduled Actions</h2>
|
||||||
|
<ul>
|
||||||
|
{% for action in scheduled_actions %}
|
||||||
|
<li>
|
||||||
|
{{ action.action_type.replace('set_', '').replace('_', ' ')|title }} - {{ action.scheduled_date.strftime('%Y-%m-%d %H:%M:%S') }} ({{ action.status }})
|
||||||
|
{% if is_owner_or_editor %}
|
||||||
|
| <a href="{{ request.route_url('edit_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id, action_id=action.id) }}">Edit</a>
|
||||||
|
| <form method="POST" action="{{ request.route_url('delete_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id, action_id=action.id) }}" class="inline-form" onsubmit="return confirm('Delete this scheduled action?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
|
||||||
|
<button type="submit" class="link-button">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if is_owner_or_editor %}
|
||||||
|
<p><a href="{{ request.route_url('add_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Add Scheduled Action</a></p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if is_owner_or_editor %}
|
||||||
|
<h2>Scheduled Actions</h2>
|
||||||
|
<p>No scheduled actions for this media.</p>
|
||||||
|
<p><a href="{{ request.route_url('add_scheduled_action', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Add Scheduled Action</a></p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if ascii_preview %}
|
||||||
|
<h2>Text Preview (First 100 lines)</h2>
|
||||||
|
<pre style="background: #f5f5f5; padding: 1em; border-radius: 4px; overflow-x: auto;">{{ ascii_preview }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h2>Preview</h2>
|
<h2>Preview</h2>
|
||||||
{% if media.media_type == 'image' %}
|
{% if media.media_type == 'image' %}
|
||||||
|
|
@ -30,7 +63,8 @@
|
||||||
|
|
||||||
<h2>Actions</h2>
|
<h2>Actions</h2>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}?download=true">Download</a>
|
<a href="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">View</a>
|
||||||
|
| <a href="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}?download=true">Download</a>
|
||||||
{% if is_owner_or_editor %}
|
{% if is_owner_or_editor %}
|
||||||
| <a href="{{ request.route_url('edit_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Edit</a>
|
| <a href="{{ request.route_url('edit_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Edit</a>
|
||||||
| <form method="POST" action="{{ request.route_url('delete_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}" class="inline-form" onsubmit="return confirm('Are you sure you want to delete this media?');">
|
| <form method="POST" action="{{ request.route_url('delete_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}" class="inline-form" onsubmit="return confirm('Are you sure you want to delete this media?');">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue