Browse Source

cleanup: remove .bak and patch artifacts from repo

master
Don Kingdon 1 week ago
parent
commit
9b6dc1fe6c
  1. 59
      app/auth/routes.py.bak.uidguard.20260413-004811
  2. 135
      app/auth/utils.py.bak.20260413-015405
  3. 130
      app/auth/utils.py.bak.20260413-021018
  4. 137
      app/auth/utils.py.bak.20260413-024827
  5. 144
      app/auth/utils.py.bak.20260413-051439
  6. 224
      app/main/routes.py
  7. 45
      app/main/routes.py.bak.20260413-015405
  8. 135
      app/main/routes.py.bak.20260413-021018
  9. 199
      app/main/routes.py.bak.20260413-024827
  10. 331
      app/main/routes.py.bak.20260413-032130
  11. 385
      app/main/routes.py.bak.20260413-051439
  12. 788
      app/main/routes.py.bak.20260413-054006
  13. 1212
      app/main/routes.py.bak.android.20260414-002002
  14. 1278
      app/main/routes.py.bak.androidactivate.20260414-023849
  15. 1268
      app/main/routes.py.bak.androidlock.20260414-014204
  16. 1529
      app/main/routes.py.bak.androidrepair.20260415-065050
  17. 1410
      app/main/routes.py.bak.androidupload.20260415-063731
  18. 1529
      app/main/routes.py.bak.androidupload.20260415-214446
  19. 1582
      app/main/routes.py.bak.androidupload_manual.20260415-231918
  20. 1273
      app/main/routes.py.bak.androiduploadlock.20260414-015331
  21. 1400
      app/main/routes.py.bak.deletefix.20260414-031044
  22. 1269
      app/main/routes.py.bak.fix_android_route.20260414-004358
  23. 1112
      app/main/routes.py.bak.folder.20260413-190117
  24. 998
      app/main/routes.py.bak.gallery.20260413-071415
  25. 1400
      app/main/routes.py.bak.gracefuldelete.20260414-025910
  26. 1386
      app/main/routes.py.bak.gracefuldelete3.20260414-032125
  27. 1386
      app/main/routes.py.bak.gracefuldelete4.20260414-032525
  28. 1529
      app/main/routes.py.bak.gracefuldelete4.20260415-065022
  29. 894
      app/main/routes.py.bak.rename.20260413-064038
  30. 894
      app/main/routes.py.bak.rename.20260413-064842
  31. 894
      app/main/routes.py.bak.rename.20260413-065605
  32. 1068
      app/main/routes.py.bak.thumb.20260413-073152
  33. 1134
      app/main/routes.py.bak.tree.20260413-200734
  34. 1038
      app/main/routes.py.bak.viewmode.1776064865
  35. 1529
      app/main/routes.py.broken.1776293208
  36. 104
      app/models/schema.sql.bak.android.20260414-002002
  37. 103
      app/models/schema.sql.bak.rename.20260413-064038
  38. 103
      app/models/schema.sql.bak.rename.20260413-064842
  39. 103
      app/models/schema.sql.bak.rename.20260413-065605
  40. 10
      app/services/video_jobs.py
  41. 218
      app/services/video_worker.py
  42. 10
      app/templates/auth/handoff_error.html.bak.20260412-235158
  43. 16
      app/templates/auth/login_required.html.bak.20260412-235158
  44. 37
      app/templates/cloud/dashboard.html.bak.20260412-235158
  45. 77
      app/templates/cloud/dashboard.html.bak.20260413-015405
  46. 124
      app/templates/cloud/dashboard.html.bak.20260413-021018
  47. 127
      app/templates/cloud/dashboard.html.bak.20260413-024827
  48. 130
      app/templates/cloud/dashboard.html.bak.20260413-032130
  49. 131
      app/templates/cloud/dashboard.html.bak.20260413-051439
  50. 133
      app/templates/cloud/dashboard.html.bak.android.20260414-002002
  51. 135
      app/templates/cloud/dashboard.html.bak.androidlock.20260414-014204
  52. 13
      app/templates/cloud/deleted_files.html
  53. 102
      app/templates/cloud/deleted_files.html.bak.20260413-054006
  54. 79
      app/templates/cloud/device_files.html
  55. 101
      app/templates/cloud/device_files.html.bak.20260413-051439
  56. 146
      app/templates/cloud/device_files.html.bak.gallery.20260413-071415
  57. 454
      app/templates/cloud/device_files.html.bak.previewfix.1776065858
  58. 126
      app/templates/cloud/device_files.html.bak.rename.20260413-064038
  59. 126
      app/templates/cloud/device_files.html.bak.rename.20260413-064842
  60. 126
      app/templates/cloud/device_files.html.bak.rename.20260413-065605
  61. 454
      app/templates/cloud/device_files.html.bak.thumb.20260413-073152
  62. 454
      app/templates/cloud/device_files.html.bak.tree.20260413-200734
  63. 89
      app/templates/cloud/device_new.html.bak.20260413-022612
  64. 324
      app/templates/cloud/image_workshop.html
  65. 96
      app/templates/cloud/upload.html.bak.20260413-210211
  66. 74
      app/templates/cloud/upload.html.bak.folder.20260413-190117
  67. 29
      app/templates/cloud/workshop.html
  68. 112
      app/templates/portal_base.html.bak.20260412-235158
  69. 170
      patch.sh
  70. 22
      patch1.sh
  71. 27
      scripts/make_test_handoff.py.bak.20260412-235158

59
app/auth/routes.py.bak.uidguard.20260413-004811

@ -1,59 +0,0 @@
from flask import Blueprint, current_app, redirect, render_template, request, session, url_for
from app.db import get_db
from .utils import ensure_user_tenant_and_devices, is_valid_signature, is_valid_timestamp
bp = Blueprint("auth", __name__, url_prefix="/auth")
@bp.route("/login-required")
def login_required_notice():
return render_template("auth/login_required.html")
@bp.route("/handoff")
def handoff():
portal_user_id = request.args.get("uid", "").strip()
email = request.args.get("email", "").strip().lower()
ts = request.args.get("ts", "").strip()
sig = request.args.get("sig", "").strip()
if not portal_user_id or not email or not ts or not sig:
return render_template("auth/handoff_error.html", message="Missing handoff parameters."), 400
if not is_valid_timestamp(ts):
return render_template("auth/handoff_error.html", message="Handoff timestamp is invalid or expired."), 403
if not is_valid_signature(email=email, ts=ts, portal_user_id=portal_user_id, sig=sig):
return render_template("auth/handoff_error.html", message="Invalid handoff signature."), 403
identity = ensure_user_tenant_and_devices(email=email, portal_user_id=int(portal_user_id))
session.clear()
session["otb_user_id"] = identity["user_id"]
session["otb_tenant_id"] = identity["tenant_id"]
session["otb_tenant_slug"] = identity["tenant_slug"]
session["otb_email"] = identity["email"]
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'handoff_login_success', %s, %s, %s)
""",
(
identity["tenant_id"],
identity["user_id"],
request.headers.get("X-Forwarded-For", request.remote_addr),
request.headers.get("User-Agent", ""),
f"Portal handoff accepted for {email}",
),
)
db.commit()
return redirect(url_for("main.dashboard"))
@bp.route("/logout")
def logout():
session.clear()
return redirect(url_for("auth.login_required_notice"))

135
app/auth/utils.py.bak.20260413-015405

@ -1,135 +0,0 @@
import hashlib
import hmac
import re
import time
from pathlib import Path
from flask import current_app
from app.db import get_db
SLUG_RE = re.compile(r"[^a-z0-9]+")
def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str:
payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8")
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool:
secret = current_app.config["OTB_PORTAL_SHARED_SECRET"]
expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret)
return hmac.compare_digest(expected, sig)
def is_valid_timestamp(ts: str) -> bool:
try:
ts_int = int(ts)
except ValueError:
return False
skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"]
return abs(int(time.time()) - ts_int) <= skew
def slugify_email(email: str) -> str:
local = email.split("@", 1)[0].lower().strip()
slug = SLUG_RE.sub("-", local).strip("-")
return slug or "tenant"
def ensure_user_tenant_and_devices(email: str, portal_user_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, portal_user_id, email, display_name
FROM users
WHERE email = %s
""",
(email,),
)
user = cur.fetchone()
if user is None:
cur.execute(
"""
INSERT INTO users (portal_user_id, email, display_name, is_active)
VALUES (%s, %s, %s, 1)
""",
(portal_user_id, email, email),
)
user_id = cur.lastrowid
else:
user_id = user["id"]
cur.execute(
"""
UPDATE users
SET portal_user_id = %s, last_login_at = NOW()
WHERE id = %s
""",
(portal_user_id, user_id),
)
cur.execute(
"""
SELECT id, slug, storage_root
FROM tenants
WHERE owner_user_id = %s
""",
(user_id,),
)
tenant = cur.fetchone()
if tenant is None:
base_slug = slugify_email(email)
slug = base_slug
suffix = 1
while True:
cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,))
existing = cur.fetchone()
if existing is None:
break
suffix += 1
slug = f"{base_slug}-{suffix}"
storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}"
cur.execute(
"""
INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode)
VALUES (%s, %s, %s, 'active', 'standard')
""",
(user_id, slug, storage_root),
)
tenant_id = cur.lastrowid
else:
tenant_id = tenant["id"]
slug = tenant["slug"]
storage_root = tenant["storage_root"]
for device_name in current_app.config["DEFAULT_DEVICE_NAMES"]:
relative_path = f"devices/{device_name}"
cur.execute(
"""
INSERT IGNORE INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(tenant_id, device_name, device_name, relative_path),
)
db.commit()
create_tenant_directories(slug, current_app.config["DEFAULT_DEVICE_NAMES"])
return {
"user_id": user_id,
"tenant_id": tenant_id,
"tenant_slug": slug,
"storage_root": storage_root,
"email": email,
}
def create_tenant_directories(tenant_slug: str, device_names: list[str]):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
(tenant_root / "logs").mkdir(parents=True, exist_ok=True)
(tenant_root / "support").mkdir(parents=True, exist_ok=True)
for device_name in device_names:
for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:
(tenant_root / "devices" / device_name / subdir).mkdir(parents=True, exist_ok=True)

130
app/auth/utils.py.bak.20260413-021018

@ -1,130 +0,0 @@
import hashlib
import hmac
import re
import time
from pathlib import Path
from flask import current_app
from app.db import get_db
SLUG_RE = re.compile(r"[^a-z0-9]+")
def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str:
payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8")
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool:
secret = current_app.config["OTB_PORTAL_SHARED_SECRET"]
expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret)
return hmac.compare_digest(expected, sig)
def is_valid_timestamp(ts: str) -> bool:
try:
ts_int = int(ts)
except ValueError:
return False
skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"]
return abs(int(time.time()) - ts_int) <= skew
def slugify_email(email: str) -> str:
local = email.split("@", 1)[0].lower().strip()
slug = SLUG_RE.sub("-", local).strip("-")
return slug or "tenant"
def slugify_device_name(name: str) -> str:
return SLUG_RE.sub("-", name.lower().strip()).strip("-") or "device"
def ensure_user_tenant_and_devices(email: str, portal_user_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, portal_user_id, email, display_name
FROM users
WHERE email = %s
""",
(email,),
)
user = cur.fetchone()
if user is None:
cur.execute(
"""
INSERT INTO users (portal_user_id, email, display_name, is_active)
VALUES (%s, %s, %s, 1)
""",
(portal_user_id, email, email),
)
user_id = cur.lastrowid
else:
user_id = user["id"]
cur.execute(
"""
UPDATE users
SET portal_user_id = %s, last_login_at = NOW()
WHERE id = %s
""",
(portal_user_id, user_id),
)
cur.execute(
"""
SELECT id, slug, storage_root
FROM tenants
WHERE owner_user_id = %s
""",
(user_id,),
)
tenant = cur.fetchone()
if tenant is None:
base_slug = slugify_email(email)
slug = base_slug
suffix = 1
while True:
cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,))
existing = cur.fetchone()
if existing is None:
break
suffix += 1
slug = f"{base_slug}-{suffix}"
storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}"
cur.execute(
"""
INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode)
VALUES (%s, %s, %s, 'active', 'standard')
""",
(user_id, slug, storage_root),
)
tenant_id = cur.lastrowid
create_tenant_root(slug)
else:
tenant_id = tenant["id"]
slug = tenant["slug"]
storage_root = tenant["storage_root"]
db.commit()
return {
"user_id": user_id,
"tenant_id": tenant_id,
"tenant_slug": slug,
"storage_root": storage_root,
"email": email,
}
def create_tenant_root(tenant_slug: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
(tenant_root / "logs").mkdir(parents=True, exist_ok=True)
(tenant_root / "support").mkdir(parents=True, exist_ok=True)
(tenant_root / "devices").mkdir(parents=True, exist_ok=True)
def create_device_directories(tenant_slug: str, device_path: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
base = tenant_root / device_path
for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:
(base / subdir).mkdir(parents=True, exist_ok=True)

137
app/auth/utils.py.bak.20260413-024827

@ -1,137 +0,0 @@
import hashlib
import hmac
import re
import shutil
import time
from pathlib import Path
from flask import current_app
from app.db import get_db
SLUG_RE = re.compile(r"[^a-z0-9]+")
def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str:
payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8")
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool:
secret = current_app.config["OTB_PORTAL_SHARED_SECRET"]
expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret)
return hmac.compare_digest(expected, sig)
def is_valid_timestamp(ts: str) -> bool:
try:
ts_int = int(ts)
except ValueError:
return False
skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"]
return abs(int(time.time()) - ts_int) <= skew
def slugify_email(email: str) -> str:
local = email.split("@", 1)[0].lower().strip()
slug = SLUG_RE.sub("-", local).strip("-")
return slug or "tenant"
def slugify_device_name(name: str) -> str:
return SLUG_RE.sub("-", name.lower().strip()).strip("-") or "device"
def ensure_user_tenant_and_devices(email: str, portal_user_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, portal_user_id, email, display_name
FROM users
WHERE email = %s
""",
(email,),
)
user = cur.fetchone()
if user is None:
cur.execute(
"""
INSERT INTO users (portal_user_id, email, display_name, is_active)
VALUES (%s, %s, %s, 1)
""",
(portal_user_id, email, email),
)
user_id = cur.lastrowid
else:
user_id = user["id"]
cur.execute(
"""
UPDATE users
SET portal_user_id = %s, last_login_at = NOW()
WHERE id = %s
""",
(portal_user_id, user_id),
)
cur.execute(
"""
SELECT id, slug, storage_root
FROM tenants
WHERE owner_user_id = %s
""",
(user_id,),
)
tenant = cur.fetchone()
if tenant is None:
base_slug = slugify_email(email)
slug = base_slug
suffix = 1
while True:
cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,))
existing = cur.fetchone()
if existing is None:
break
suffix += 1
slug = f"{base_slug}-{suffix}"
storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}"
cur.execute(
"""
INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode)
VALUES (%s, %s, %s, 'active', 'standard')
""",
(user_id, slug, storage_root),
)
tenant_id = cur.lastrowid
create_tenant_root(slug)
else:
tenant_id = tenant["id"]
slug = tenant["slug"]
storage_root = tenant["storage_root"]
db.commit()
return {
"user_id": user_id,
"tenant_id": tenant_id,
"tenant_slug": slug,
"storage_root": storage_root,
"email": email,
}
def create_tenant_root(tenant_slug: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
(tenant_root / "logs").mkdir(parents=True, exist_ok=True)
(tenant_root / "support").mkdir(parents=True, exist_ok=True)
(tenant_root / "devices").mkdir(parents=True, exist_ok=True)
def create_device_directories(tenant_slug: str, device_path: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
base = tenant_root / device_path
for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:
(base / subdir).mkdir(parents=True, exist_ok=True)
def remove_device_directories(tenant_slug: str, device_path: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
base = tenant_root / device_path
if base.exists() and base.is_dir():
shutil.rmtree(base)

144
app/auth/utils.py.bak.20260413-051439

@ -1,144 +0,0 @@
import hashlib
import hmac
import re
import shutil
import time
from pathlib import Path
from flask import current_app
from app.db import get_db
SLUG_RE = re.compile(r"[^a-z0-9]+")
def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str:
payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8")
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool:
secret = current_app.config["OTB_PORTAL_SHARED_SECRET"]
expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret)
return hmac.compare_digest(expected, sig)
def is_valid_timestamp(ts: str) -> bool:
try:
ts_int = int(ts)
except ValueError:
return False
skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"]
return abs(int(time.time()) - ts_int) <= skew
def slugify_email(email: str) -> str:
local = email.split("@", 1)[0].lower().strip()
slug = SLUG_RE.sub("-", local).strip("-")
return slug or "tenant"
def slugify_device_name(name: str) -> str:
return SLUG_RE.sub("-", name.lower().strip()).strip("-") or "device"
def ensure_user_tenant_and_devices(email: str, portal_user_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, portal_user_id, email, display_name
FROM users
WHERE email = %s
""",
(email,),
)
user = cur.fetchone()
if user is None:
cur.execute(
"""
INSERT INTO users (portal_user_id, email, display_name, is_active)
VALUES (%s, %s, %s, 1)
""",
(portal_user_id, email, email),
)
user_id = cur.lastrowid
else:
user_id = user["id"]
cur.execute(
"""
UPDATE users
SET portal_user_id = %s, last_login_at = NOW()
WHERE id = %s
""",
(portal_user_id, user_id),
)
cur.execute(
"""
SELECT id, slug, storage_root
FROM tenants
WHERE owner_user_id = %s
""",
(user_id,),
)
tenant = cur.fetchone()
if tenant is None:
base_slug = slugify_email(email)
slug = base_slug
suffix = 1
while True:
cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,))
existing = cur.fetchone()
if existing is None:
break
suffix += 1
slug = f"{base_slug}-{suffix}"
storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}"
cur.execute(
"""
INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode)
VALUES (%s, %s, %s, 'active', 'standard')
""",
(user_id, slug, storage_root),
)
tenant_id = cur.lastrowid
create_tenant_root(slug)
else:
tenant_id = tenant["id"]
slug = tenant["slug"]
storage_root = tenant["storage_root"]
db.commit()
return {
"user_id": user_id,
"tenant_id": tenant_id,
"tenant_slug": slug,
"storage_root": storage_root,
"email": email,
}
def create_tenant_root(tenant_slug: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
(tenant_root / "logs").mkdir(parents=True, exist_ok=True)
(tenant_root / "support").mkdir(parents=True, exist_ok=True)
(tenant_root / "devices").mkdir(parents=True, exist_ok=True)
def create_device_directories(tenant_slug: str, device_path: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
base = tenant_root / device_path
for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:
(base / subdir).mkdir(parents=True, exist_ok=True)
def remove_device_directories(tenant_slug: str, device_path: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
base = tenant_root / device_path
if base.exists() and base.is_dir():
shutil.rmtree(base)
def compute_sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()

224
app/main/routes.py

@ -1180,7 +1180,13 @@ def rename_file(file_id: int):
else:
flash(f"File renamed to '{display_filename}'.", "success")
return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"]))
return redirect(url_for(
"main.browse_device_files",
device_id=file_row["device_id"],
path=request.form.get("path", ""),
view=request.form.get("view", "list"),
page=request.form.get("page", "1"),
))
@bp.route("/devices/<int:device_id>/files", methods=["GET"])
@portal_session_required
@ -1192,14 +1198,30 @@ def browse_device_files(device_id: int):
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
view_mode = request.args.get("view", "list").strip().lower()
current_path = _normalize_browser_path(request.args.get("path", ""))
requested_view = request.args.get("view")
if requested_view:
view_mode = requested_view.strip().lower()
elif current_path == "images" or current_path.startswith("images/"):
view_mode = "gallery"
else:
view_mode = "list"
if view_mode not in ("list", "gallery"):
view_mode = "list"
current_path = _normalize_browser_path(request.args.get("path", ""))
root_directory = f"{device['relative_path']}/originals"
current_directory = f"{root_directory}/{current_path}" if current_path else root_directory
try:
page = max(1, int(request.args.get("page", "1")))
except Exception:
page = 1
per_page = 100
offset = (page - 1) * per_page
with db.cursor() as cur:
cur.execute(
"""
@ -1223,11 +1245,26 @@ def browse_device_files(device_id: int):
AND is_deleted = 0
AND directory_path = %s
ORDER BY uploaded_at DESC, id DESC
LIMIT %s OFFSET %s
""",
(session["otb_tenant_id"], device_id, current_directory),
(session["otb_tenant_id"], device_id, current_directory, per_page, offset),
)
files = cur.fetchall()
cur.execute(
"""
SELECT COUNT(*) AS total_files
FROM files
WHERE tenant_id = %s
AND device_id = %s
AND is_deleted = 0
AND directory_path = %s
""",
(session["otb_tenant_id"], device_id, current_directory),
)
count_row = cur.fetchone() or {"total_files": 0}
total_files = count_row["total_files"] if isinstance(count_row, dict) else count_row[0]
cur.execute(
"""
SELECT DISTINCT directory_path
@ -1314,6 +1351,11 @@ def browse_device_files(device_id: int):
device=device,
files=files,
file_count=len(files),
total_files=total_files,
page=page,
per_page=per_page,
has_prev=page > 1,
has_next=(offset + per_page) < total_files,
view_mode=view_mode,
current_path=current_path,
parent_path=parent_path,
@ -1645,7 +1687,18 @@ def lts_view():
from app.services.video_jobs import create_video_job, list_jobs_for_tenant
@bp.route("/image-workshop/<int:device_id>")
@portal_session_required
def image_workshop(device_id):
return render_template(
"cloud/image_workshop.html",
device_id=device_id,
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
@bp.route("/workshop/<int:device_id>")
@portal_session_required
def workshop(device_id):
from app.db import get_db
@ -2107,3 +2160,166 @@ def video_queue_summary():
"active_users": active_users
}
@bp.route("/api/image/process", methods=["POST"])
@portal_session_required
def image_process():
from PIL import Image, ImageOps
from pathlib import Path
from datetime import datetime
db = get_db()
data = request.get_json(silent=True) or {}
items = data.get("items") or []
state = data.get("state") or {}
if not isinstance(items, list) or not items:
return jsonify({"ok": False, "error": "no_images_selected"}), 400
if len(items) > 25:
return jsonify({"ok": False, "error": "Image Workshop is limited to 25 images per batch."}), 400
tenant_id = session.get("otb_tenant_id")
tenant_slug = session.get("otb_tenant_slug") or session.get("tenant") or "def"
processed = []
with db.cursor() as cur:
cur.execute("SELECT storage_root FROM tenants WHERE id = %s LIMIT 1", (tenant_id,))
tenant_row = cur.fetchone()
if not tenant_row:
return jsonify({"ok": False, "error": "tenant_not_found"}), 404
storage_root = Path(tenant_row["storage_root"])
for item in items:
try:
file_id = int(item.get("id"))
except Exception:
continue
with db.cursor() as cur:
cur.execute(
"""
SELECT id, tenant_id, device_id, relative_path, directory_path,
original_filename, display_filename, mime_type
FROM files
WHERE id = %s
AND tenant_id = %s
AND is_deleted = 0
AND mime_type LIKE 'image%%'
LIMIT 1
""",
(file_id, tenant_id),
)
row = cur.fetchone()
if not row:
continue
cfg = state.get(str(file_id)) or state.get(file_id) or {}
rotation = int(cfg.get("rotation") or 0)
filter_name = cfg.get("filter")
fmt = (cfg.get("format") or "").lower()
src = storage_root / row["relative_path"]
if not src.exists() or not src.is_file():
continue
out_dir_rel = row["directory_path"]
out_dir = storage_root / out_dir_rel
out_dir.mkdir(parents=True, exist_ok=True)
src_name = Path(row["original_filename"])
requested_name = (cfg.get("name") or "").strip()
if requested_name:
base = "".join(c for c in requested_name if c.isalnum() or c in ("-", "_", " ")).strip().replace(" ", "_")
else:
base = src_name.stem + "_edited"
requested_ext = fmt if fmt in ("jpg", "jpeg", "png", "webp") else src_name.suffix.lower().lstrip(".")
if requested_ext == "jpeg":
requested_ext = "jpg"
if requested_ext not in ("jpg", "png", "webp"):
requested_ext = "jpg"
stamp = datetime.utcnow().strftime("%Y%m%dT%H%M%S%fZ")
out_name = f"{base}.{requested_ext}"
if (out_dir / out_name).exists():
out_name = f"{base}_{stamp}.{requested_ext}"
out_path = out_dir / out_name
out_rel = f"{out_dir_rel}/{out_name}"
with Image.open(src) as img:
img = ImageOps.exif_transpose(img)
if rotation in (90, 180, 270):
img = img.rotate(-rotation, expand=True)
if filter_name == "bw":
img = ImageOps.grayscale(img).convert("RGB")
elif filter_name == "sepia":
gray = ImageOps.grayscale(img)
img = ImageOps.colorize(gray, "#2b1b0f", "#f2d3a3").convert("RGB")
else:
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
save_kwargs = {}
if requested_ext in ("jpg", "webp"):
if img.mode == "RGBA":
img = img.convert("RGB")
save_kwargs["quality"] = 88
if requested_ext == "jpg":
img.save(out_path, "JPEG", **save_kwargs)
mime = "image/jpeg"
elif requested_ext == "png":
img.save(out_path, "PNG")
mime = "image/png"
else:
img.save(out_path, "WEBP", **save_kwargs)
mime = "image/webp"
size_bytes = out_path.stat().st_size
sha256 = compute_sha256(out_path)
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO files (
tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path,
original_filename, display_filename, basename, extension, mime_type, size_bytes, sha256,
capture_date, uploaded_at, is_immutable, is_deleted, deleted_at
) VALUES (%s, %s, %s, 'image_processed', %s, %s,
%s, %s, %s, %s, %s, %s, %s,
NULL, UTC_TIMESTAMP(), 1, 0, NULL)
""",
(
row["tenant_id"],
row["device_id"],
row["id"],
out_rel,
out_dir_rel,
out_name,
out_name,
Path(out_name).stem,
requested_ext,
mime,
size_bytes,
sha256,
),
)
new_id = cur.lastrowid
db.commit()
processed.append({
"id": new_id,
"filename": out_name,
"relative_path": out_rel,
"size_bytes": size_bytes,
})
return jsonify({"ok": True, "processed": processed})

45
app/main/routes.py.bak.20260413-015405

@ -1,45 +0,0 @@
from functools import wraps
from flask import Blueprint, redirect, render_template, session, url_for
from app.db import get_db
bp = Blueprint("main", __name__)
def portal_session_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "otb_user_id" not in session or "otb_tenant_id" not in session:
return redirect(url_for("auth.login_required_notice"))
return view_func(*args, **kwargs)
return wrapped
@bp.route("/")
def index():
if "otb_user_id" in session:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login_required_notice"))
@bp.route("/dashboard")
@portal_session_required
def dashboard():
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path, is_active, created_at
FROM devices
WHERE tenant_id = %s
ORDER BY id
""",
(session["otb_tenant_id"],),
)
devices = cur.fetchall()
return render_template(
"cloud/dashboard.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)

135
app/main/routes.py.bak.20260413-021018

@ -1,135 +0,0 @@
from functools import wraps
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from app.db import get_db
from app.auth.utils import create_device_directories, slugify_device_name
bp = Blueprint("main", __name__)
def portal_session_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "otb_user_id" not in session or "otb_tenant_id" not in session:
return redirect(url_for("auth.login_required_notice"))
return view_func(*args, **kwargs)
return wrapped
@bp.route("/")
def index():
if "otb_user_id" in session:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login_required_notice"))
@bp.route("/dashboard")
@portal_session_required
def dashboard():
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path, is_active, created_at
FROM devices
WHERE tenant_id = %s
ORDER BY id
""",
(session["otb_tenant_id"],),
)
devices = cur.fetchall()
return render_template(
"cloud/dashboard.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)
@bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required
def add_device():
if request.method == "GET":
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
device_name = (request.form.get("device_name") or "").strip()
device_type = (request.form.get("device_type") or "").strip()
if not device_name:
flash("Device name is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
if not device_type:
flash("Device type is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
slug = slugify_device_name(device_name)
relative_path = f"devices/{slug}"
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id
FROM devices
WHERE tenant_id = %s AND device_name = %s
""",
(session["otb_tenant_id"], device_name),
)
existing = cur.fetchone()
if existing:
flash("A device with that name already exists.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
cur.execute(
"""
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(session["otb_tenant_id"], device_name, device_type, relative_path),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
request.headers.get("X-Forwarded-For", request.remote_addr),
request.headers.get("User-Agent", ""),
f"Created device '{device_name}' ({device_type}) at {relative_path}",
),
)
db.commit()
create_device_directories(session["otb_tenant_slug"], relative_path)
flash("Device added successfully.", "success")
return redirect(url_for("main.dashboard"))

199
app/main/routes.py.bak.20260413-024827

@ -1,199 +0,0 @@
from functools import wraps
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from app.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name
bp = Blueprint("main", __name__)
def portal_session_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "otb_user_id" not in session or "otb_tenant_id" not in session:
return redirect(url_for("auth.login_required_notice"))
return view_func(*args, **kwargs)
return wrapped
@bp.route("/")
def index():
if "otb_user_id" in session:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login_required_notice"))
@bp.route("/dashboard")
@portal_session_required
def dashboard():
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path, is_active, created_at
FROM devices
WHERE tenant_id = %s
ORDER BY id
""",
(session["otb_tenant_id"],),
)
devices = cur.fetchall()
return render_template(
"cloud/dashboard.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)
@bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required
def add_device():
if request.method == "GET":
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
device_name = (request.form.get("device_name") or "").strip()
device_type = (request.form.get("device_type") or "").strip()
if not device_name:
flash("Device name is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
if not device_type:
flash("Device type is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
slug = slugify_device_name(device_name)
relative_path = f"devices/{slug}"
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id
FROM devices
WHERE tenant_id = %s AND device_name = %s
""",
(session["otb_tenant_id"], device_name),
)
existing = cur.fetchone()
if existing:
flash("A device with that name already exists.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
cur.execute(
"""
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(session["otb_tenant_id"], device_name, device_type, relative_path),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
request.headers.get("X-Forwarded-For", request.remote_addr),
request.headers.get("User-Agent", ""),
f"Created device '{device_name}' ({device_type}) at {relative_path}",
),
)
db.commit()
create_device_directories(session["otb_tenant_slug"], relative_path)
flash("Device added successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/delete/<int:device_id>", methods=["POST"])
@portal_session_required
def delete_device(device_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
device = cur.fetchone()
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
SELECT COUNT(*) AS file_count
FROM files
WHERE tenant_id = %s AND device_id = %s AND is_deleted = 0
""",
(session["otb_tenant_id"], device_id),
)
file_count = cur.fetchone()["file_count"]
if file_count and int(file_count) > 0:
flash("This device cannot be removed because files are still linked to it.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
DELETE FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_deleted', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
request.headers.get("X-Forwarded-For", request.remote_addr),
request.headers.get("User-Agent", ""),
f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}",
),
)
db.commit()
remove_device_directories(session["otb_tenant_slug"], device["relative_path"])
flash("Device removed successfully.", "success")
return redirect(url_for("main.dashboard"))

331
app/main/routes.py.bak.20260413-032130

@ -1,331 +0,0 @@
from functools import wraps
from pathlib import Path
from datetime import datetime, timezone
from werkzeug.utils import secure_filename
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app
from app.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256
bp = Blueprint("main", __name__)
def portal_session_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "otb_user_id" not in session or "otb_tenant_id" not in session:
return redirect(url_for("auth.login_required_notice"))
return view_func(*args, **kwargs)
return wrapped
def _client_ip():
return request.headers.get("X-Forwarded-For", request.remote_addr)
def _tenant_root() -> Path:
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"]
def _stored_name(original_name: str) -> str:
safe = secure_filename(original_name or "upload.bin")
if not safe:
safe = "upload.bin"
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}"
@bp.route("/")
def index():
if "otb_user_id" in session:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login_required_notice"))
@bp.route("/dashboard")
@portal_session_required
def dashboard():
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path, is_active, created_at
FROM devices
WHERE tenant_id = %s
ORDER BY id
""",
(session["otb_tenant_id"],),
)
devices = cur.fetchall()
return render_template(
"cloud/dashboard.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)
@bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required
def add_device():
if request.method == "GET":
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
device_name = (request.form.get("device_name") or "").strip()
device_type = (request.form.get("device_type") or "").strip()
if not device_name:
flash("Device name is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
if not device_type:
flash("Device type is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
slug = slugify_device_name(device_name)
relative_path = f"devices/{slug}"
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id
FROM devices
WHERE tenant_id = %s AND device_name = %s
""",
(session["otb_tenant_id"], device_name),
)
existing = cur.fetchone()
if existing:
flash("A device with that name already exists.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
cur.execute(
"""
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(session["otb_tenant_id"], device_name, device_type, relative_path),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created device '{device_name}' ({device_type}) at {relative_path}",
),
)
db.commit()
create_device_directories(session["otb_tenant_slug"], relative_path)
flash("Device added successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/delete/<int:device_id>", methods=["POST"])
@portal_session_required
def delete_device(device_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
device = cur.fetchone()
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
SELECT COUNT(*) AS file_count
FROM files
WHERE tenant_id = %s AND device_id = %s AND is_deleted = 0
""",
(session["otb_tenant_id"], device_id),
)
file_count = cur.fetchone()["file_count"]
if file_count and int(file_count) > 0:
flash("This device cannot be removed because files are still linked to it.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
DELETE FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_deleted', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}",
),
)
db.commit()
remove_device_directories(session["otb_tenant_slug"], device["relative_path"])
flash("Device removed successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/<int:device_id>/upload", methods=["GET", "POST"])
@portal_session_required
def upload_files(device_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
device = cur.fetchone()
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
if request.method == "GET":
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
files = request.files.getlist("files")
files = [f for f in files if f and f.filename]
if not files:
flash("Please choose at least one file to upload.", "warning")
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
upload_base = _tenant_root() / device["relative_path"] / "originals"
upload_base.mkdir(parents=True, exist_ok=True)
uploaded_count = 0
with db.cursor() as cur:
for incoming in files:
original_filename = incoming.filename or "upload.bin"
stored_name = _stored_name(original_filename)
target_path = upload_base / stored_name
incoming.save(target_path)
size_bytes = target_path.stat().st_size
sha256 = compute_sha256(target_path)
base_name = original_filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
else:
basename, extension = base_name, ""
relative_path = f"{device['relative_path']}/originals/{stored_name}"
directory_path = f"{device['relative_path']}/originals"
cur.execute(
"""
INSERT INTO files (
tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path,
original_filename, basename, extension, mime_type, size_bytes, sha256,
capture_date, uploaded_at, is_immutable, is_deleted, deleted_at
) VALUES (
%s, %s, NULL, 'original', %s, %s,
%s, %s, %s, %s, %s, %s,
NULL, UTC_TIMESTAMP(), 1, 0, NULL
)
""",
(
session["otb_tenant_id"],
device["id"],
relative_path,
directory_path,
original_filename,
basename,
extension,
incoming.mimetype or None,
size_bytes,
sha256,
),
)
file_id = cur.lastrowid
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_uploaded', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Uploaded '{original_filename}' to {relative_path}",
),
)
uploaded_count += 1
db.commit()
flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success")
return redirect(url_for("main.dashboard"))

385
app/main/routes.py.bak.20260413-051439

@ -1,385 +0,0 @@
from functools import wraps
from pathlib import Path
from datetime import datetime, timezone
from werkzeug.utils import secure_filename
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app
from app.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256
bp = Blueprint("main", __name__)
def portal_session_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "otb_user_id" not in session or "otb_tenant_id" not in session:
return redirect(url_for("auth.login_required_notice"))
return view_func(*args, **kwargs)
return wrapped
def _client_ip():
return request.headers.get("X-Forwarded-For", request.remote_addr)
def _tenant_root() -> Path:
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"]
def _stored_name(original_name: str) -> str:
safe = secure_filename(original_name or "upload.bin")
if not safe:
safe = "upload.bin"
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}"
@bp.route("/")
def index():
if "otb_user_id" in session:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login_required_notice"))
@bp.route("/dashboard")
@portal_session_required
def dashboard():
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path, is_active, created_at
FROM devices
WHERE tenant_id = %s
ORDER BY id
""",
(session["otb_tenant_id"],),
)
devices = cur.fetchall()
return render_template(
"cloud/dashboard.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)
@bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required
def add_device():
if request.method == "GET":
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
device_name = (request.form.get("device_name") or "").strip()
device_type = (request.form.get("device_type") or "").strip()
if not device_name:
flash("Device name is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
if not device_type:
flash("Device type is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
slug = slugify_device_name(device_name)
relative_path = f"devices/{slug}"
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id
FROM devices
WHERE tenant_id = %s AND device_name = %s
""",
(session["otb_tenant_id"], device_name),
)
existing = cur.fetchone()
if existing:
flash("A device with that name already exists.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
cur.execute(
"""
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(session["otb_tenant_id"], device_name, device_type, relative_path),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created device '{device_name}' ({device_type}) at {relative_path}",
),
)
db.commit()
create_device_directories(session["otb_tenant_slug"], relative_path)
flash("Device added successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/delete/<int:device_id>", methods=["POST"])
@portal_session_required
def delete_device(device_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
device = cur.fetchone()
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
SELECT COUNT(*) AS file_count
FROM files
WHERE tenant_id = %s AND device_id = %s AND is_deleted = 0
""",
(session["otb_tenant_id"], device_id),
)
file_count = cur.fetchone()["file_count"]
if file_count and int(file_count) > 0:
flash("This device cannot be removed because files are still linked to it.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
DELETE FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_deleted', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}",
),
)
db.commit()
remove_device_directories(session["otb_tenant_slug"], device["relative_path"])
flash("Device removed successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/<int:device_id>/upload", methods=["GET", "POST"])
@portal_session_required
def upload_files(device_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
device = cur.fetchone()
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
if request.method == "GET":
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
files = request.files.getlist("files")
files = [f for f in files if f and f.filename]
if not files:
flash("Please choose at least one file to upload.", "warning")
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
upload_base = _tenant_root() / device["relative_path"] / "originals"
upload_base.mkdir(parents=True, exist_ok=True)
uploaded_count = 0
with db.cursor() as cur:
for incoming in files:
original_filename = incoming.filename or "upload.bin"
stored_name = _stored_name(original_filename)
target_path = upload_base / stored_name
incoming.save(target_path)
size_bytes = target_path.stat().st_size
sha256 = compute_sha256(target_path)
base_name = original_filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
else:
basename, extension = base_name, ""
relative_path = f"{device['relative_path']}/originals/{stored_name}"
directory_path = f"{device['relative_path']}/originals"
cur.execute(
"""
INSERT INTO files (
tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path,
original_filename, basename, extension, mime_type, size_bytes, sha256,
capture_date, uploaded_at, is_immutable, is_deleted, deleted_at
) VALUES (
%s, %s, NULL, 'original', %s, %s,
%s, %s, %s, %s, %s, %s,
NULL, UTC_TIMESTAMP(), 1, 0, NULL
)
""",
(
session["otb_tenant_id"],
device["id"],
relative_path,
directory_path,
original_filename,
basename,
extension,
incoming.mimetype or None,
size_bytes,
sha256,
),
)
file_id = cur.lastrowid
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_uploaded', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Uploaded '{original_filename}' to {relative_path}",
),
)
uploaded_count += 1
db.commit()
flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/<int:device_id>/files", methods=["GET"])
@portal_session_required
def browse_device_files(device_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
device = cur.fetchone()
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
SELECT
id,
file_kind,
relative_path,
directory_path,
original_filename,
basename,
extension,
mime_type,
size_bytes,
sha256,
uploaded_at,
is_immutable
FROM files
WHERE tenant_id = %s
AND device_id = %s
AND is_deleted = 0
ORDER BY uploaded_at DESC, id DESC
""",
(session["otb_tenant_id"], device_id),
)
files = cur.fetchall()
return render_template(
"cloud/device_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
files=files,
file_count=len(files),
)

788
app/main/routes.py.bak.20260413-054006

@ -1,788 +0,0 @@
from functools import wraps
from pathlib import Path
from datetime import datetime, timezone
import shutil
import zipfile
from werkzeug.utils import secure_filename
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file
from app.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256
bp = Blueprint("main", __name__)
def portal_session_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "otb_user_id" not in session or "otb_tenant_id" not in session:
return redirect(url_for("auth.login_required_notice"))
return view_func(*args, **kwargs)
return wrapped
def _client_ip():
return request.headers.get("X-Forwarded-For", request.remote_addr)
def _tenant_root() -> Path:
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"]
def _stored_name(original_name: str) -> str:
safe = secure_filename(original_name or "upload.bin")
if not safe:
safe = "upload.bin"
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}"
def _safe_path_from_relative(relative_path: str) -> Path:
return _tenant_root() / relative_path
def _get_device_for_tenant(db, device_id: int):
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
return cur.fetchone()
def _purge_expired_deleted_files(db):
expired = []
with db.cursor() as cur:
cur.execute(
"""
SELECT id, relative_path, original_filename
FROM files
WHERE tenant_id = %s
AND is_deleted = 1
AND deleted_at IS NOT NULL
AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR)
""",
(session["otb_tenant_id"],),
)
expired = cur.fetchall()
for row in expired:
file_path = _safe_path_from_relative(row["relative_path"])
if file_path.exists():
try:
file_path.unlink()
except FileNotFoundError:
pass
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'system', 'deleted_file_purged', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
row["id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Purged deleted file '{row['original_filename']}' after retention window",
),
)
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"]))
db.commit()
@bp.route("/")
def index():
if "otb_user_id" in session:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login_required_notice"))
@bp.route("/dashboard")
@portal_session_required
def dashboard():
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path, is_active, created_at
FROM devices
WHERE tenant_id = %s
ORDER BY id
""",
(session["otb_tenant_id"],),
)
devices = cur.fetchall()
return render_template(
"cloud/dashboard.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)
@bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required
def add_device():
if request.method == "GET":
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
device_name = (request.form.get("device_name") or "").strip()
device_type = (request.form.get("device_type") or "").strip()
if not device_name:
flash("Device name is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
if not device_type:
flash("Device type is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
slug = slugify_device_name(device_name)
relative_path = f"devices/{slug}"
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id
FROM devices
WHERE tenant_id = %s AND device_name = %s
""",
(session["otb_tenant_id"], device_name),
)
existing = cur.fetchone()
if existing:
flash("A device with that name already exists.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
cur.execute(
"""
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(session["otb_tenant_id"], device_name, device_type, relative_path),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created device '{device_name}' ({device_type}) at {relative_path}",
),
)
db.commit()
create_device_directories(session["otb_tenant_slug"], relative_path)
flash("Device added successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/delete/<int:device_id>", methods=["POST"])
@portal_session_required
def delete_device(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
with db.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) AS file_count
FROM files
WHERE tenant_id = %s AND device_id = %s AND is_deleted = 0
""",
(session["otb_tenant_id"], device_id),
)
file_count = cur.fetchone()["file_count"]
if file_count and int(file_count) > 0:
flash("This device cannot be removed because files are still linked to it.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
DELETE FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_deleted', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}",
),
)
db.commit()
remove_device_directories(session["otb_tenant_slug"], device["relative_path"])
flash("Device removed successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/<int:device_id>/upload", methods=["GET", "POST"])
@portal_session_required
def upload_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
if request.method == "GET":
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
files = request.files.getlist("files")
files = [f for f in files if f and f.filename]
if not files:
flash("Please choose at least one file to upload.", "warning")
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
upload_base = _tenant_root() / device["relative_path"] / "originals"
upload_base.mkdir(parents=True, exist_ok=True)
uploaded_count = 0
with db.cursor() as cur:
for incoming in files:
original_filename = incoming.filename or "upload.bin"
stored_name = _stored_name(original_filename)
target_path = upload_base / stored_name
incoming.save(target_path)
size_bytes = target_path.stat().st_size
sha256 = compute_sha256(target_path)
base_name = original_filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
else:
basename, extension = base_name, ""
relative_path = f"{device['relative_path']}/originals/{stored_name}"
directory_path = f"{device['relative_path']}/originals"
cur.execute(
"""
INSERT INTO files (
tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path,
original_filename, basename, extension, mime_type, size_bytes, sha256,
capture_date, uploaded_at, is_immutable, is_deleted, deleted_at
) VALUES (
%s, %s, NULL, 'original', %s, %s,
%s, %s, %s, %s, %s, %s,
NULL, UTC_TIMESTAMP(), 1, 0, NULL
)
""",
(
session["otb_tenant_id"],
device["id"],
relative_path,
directory_path,
original_filename,
basename,
extension,
incoming.mimetype or None,
size_bytes,
sha256,
),
)
file_id = cur.lastrowid
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_uploaded', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Uploaded '{original_filename}' to {relative_path}",
),
)
uploaded_count += 1
db.commit()
flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/files/<int:file_id>/download", methods=["GET"])
@portal_session_required
def download_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, relative_path
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row:
flash("File not found.", "warning")
return redirect(url_for("main.dashboard"))
file_path = _safe_path_from_relative(file_row["relative_path"])
if not file_path.exists():
flash("File is missing from storage.", "warning")
return redirect(url_for("main.dashboard"))
return send_file(file_path, as_attachment=True, download_name=file_row["original_filename"])
@bp.route("/devices/<int:device_id>/files/download-selected", methods=["POST"])
@portal_session_required
def download_selected_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
if len(selected_ids) != 1:
flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
return redirect(url_for("main.download_file", file_id=selected_ids[0]))
@bp.route("/devices/<int:device_id>/files/delete-selected", methods=["POST"])
@portal_session_required
def delete_selected_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
deleted_count = 0
with db.cursor() as cur:
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, relative_path, directory_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
(file_id, session["otb_tenant_id"], device_id),
)
file_row = cur.fetchone()
if not file_row or file_row["is_deleted"]:
continue
source_path = _safe_path_from_relative(file_row["relative_path"])
target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}"
target_path = _safe_path_from_relative(target_rel)
target_path.parent.mkdir(parents=True, exist_ok=True)
if source_path.exists():
shutil.move(str(source_path), str(target_path))
cur.execute(
"""
UPDATE files
SET relative_path = %s,
directory_path = %s,
is_deleted = 1,
deleted_at = UTC_TIMESTAMP()
WHERE id = %s AND tenant_id = %s
""",
(
target_rel,
f"{device['relative_path']}/deleted",
file_id,
session["otb_tenant_id"],
),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_soft_deleted', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Soft-deleted '{file_row['original_filename']}' into deleted area",
),
)
deleted_count += 1
db.commit()
flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/devices/<int:device_id>/files/send-to-zip", methods=["POST"])
@portal_session_required
def send_selected_to_zip_workspace(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
staging_dir = _tenant_root() / "zip_staging"
staging_dir.mkdir(parents=True, exist_ok=True)
copied_count = 0
with db.cursor() as cur:
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
(file_id, session["otb_tenant_id"], device_id),
)
file_row = cur.fetchone()
if not file_row or file_row["is_deleted"]:
continue
source_path = _safe_path_from_relative(file_row["relative_path"])
if not source_path.exists():
continue
target_name = _stored_name(file_row["original_filename"])
target_path = staging_dir / target_name
shutil.copy2(source_path, target_path)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_staged_for_zip', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Copied '{file_row['original_filename']}' into zip workspace",
),
)
copied_count += 1
db.commit()
flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/workspace/zip", methods=["GET"])
@portal_session_required
def zip_workspace():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
exports_dir = tenant_root / "exports"
staging_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
staged_files = []
for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()):
if p.is_file():
staged_files.append({
"name": p.name,
"size_bytes": p.stat().st_size,
"path": str(p.relative_to(tenant_root)),
})
export_files = []
for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file():
export_files.append({
"name": p.name,
"size_bytes": p.stat().st_size,
"path": str(p.relative_to(tenant_root)),
})
return render_template(
"cloud/zip_workspace.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
staged_files=staged_files,
export_files=export_files,
)
@bp.route("/workspace/zip/create", methods=["POST"])
@portal_session_required
def create_zip_from_workspace():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
exports_dir = tenant_root / "exports"
staging_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
staged = [p for p in staging_dir.iterdir() if p.is_file()]
if not staged:
flash("Zip Workspace is empty.", "warning")
return redirect(url_for("main.zip_workspace"))
zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip"
zip_path = exports_dir / zip_name
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in staged:
zf.write(p, arcname=p.name)
for p in staged:
p.unlink(missing_ok=True)
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success",
),
)
db.commit()
flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success")
return redirect(url_for("main.zip_workspace"))
@bp.route("/workspace/exports/<path:filename>/download", methods=["GET"])
@portal_session_required
def download_export(filename: str):
exports_dir = _tenant_root() / "exports"
file_path = exports_dir / filename
if not file_path.exists() or not file_path.is_file():
flash("Export file not found.", "warning")
return redirect(url_for("main.zip_workspace"))
return send_file(file_path, as_attachment=True, download_name=file_path.name)
@bp.route("/deleted", methods=["GET"])
@portal_session_required
def deleted_files():
db = get_db()
_purge_expired_deleted_files(db)
with db.cursor() as cur:
cur.execute(
"""
SELECT
f.id,
f.original_filename,
f.relative_path,
f.size_bytes,
f.deleted_at,
d.device_name,
d.device_type
FROM files f
LEFT JOIN devices d ON f.device_id = d.id
WHERE f.tenant_id = %s
AND f.is_deleted = 1
ORDER BY f.deleted_at DESC, f.id DESC
""",
(session["otb_tenant_id"],),
)
files = cur.fetchall()
return render_template(
"cloud/deleted_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
files=files,
)
@bp.route("/deleted/<int:file_id>/hard-delete", methods=["POST"])
@portal_session_required
def hard_delete_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row or not file_row["is_deleted"]:
flash("Deleted file not found.", "warning")
return redirect(url_for("main.deleted_files"))
file_path = _safe_path_from_relative(file_row["relative_path"])
if file_path.exists():
file_path.unlink(missing_ok=True)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_hard_deleted', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area",
),
)
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"]))
db.commit()
flash("File permanently deleted.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/devices/<int:device_id>/files", methods=["GET"])
@portal_session_required
def browse_device_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
with db.cursor() as cur:
cur.execute(
"""
SELECT
id,
file_kind,
relative_path,
directory_path,
original_filename,
basename,
extension,
mime_type,
size_bytes,
sha256,
uploaded_at,
is_immutable
FROM files
WHERE tenant_id = %s
AND device_id = %s
AND is_deleted = 0
ORDER BY uploaded_at DESC, id DESC
""",
(session["otb_tenant_id"], device_id),
)
files = cur.fetchall()
return render_template(
"cloud/device_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
files=files,
file_count=len(files),
)

1212
app/main/routes.py.bak.android.20260414-002002

File diff suppressed because it is too large Load Diff

1278
app/main/routes.py.bak.androidactivate.20260414-023849

File diff suppressed because it is too large Load Diff

1268
app/main/routes.py.bak.androidlock.20260414-014204

File diff suppressed because it is too large Load Diff

1529
app/main/routes.py.bak.androidrepair.20260415-065050

File diff suppressed because it is too large Load Diff

1410
app/main/routes.py.bak.androidupload.20260415-063731

File diff suppressed because it is too large Load Diff

1529
app/main/routes.py.bak.androidupload.20260415-214446

File diff suppressed because it is too large Load Diff

1582
app/main/routes.py.bak.androidupload_manual.20260415-231918

File diff suppressed because it is too large Load Diff

1273
app/main/routes.py.bak.androiduploadlock.20260414-015331

File diff suppressed because it is too large Load Diff

1400
app/main/routes.py.bak.deletefix.20260414-031044

File diff suppressed because it is too large Load Diff

1269
app/main/routes.py.bak.fix_android_route.20260414-004358

File diff suppressed because it is too large Load Diff

1112
app/main/routes.py.bak.folder.20260413-190117

File diff suppressed because it is too large Load Diff

998
app/main/routes.py.bak.gallery.20260413-071415

@ -1,998 +0,0 @@
from functools import wraps
from pathlib import Path
from datetime import datetime, timezone
import shutil
import zipfile
import re
from werkzeug.utils import secure_filename
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file
from app.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256
bp = Blueprint("main", __name__)
def portal_session_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "otb_user_id" not in session or "otb_tenant_id" not in session:
return redirect(url_for("auth.login_required_notice"))
return view_func(*args, **kwargs)
return wrapped
def _client_ip():
return request.headers.get("X-Forwarded-For", request.remote_addr)
def _tenant_root() -> Path:
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"]
def _stored_name(original_name: str) -> str:
safe = secure_filename(original_name or "upload.bin")
if not safe:
safe = "upload.bin"
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}"
def _recovered_filename(original_name: str) -> tuple[str, str, str]:
base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
recovered_name = f"{basename}-recovered.{extension}"
return recovered_name, f"{basename}-recovered", extension
recovered_name = f"{base_name}-recovered"
return recovered_name, f"{base_name}-recovered", ""
def _display_filename(file_row) -> str:
if not file_row:
return "download.bin"
return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip()
def _sanitize_display_basename(raw_name: str) -> str:
cleaned = (raw_name or "").replace("\x00", "").strip()
cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned)
cleaned = cleaned.strip().strip(".")
if "." in cleaned:
cleaned = cleaned.rsplit(".", 1)[0].strip()
return cleaned[:200]
def _safe_path_from_relative(relative_path: str) -> Path:
return _tenant_root() / relative_path
def _get_device_for_tenant(db, device_id: int):
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
return cur.fetchone()
def _purge_expired_deleted_files(db):
expired = []
with db.cursor() as cur:
cur.execute(
"""
SELECT id, relative_path, original_filename
FROM files
WHERE tenant_id = %s
AND is_deleted = 1
AND deleted_at IS NOT NULL
AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR)
""",
(session["otb_tenant_id"],),
)
expired = cur.fetchall()
for row in expired:
file_path = _safe_path_from_relative(row["relative_path"])
if file_path.exists():
try:
file_path.unlink()
except FileNotFoundError:
pass
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'system', 'deleted_file_purged', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
row["id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Purged deleted file '{row['original_filename']}' after retention window",
),
)
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"]))
db.commit()
@bp.route("/")
def index():
if "otb_user_id" in session:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login_required_notice"))
@bp.route("/dashboard")
@portal_session_required
def dashboard():
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path, is_active, created_at
FROM devices
WHERE tenant_id = %s
ORDER BY id
""",
(session["otb_tenant_id"],),
)
devices = cur.fetchall()
return render_template(
"cloud/dashboard.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)
@bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required
def add_device():
if request.method == "GET":
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
device_name = (request.form.get("device_name") or "").strip()
device_type = (request.form.get("device_type") or "").strip()
if not device_name:
flash("Device name is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
if not device_type:
flash("Device type is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
slug = slugify_device_name(device_name)
relative_path = f"devices/{slug}"
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id
FROM devices
WHERE tenant_id = %s AND device_name = %s
""",
(session["otb_tenant_id"], device_name),
)
existing = cur.fetchone()
if existing:
flash("A device with that name already exists.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
cur.execute(
"""
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(session["otb_tenant_id"], device_name, device_type, relative_path),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created device '{device_name}' ({device_type}) at {relative_path}",
),
)
db.commit()
create_device_directories(session["otb_tenant_slug"], relative_path)
flash("Device added successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/delete/<int:device_id>", methods=["POST"])
@portal_session_required
def delete_device(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
with db.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) AS file_count
FROM files
WHERE tenant_id = %s AND device_id = %s AND is_deleted = 0
""",
(session["otb_tenant_id"], device_id),
)
file_count = cur.fetchone()["file_count"]
if file_count and int(file_count) > 0:
flash("This device cannot be removed because files are still linked to it.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
DELETE FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_deleted', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}",
),
)
db.commit()
remove_device_directories(session["otb_tenant_slug"], device["relative_path"])
flash("Device removed successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/<int:device_id>/upload", methods=["GET", "POST"])
@portal_session_required
def upload_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
if request.method == "GET":
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
files = request.files.getlist("files")
files = [f for f in files if f and f.filename]
if not files:
flash("Please choose at least one file to upload.", "warning")
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
upload_base = _tenant_root() / device["relative_path"] / "originals"
upload_base.mkdir(parents=True, exist_ok=True)
uploaded_count = 0
with db.cursor() as cur:
for incoming in files:
original_filename = incoming.filename or "upload.bin"
stored_name = _stored_name(original_filename)
target_path = upload_base / stored_name
incoming.save(target_path)
size_bytes = target_path.stat().st_size
sha256 = compute_sha256(target_path)
base_name = original_filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
else:
basename, extension = base_name, ""
relative_path = f"{device['relative_path']}/originals/{stored_name}"
directory_path = f"{device['relative_path']}/originals"
cur.execute(
"""
INSERT INTO files (
tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path,
original_filename, basename, extension, mime_type, size_bytes, sha256,
capture_date, uploaded_at, is_immutable, is_deleted, deleted_at
) VALUES (
%s, %s, NULL, 'original', %s, %s,
%s, %s, %s, %s, %s, %s,
NULL, UTC_TIMESTAMP(), 1, 0, NULL
)
""",
(
session["otb_tenant_id"],
device["id"],
relative_path,
directory_path,
original_filename,
basename,
extension,
incoming.mimetype or None,
size_bytes,
sha256,
),
)
file_id = cur.lastrowid
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_uploaded', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Uploaded '{original_filename}' to {relative_path}",
),
)
uploaded_count += 1
db.commit()
flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/files/<int:file_id>/download", methods=["GET"])
@portal_session_required
def download_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, display_filename, relative_path
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row:
flash("File not found.", "warning")
return redirect(url_for("main.dashboard"))
file_path = _safe_path_from_relative(file_row["relative_path"])
if not file_path.exists():
flash("File is missing from storage.", "warning")
return redirect(url_for("main.dashboard"))
return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row))
@bp.route("/devices/<int:device_id>/files/download-selected", methods=["POST"])
@portal_session_required
def download_selected_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
if len(selected_ids) != 1:
flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
return redirect(url_for("main.download_file", file_id=selected_ids[0]))
@bp.route("/devices/<int:device_id>/files/delete-selected", methods=["POST"])
@portal_session_required
def delete_selected_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
deleted_count = 0
with db.cursor() as cur:
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, relative_path, directory_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
(file_id, session["otb_tenant_id"], device_id),
)
file_row = cur.fetchone()
if not file_row or file_row["is_deleted"]:
continue
source_path = _safe_path_from_relative(file_row["relative_path"])
target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}"
target_path = _safe_path_from_relative(target_rel)
target_path.parent.mkdir(parents=True, exist_ok=True)
if source_path.exists():
shutil.move(str(source_path), str(target_path))
cur.execute(
"""
UPDATE files
SET relative_path = %s,
directory_path = %s,
is_deleted = 1,
deleted_at = UTC_TIMESTAMP()
WHERE id = %s AND tenant_id = %s
""",
(
target_rel,
f"{device['relative_path']}/deleted",
file_id,
session["otb_tenant_id"],
),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_soft_deleted', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Soft-deleted '{file_row['original_filename']}' into deleted area",
),
)
deleted_count += 1
db.commit()
flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/devices/<int:device_id>/files/send-to-zip", methods=["POST"])
@portal_session_required
def send_selected_to_zip_workspace(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
staging_dir = _tenant_root() / "zip_staging"
staging_dir.mkdir(parents=True, exist_ok=True)
copied_count = 0
with db.cursor() as cur:
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, display_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
(file_id, session["otb_tenant_id"], device_id),
)
file_row = cur.fetchone()
if not file_row or file_row["is_deleted"]:
continue
source_path = _safe_path_from_relative(file_row["relative_path"])
if not source_path.exists():
continue
target_name = _stored_name(_display_filename(file_row))
target_path = staging_dir / target_name
shutil.copy2(source_path, target_path)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_staged_for_zip', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Copied '{_display_filename(file_row)}' into zip workspace",
),
)
copied_count += 1
db.commit()
flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/workspace/zip", methods=["GET"])
@portal_session_required
def zip_workspace():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
exports_dir = tenant_root / "exports"
staging_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
staged_files = []
for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()):
if p.is_file():
staged_files.append({
"name": p.name,
"size_bytes": p.stat().st_size,
"path": str(p.relative_to(tenant_root)),
})
export_files = []
for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file():
export_files.append({
"name": p.name,
"size_bytes": p.stat().st_size,
"path": str(p.relative_to(tenant_root)),
})
return render_template(
"cloud/zip_workspace.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
staged_files=staged_files,
export_files=export_files,
)
@bp.route("/workspace/zip/create", methods=["POST"])
@portal_session_required
def create_zip_from_workspace():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
exports_dir = tenant_root / "exports"
staging_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
staged = [p for p in staging_dir.iterdir() if p.is_file()]
if not staged:
flash("Zip Workspace is empty.", "warning")
return redirect(url_for("main.zip_workspace"))
zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip"
zip_path = exports_dir / zip_name
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in staged:
arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name
zf.write(p, arcname=arcname)
for p in staged:
p.unlink(missing_ok=True)
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success",
),
)
db.commit()
flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success")
return redirect(url_for("main.zip_workspace"))
@bp.route("/workspace/exports/<path:filename>/download", methods=["GET"])
@portal_session_required
def download_export(filename: str):
exports_dir = _tenant_root() / "exports"
file_path = exports_dir / filename
if not file_path.exists() or not file_path.is_file():
flash("Export file not found.", "warning")
return redirect(url_for("main.zip_workspace"))
return send_file(file_path, as_attachment=True, download_name=file_path.name)
@bp.route("/deleted", methods=["GET"])
@portal_session_required
def deleted_files():
db = get_db()
_purge_expired_deleted_files(db)
with db.cursor() as cur:
cur.execute(
"""
SELECT
f.id,
f.original_filename,
f.relative_path,
f.size_bytes,
f.deleted_at,
d.device_name,
d.device_type
FROM files f
LEFT JOIN devices d ON f.device_id = d.id
WHERE f.tenant_id = %s
AND f.is_deleted = 1
ORDER BY f.deleted_at DESC, f.id DESC
""",
(session["otb_tenant_id"],),
)
files = cur.fetchall()
return render_template(
"cloud/deleted_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
files=files,
)
@bp.route("/deleted/<int:file_id>/recover", methods=["POST"])
@portal_session_required
def recover_deleted_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT
f.id,
f.device_id,
f.original_filename,
f.relative_path,
f.is_deleted,
d.device_name,
d.device_type,
d.relative_path AS device_relative_path
FROM files f
LEFT JOIN devices d ON f.device_id = d.id
WHERE f.id = %s
AND f.tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row or not file_row["is_deleted"]:
flash("Deleted file not found.", "warning")
return redirect(url_for("main.deleted_files"))
if not file_row["device_relative_path"]:
flash("Cannot recover this file because its device record is missing.", "warning")
return redirect(url_for("main.deleted_files"))
source_path = _safe_path_from_relative(file_row["relative_path"])
if not source_path.exists():
flash("Deleted file is missing from storage.", "warning")
return redirect(url_for("main.deleted_files"))
recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"])
stored_name = _stored_name(recovered_name)
target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}"
target_dir = f"{file_row['device_relative_path']}/originals"
target_path = _safe_path_from_relative(target_rel)
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source_path), str(target_path))
cur.execute(
"""
UPDATE files
SET original_filename = %s,
display_filename = NULL,
basename = %s,
extension = %s,
relative_path = %s,
directory_path = %s,
is_deleted = 0,
deleted_at = NULL
WHERE id = %s AND tenant_id = %s
""",
(
recovered_name,
recovered_basename,
recovered_extension,
target_rel,
target_dir,
file_id,
session["otb_tenant_id"],
),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_recovered', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Recovered deleted file as '{recovered_name}' back into originals",
),
)
db.commit()
flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/deleted/<int:file_id>/hard-delete", methods=["POST"])
@portal_session_required
def hard_delete_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row or not file_row["is_deleted"]:
flash("Deleted file not found.", "warning")
return redirect(url_for("main.deleted_files"))
file_path = _safe_path_from_relative(file_row["relative_path"])
if file_path.exists():
file_path.unlink(missing_ok=True)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_hard_deleted', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area",
),
)
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"]))
db.commit()
flash("File permanently deleted.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/files/<int:file_id>/rename", methods=["POST"])
@portal_session_required
def rename_file(file_id: int):
db = get_db()
requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "")
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_id, original_filename, display_filename, extension, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row:
flash("File not found.", "warning")
return redirect(url_for("main.dashboard"))
if file_row["is_deleted"]:
flash("You can only rename active files from the device file browser.", "warning")
return redirect(url_for("main.deleted_files"))
if not requested_basename:
flash("Please enter a new file name.", "warning")
return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"]))
new_visible_name = (
f"{requested_basename}.{file_row['extension']}"
if file_row["extension"]
else requested_basename
)
if len(new_visible_name) > 255:
flash("That file name is too long.", "warning")
return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"]))
current_visible_name = _display_filename(file_row)
if new_visible_name == current_visible_name:
flash("That file already has that name.", "warning")
return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"]))
display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name
cur.execute(
"""
UPDATE files
SET display_filename = %s
WHERE id = %s AND tenant_id = %s
""",
(display_filename, file_id, session["otb_tenant_id"]),
)
final_visible_name = display_filename or file_row["original_filename"]
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_display_renamed', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'",
),
)
db.commit()
if display_filename is None:
flash("Custom name cleared. The original file name is now shown again.", "success")
else:
flash(f"File renamed to '{display_filename}'.", "success")
return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"]))
@bp.route("/devices/<int:device_id>/files", methods=["GET"])
@portal_session_required
def browse_device_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
with db.cursor() as cur:
cur.execute(
"""
SELECT
id,
file_kind,
relative_path,
directory_path,
original_filename,
display_filename,
basename,
extension,
mime_type,
size_bytes,
sha256,
uploaded_at,
is_immutable
FROM files
WHERE tenant_id = %s
AND device_id = %s
AND is_deleted = 0
ORDER BY uploaded_at DESC, id DESC
""",
(session["otb_tenant_id"], device_id),
)
files = cur.fetchall()
return render_template(
"cloud/device_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
files=files,
file_count=len(files),
)

1400
app/main/routes.py.bak.gracefuldelete.20260414-025910

File diff suppressed because it is too large Load Diff

1386
app/main/routes.py.bak.gracefuldelete3.20260414-032125

File diff suppressed because it is too large Load Diff

1386
app/main/routes.py.bak.gracefuldelete4.20260414-032525

File diff suppressed because it is too large Load Diff

1529
app/main/routes.py.bak.gracefuldelete4.20260415-065022

File diff suppressed because it is too large Load Diff

894
app/main/routes.py.bak.rename.20260413-064038

@ -1,894 +0,0 @@
from functools import wraps
from pathlib import Path
from datetime import datetime, timezone
import shutil
import zipfile
from werkzeug.utils import secure_filename
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file
from app.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256
bp = Blueprint("main", __name__)
def portal_session_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "otb_user_id" not in session or "otb_tenant_id" not in session:
return redirect(url_for("auth.login_required_notice"))
return view_func(*args, **kwargs)
return wrapped
def _client_ip():
return request.headers.get("X-Forwarded-For", request.remote_addr)
def _tenant_root() -> Path:
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"]
def _stored_name(original_name: str) -> str:
safe = secure_filename(original_name or "upload.bin")
if not safe:
safe = "upload.bin"
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}"
def _recovered_filename(original_name: str) -> tuple[str, str, str]:
base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
recovered_name = f"{basename}-recovered.{extension}"
return recovered_name, f"{basename}-recovered", extension
recovered_name = f"{base_name}-recovered"
return recovered_name, f"{base_name}-recovered", ""
def _safe_path_from_relative(relative_path: str) -> Path:
return _tenant_root() / relative_path
def _get_device_for_tenant(db, device_id: int):
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
return cur.fetchone()
def _purge_expired_deleted_files(db):
expired = []
with db.cursor() as cur:
cur.execute(
"""
SELECT id, relative_path, original_filename
FROM files
WHERE tenant_id = %s
AND is_deleted = 1
AND deleted_at IS NOT NULL
AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR)
""",
(session["otb_tenant_id"],),
)
expired = cur.fetchall()
for row in expired:
file_path = _safe_path_from_relative(row["relative_path"])
if file_path.exists():
try:
file_path.unlink()
except FileNotFoundError:
pass
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'system', 'deleted_file_purged', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
row["id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Purged deleted file '{row['original_filename']}' after retention window",
),
)
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"]))
db.commit()
@bp.route("/")
def index():
if "otb_user_id" in session:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login_required_notice"))
@bp.route("/dashboard")
@portal_session_required
def dashboard():
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path, is_active, created_at
FROM devices
WHERE tenant_id = %s
ORDER BY id
""",
(session["otb_tenant_id"],),
)
devices = cur.fetchall()
return render_template(
"cloud/dashboard.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)
@bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required
def add_device():
if request.method == "GET":
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
device_name = (request.form.get("device_name") or "").strip()
device_type = (request.form.get("device_type") or "").strip()
if not device_name:
flash("Device name is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
if not device_type:
flash("Device type is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
slug = slugify_device_name(device_name)
relative_path = f"devices/{slug}"
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id
FROM devices
WHERE tenant_id = %s AND device_name = %s
""",
(session["otb_tenant_id"], device_name),
)
existing = cur.fetchone()
if existing:
flash("A device with that name already exists.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
cur.execute(
"""
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(session["otb_tenant_id"], device_name, device_type, relative_path),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created device '{device_name}' ({device_type}) at {relative_path}",
),
)
db.commit()
create_device_directories(session["otb_tenant_slug"], relative_path)
flash("Device added successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/delete/<int:device_id>", methods=["POST"])
@portal_session_required
def delete_device(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
with db.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) AS file_count
FROM files
WHERE tenant_id = %s AND device_id = %s AND is_deleted = 0
""",
(session["otb_tenant_id"], device_id),
)
file_count = cur.fetchone()["file_count"]
if file_count and int(file_count) > 0:
flash("This device cannot be removed because files are still linked to it.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
DELETE FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_deleted', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}",
),
)
db.commit()
remove_device_directories(session["otb_tenant_slug"], device["relative_path"])
flash("Device removed successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/<int:device_id>/upload", methods=["GET", "POST"])
@portal_session_required
def upload_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
if request.method == "GET":
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
files = request.files.getlist("files")
files = [f for f in files if f and f.filename]
if not files:
flash("Please choose at least one file to upload.", "warning")
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
upload_base = _tenant_root() / device["relative_path"] / "originals"
upload_base.mkdir(parents=True, exist_ok=True)
uploaded_count = 0
with db.cursor() as cur:
for incoming in files:
original_filename = incoming.filename or "upload.bin"
stored_name = _stored_name(original_filename)
target_path = upload_base / stored_name
incoming.save(target_path)
size_bytes = target_path.stat().st_size
sha256 = compute_sha256(target_path)
base_name = original_filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
else:
basename, extension = base_name, ""
relative_path = f"{device['relative_path']}/originals/{stored_name}"
directory_path = f"{device['relative_path']}/originals"
cur.execute(
"""
INSERT INTO files (
tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path,
original_filename, basename, extension, mime_type, size_bytes, sha256,
capture_date, uploaded_at, is_immutable, is_deleted, deleted_at
) VALUES (
%s, %s, NULL, 'original', %s, %s,
%s, %s, %s, %s, %s, %s,
NULL, UTC_TIMESTAMP(), 1, 0, NULL
)
""",
(
session["otb_tenant_id"],
device["id"],
relative_path,
directory_path,
original_filename,
basename,
extension,
incoming.mimetype or None,
size_bytes,
sha256,
),
)
file_id = cur.lastrowid
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_uploaded', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Uploaded '{original_filename}' to {relative_path}",
),
)
uploaded_count += 1
db.commit()
flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/files/<int:file_id>/download", methods=["GET"])
@portal_session_required
def download_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, relative_path
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row:
flash("File not found.", "warning")
return redirect(url_for("main.dashboard"))
file_path = _safe_path_from_relative(file_row["relative_path"])
if not file_path.exists():
flash("File is missing from storage.", "warning")
return redirect(url_for("main.dashboard"))
return send_file(file_path, as_attachment=True, download_name=file_row["original_filename"])
@bp.route("/devices/<int:device_id>/files/download-selected", methods=["POST"])
@portal_session_required
def download_selected_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
if len(selected_ids) != 1:
flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
return redirect(url_for("main.download_file", file_id=selected_ids[0]))
@bp.route("/devices/<int:device_id>/files/delete-selected", methods=["POST"])
@portal_session_required
def delete_selected_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
deleted_count = 0
with db.cursor() as cur:
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, relative_path, directory_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
(file_id, session["otb_tenant_id"], device_id),
)
file_row = cur.fetchone()
if not file_row or file_row["is_deleted"]:
continue
source_path = _safe_path_from_relative(file_row["relative_path"])
target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}"
target_path = _safe_path_from_relative(target_rel)
target_path.parent.mkdir(parents=True, exist_ok=True)
if source_path.exists():
shutil.move(str(source_path), str(target_path))
cur.execute(
"""
UPDATE files
SET relative_path = %s,
directory_path = %s,
is_deleted = 1,
deleted_at = UTC_TIMESTAMP()
WHERE id = %s AND tenant_id = %s
""",
(
target_rel,
f"{device['relative_path']}/deleted",
file_id,
session["otb_tenant_id"],
),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_soft_deleted', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Soft-deleted '{file_row['original_filename']}' into deleted area",
),
)
deleted_count += 1
db.commit()
flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/devices/<int:device_id>/files/send-to-zip", methods=["POST"])
@portal_session_required
def send_selected_to_zip_workspace(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
staging_dir = _tenant_root() / "zip_staging"
staging_dir.mkdir(parents=True, exist_ok=True)
copied_count = 0
with db.cursor() as cur:
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
(file_id, session["otb_tenant_id"], device_id),
)
file_row = cur.fetchone()
if not file_row or file_row["is_deleted"]:
continue
source_path = _safe_path_from_relative(file_row["relative_path"])
if not source_path.exists():
continue
target_name = _stored_name(file_row["original_filename"])
target_path = staging_dir / target_name
shutil.copy2(source_path, target_path)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_staged_for_zip', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Copied '{file_row['original_filename']}' into zip workspace",
),
)
copied_count += 1
db.commit()
flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/workspace/zip", methods=["GET"])
@portal_session_required
def zip_workspace():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
exports_dir = tenant_root / "exports"
staging_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
staged_files = []
for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()):
if p.is_file():
staged_files.append({
"name": p.name,
"size_bytes": p.stat().st_size,
"path": str(p.relative_to(tenant_root)),
})
export_files = []
for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file():
export_files.append({
"name": p.name,
"size_bytes": p.stat().st_size,
"path": str(p.relative_to(tenant_root)),
})
return render_template(
"cloud/zip_workspace.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
staged_files=staged_files,
export_files=export_files,
)
@bp.route("/workspace/zip/create", methods=["POST"])
@portal_session_required
def create_zip_from_workspace():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
exports_dir = tenant_root / "exports"
staging_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
staged = [p for p in staging_dir.iterdir() if p.is_file()]
if not staged:
flash("Zip Workspace is empty.", "warning")
return redirect(url_for("main.zip_workspace"))
zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip"
zip_path = exports_dir / zip_name
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in staged:
zf.write(p, arcname=p.name)
for p in staged:
p.unlink(missing_ok=True)
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success",
),
)
db.commit()
flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success")
return redirect(url_for("main.zip_workspace"))
@bp.route("/workspace/exports/<path:filename>/download", methods=["GET"])
@portal_session_required
def download_export(filename: str):
exports_dir = _tenant_root() / "exports"
file_path = exports_dir / filename
if not file_path.exists() or not file_path.is_file():
flash("Export file not found.", "warning")
return redirect(url_for("main.zip_workspace"))
return send_file(file_path, as_attachment=True, download_name=file_path.name)
@bp.route("/deleted", methods=["GET"])
@portal_session_required
def deleted_files():
db = get_db()
_purge_expired_deleted_files(db)
with db.cursor() as cur:
cur.execute(
"""
SELECT
f.id,
f.original_filename,
f.relative_path,
f.size_bytes,
f.deleted_at,
d.device_name,
d.device_type
FROM files f
LEFT JOIN devices d ON f.device_id = d.id
WHERE f.tenant_id = %s
AND f.is_deleted = 1
ORDER BY f.deleted_at DESC, f.id DESC
""",
(session["otb_tenant_id"],),
)
files = cur.fetchall()
return render_template(
"cloud/deleted_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
files=files,
)
@bp.route("/deleted/<int:file_id>/recover", methods=["POST"])
@portal_session_required
def recover_deleted_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT
f.id,
f.device_id,
f.original_filename,
f.relative_path,
f.is_deleted,
d.device_name,
d.device_type,
d.relative_path AS device_relative_path
FROM files f
LEFT JOIN devices d ON f.device_id = d.id
WHERE f.id = %s
AND f.tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row or not file_row["is_deleted"]:
flash("Deleted file not found.", "warning")
return redirect(url_for("main.deleted_files"))
if not file_row["device_relative_path"]:
flash("Cannot recover this file because its device record is missing.", "warning")
return redirect(url_for("main.deleted_files"))
source_path = _safe_path_from_relative(file_row["relative_path"])
if not source_path.exists():
flash("Deleted file is missing from storage.", "warning")
return redirect(url_for("main.deleted_files"))
recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"])
stored_name = _stored_name(recovered_name)
target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}"
target_dir = f"{file_row['device_relative_path']}/originals"
target_path = _safe_path_from_relative(target_rel)
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source_path), str(target_path))
cur.execute(
"""
UPDATE files
SET original_filename = %s,
basename = %s,
extension = %s,
relative_path = %s,
directory_path = %s,
is_deleted = 0,
deleted_at = NULL
WHERE id = %s AND tenant_id = %s
""",
(
recovered_name,
recovered_basename,
recovered_extension,
target_rel,
target_dir,
file_id,
session["otb_tenant_id"],
),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_recovered', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Recovered deleted file as '{recovered_name}' back into originals",
),
)
db.commit()
flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/deleted/<int:file_id>/hard-delete", methods=["POST"])
@portal_session_required
def hard_delete_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row or not file_row["is_deleted"]:
flash("Deleted file not found.", "warning")
return redirect(url_for("main.deleted_files"))
file_path = _safe_path_from_relative(file_row["relative_path"])
if file_path.exists():
file_path.unlink(missing_ok=True)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_hard_deleted', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area",
),
)
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"]))
db.commit()
flash("File permanently deleted.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/devices/<int:device_id>/files", methods=["GET"])
@portal_session_required
def browse_device_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
with db.cursor() as cur:
cur.execute(
"""
SELECT
id,
file_kind,
relative_path,
directory_path,
original_filename,
basename,
extension,
mime_type,
size_bytes,
sha256,
uploaded_at,
is_immutable
FROM files
WHERE tenant_id = %s
AND device_id = %s
AND is_deleted = 0
ORDER BY uploaded_at DESC, id DESC
""",
(session["otb_tenant_id"], device_id),
)
files = cur.fetchall()
return render_template(
"cloud/device_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
files=files,
file_count=len(files),
)

894
app/main/routes.py.bak.rename.20260413-064842

@ -1,894 +0,0 @@
from functools import wraps
from pathlib import Path
from datetime import datetime, timezone
import shutil
import zipfile
from werkzeug.utils import secure_filename
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file
from app.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256
bp = Blueprint("main", __name__)
def portal_session_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "otb_user_id" not in session or "otb_tenant_id" not in session:
return redirect(url_for("auth.login_required_notice"))
return view_func(*args, **kwargs)
return wrapped
def _client_ip():
return request.headers.get("X-Forwarded-For", request.remote_addr)
def _tenant_root() -> Path:
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"]
def _stored_name(original_name: str) -> str:
safe = secure_filename(original_name or "upload.bin")
if not safe:
safe = "upload.bin"
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}"
def _recovered_filename(original_name: str) -> tuple[str, str, str]:
base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
recovered_name = f"{basename}-recovered.{extension}"
return recovered_name, f"{basename}-recovered", extension
recovered_name = f"{base_name}-recovered"
return recovered_name, f"{base_name}-recovered", ""
def _safe_path_from_relative(relative_path: str) -> Path:
return _tenant_root() / relative_path
def _get_device_for_tenant(db, device_id: int):
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
return cur.fetchone()
def _purge_expired_deleted_files(db):
expired = []
with db.cursor() as cur:
cur.execute(
"""
SELECT id, relative_path, original_filename
FROM files
WHERE tenant_id = %s
AND is_deleted = 1
AND deleted_at IS NOT NULL
AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR)
""",
(session["otb_tenant_id"],),
)
expired = cur.fetchall()
for row in expired:
file_path = _safe_path_from_relative(row["relative_path"])
if file_path.exists():
try:
file_path.unlink()
except FileNotFoundError:
pass
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'system', 'deleted_file_purged', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
row["id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Purged deleted file '{row['original_filename']}' after retention window",
),
)
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"]))
db.commit()
@bp.route("/")
def index():
if "otb_user_id" in session:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login_required_notice"))
@bp.route("/dashboard")
@portal_session_required
def dashboard():
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path, is_active, created_at
FROM devices
WHERE tenant_id = %s
ORDER BY id
""",
(session["otb_tenant_id"],),
)
devices = cur.fetchall()
return render_template(
"cloud/dashboard.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)
@bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required
def add_device():
if request.method == "GET":
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
device_name = (request.form.get("device_name") or "").strip()
device_type = (request.form.get("device_type") or "").strip()
if not device_name:
flash("Device name is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
if not device_type:
flash("Device type is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
slug = slugify_device_name(device_name)
relative_path = f"devices/{slug}"
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id
FROM devices
WHERE tenant_id = %s AND device_name = %s
""",
(session["otb_tenant_id"], device_name),
)
existing = cur.fetchone()
if existing:
flash("A device with that name already exists.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
cur.execute(
"""
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(session["otb_tenant_id"], device_name, device_type, relative_path),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created device '{device_name}' ({device_type}) at {relative_path}",
),
)
db.commit()
create_device_directories(session["otb_tenant_slug"], relative_path)
flash("Device added successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/delete/<int:device_id>", methods=["POST"])
@portal_session_required
def delete_device(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
with db.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) AS file_count
FROM files
WHERE tenant_id = %s AND device_id = %s AND is_deleted = 0
""",
(session["otb_tenant_id"], device_id),
)
file_count = cur.fetchone()["file_count"]
if file_count and int(file_count) > 0:
flash("This device cannot be removed because files are still linked to it.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
DELETE FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_deleted', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}",
),
)
db.commit()
remove_device_directories(session["otb_tenant_slug"], device["relative_path"])
flash("Device removed successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/<int:device_id>/upload", methods=["GET", "POST"])
@portal_session_required
def upload_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
if request.method == "GET":
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
files = request.files.getlist("files")
files = [f for f in files if f and f.filename]
if not files:
flash("Please choose at least one file to upload.", "warning")
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
upload_base = _tenant_root() / device["relative_path"] / "originals"
upload_base.mkdir(parents=True, exist_ok=True)
uploaded_count = 0
with db.cursor() as cur:
for incoming in files:
original_filename = incoming.filename or "upload.bin"
stored_name = _stored_name(original_filename)
target_path = upload_base / stored_name
incoming.save(target_path)
size_bytes = target_path.stat().st_size
sha256 = compute_sha256(target_path)
base_name = original_filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
else:
basename, extension = base_name, ""
relative_path = f"{device['relative_path']}/originals/{stored_name}"
directory_path = f"{device['relative_path']}/originals"
cur.execute(
"""
INSERT INTO files (
tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path,
original_filename, basename, extension, mime_type, size_bytes, sha256,
capture_date, uploaded_at, is_immutable, is_deleted, deleted_at
) VALUES (
%s, %s, NULL, 'original', %s, %s,
%s, %s, %s, %s, %s, %s,
NULL, UTC_TIMESTAMP(), 1, 0, NULL
)
""",
(
session["otb_tenant_id"],
device["id"],
relative_path,
directory_path,
original_filename,
basename,
extension,
incoming.mimetype or None,
size_bytes,
sha256,
),
)
file_id = cur.lastrowid
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_uploaded', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Uploaded '{original_filename}' to {relative_path}",
),
)
uploaded_count += 1
db.commit()
flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/files/<int:file_id>/download", methods=["GET"])
@portal_session_required
def download_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, relative_path
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row:
flash("File not found.", "warning")
return redirect(url_for("main.dashboard"))
file_path = _safe_path_from_relative(file_row["relative_path"])
if not file_path.exists():
flash("File is missing from storage.", "warning")
return redirect(url_for("main.dashboard"))
return send_file(file_path, as_attachment=True, download_name=file_row["original_filename"])
@bp.route("/devices/<int:device_id>/files/download-selected", methods=["POST"])
@portal_session_required
def download_selected_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
if len(selected_ids) != 1:
flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
return redirect(url_for("main.download_file", file_id=selected_ids[0]))
@bp.route("/devices/<int:device_id>/files/delete-selected", methods=["POST"])
@portal_session_required
def delete_selected_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
deleted_count = 0
with db.cursor() as cur:
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, relative_path, directory_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
(file_id, session["otb_tenant_id"], device_id),
)
file_row = cur.fetchone()
if not file_row or file_row["is_deleted"]:
continue
source_path = _safe_path_from_relative(file_row["relative_path"])
target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}"
target_path = _safe_path_from_relative(target_rel)
target_path.parent.mkdir(parents=True, exist_ok=True)
if source_path.exists():
shutil.move(str(source_path), str(target_path))
cur.execute(
"""
UPDATE files
SET relative_path = %s,
directory_path = %s,
is_deleted = 1,
deleted_at = UTC_TIMESTAMP()
WHERE id = %s AND tenant_id = %s
""",
(
target_rel,
f"{device['relative_path']}/deleted",
file_id,
session["otb_tenant_id"],
),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_soft_deleted', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Soft-deleted '{file_row['original_filename']}' into deleted area",
),
)
deleted_count += 1
db.commit()
flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/devices/<int:device_id>/files/send-to-zip", methods=["POST"])
@portal_session_required
def send_selected_to_zip_workspace(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
staging_dir = _tenant_root() / "zip_staging"
staging_dir.mkdir(parents=True, exist_ok=True)
copied_count = 0
with db.cursor() as cur:
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
(file_id, session["otb_tenant_id"], device_id),
)
file_row = cur.fetchone()
if not file_row or file_row["is_deleted"]:
continue
source_path = _safe_path_from_relative(file_row["relative_path"])
if not source_path.exists():
continue
target_name = _stored_name(file_row["original_filename"])
target_path = staging_dir / target_name
shutil.copy2(source_path, target_path)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_staged_for_zip', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Copied '{file_row['original_filename']}' into zip workspace",
),
)
copied_count += 1
db.commit()
flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/workspace/zip", methods=["GET"])
@portal_session_required
def zip_workspace():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
exports_dir = tenant_root / "exports"
staging_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
staged_files = []
for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()):
if p.is_file():
staged_files.append({
"name": p.name,
"size_bytes": p.stat().st_size,
"path": str(p.relative_to(tenant_root)),
})
export_files = []
for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file():
export_files.append({
"name": p.name,
"size_bytes": p.stat().st_size,
"path": str(p.relative_to(tenant_root)),
})
return render_template(
"cloud/zip_workspace.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
staged_files=staged_files,
export_files=export_files,
)
@bp.route("/workspace/zip/create", methods=["POST"])
@portal_session_required
def create_zip_from_workspace():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
exports_dir = tenant_root / "exports"
staging_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
staged = [p for p in staging_dir.iterdir() if p.is_file()]
if not staged:
flash("Zip Workspace is empty.", "warning")
return redirect(url_for("main.zip_workspace"))
zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip"
zip_path = exports_dir / zip_name
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in staged:
zf.write(p, arcname=p.name)
for p in staged:
p.unlink(missing_ok=True)
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success",
),
)
db.commit()
flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success")
return redirect(url_for("main.zip_workspace"))
@bp.route("/workspace/exports/<path:filename>/download", methods=["GET"])
@portal_session_required
def download_export(filename: str):
exports_dir = _tenant_root() / "exports"
file_path = exports_dir / filename
if not file_path.exists() or not file_path.is_file():
flash("Export file not found.", "warning")
return redirect(url_for("main.zip_workspace"))
return send_file(file_path, as_attachment=True, download_name=file_path.name)
@bp.route("/deleted", methods=["GET"])
@portal_session_required
def deleted_files():
db = get_db()
_purge_expired_deleted_files(db)
with db.cursor() as cur:
cur.execute(
"""
SELECT
f.id,
f.original_filename,
f.relative_path,
f.size_bytes,
f.deleted_at,
d.device_name,
d.device_type
FROM files f
LEFT JOIN devices d ON f.device_id = d.id
WHERE f.tenant_id = %s
AND f.is_deleted = 1
ORDER BY f.deleted_at DESC, f.id DESC
""",
(session["otb_tenant_id"],),
)
files = cur.fetchall()
return render_template(
"cloud/deleted_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
files=files,
)
@bp.route("/deleted/<int:file_id>/recover", methods=["POST"])
@portal_session_required
def recover_deleted_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT
f.id,
f.device_id,
f.original_filename,
f.relative_path,
f.is_deleted,
d.device_name,
d.device_type,
d.relative_path AS device_relative_path
FROM files f
LEFT JOIN devices d ON f.device_id = d.id
WHERE f.id = %s
AND f.tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row or not file_row["is_deleted"]:
flash("Deleted file not found.", "warning")
return redirect(url_for("main.deleted_files"))
if not file_row["device_relative_path"]:
flash("Cannot recover this file because its device record is missing.", "warning")
return redirect(url_for("main.deleted_files"))
source_path = _safe_path_from_relative(file_row["relative_path"])
if not source_path.exists():
flash("Deleted file is missing from storage.", "warning")
return redirect(url_for("main.deleted_files"))
recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"])
stored_name = _stored_name(recovered_name)
target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}"
target_dir = f"{file_row['device_relative_path']}/originals"
target_path = _safe_path_from_relative(target_rel)
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source_path), str(target_path))
cur.execute(
"""
UPDATE files
SET original_filename = %s,
basename = %s,
extension = %s,
relative_path = %s,
directory_path = %s,
is_deleted = 0,
deleted_at = NULL
WHERE id = %s AND tenant_id = %s
""",
(
recovered_name,
recovered_basename,
recovered_extension,
target_rel,
target_dir,
file_id,
session["otb_tenant_id"],
),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_recovered', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Recovered deleted file as '{recovered_name}' back into originals",
),
)
db.commit()
flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/deleted/<int:file_id>/hard-delete", methods=["POST"])
@portal_session_required
def hard_delete_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row or not file_row["is_deleted"]:
flash("Deleted file not found.", "warning")
return redirect(url_for("main.deleted_files"))
file_path = _safe_path_from_relative(file_row["relative_path"])
if file_path.exists():
file_path.unlink(missing_ok=True)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_hard_deleted', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area",
),
)
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"]))
db.commit()
flash("File permanently deleted.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/devices/<int:device_id>/files", methods=["GET"])
@portal_session_required
def browse_device_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
with db.cursor() as cur:
cur.execute(
"""
SELECT
id,
file_kind,
relative_path,
directory_path,
original_filename,
basename,
extension,
mime_type,
size_bytes,
sha256,
uploaded_at,
is_immutable
FROM files
WHERE tenant_id = %s
AND device_id = %s
AND is_deleted = 0
ORDER BY uploaded_at DESC, id DESC
""",
(session["otb_tenant_id"], device_id),
)
files = cur.fetchall()
return render_template(
"cloud/device_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
files=files,
file_count=len(files),
)

894
app/main/routes.py.bak.rename.20260413-065605

@ -1,894 +0,0 @@
from functools import wraps
from pathlib import Path
from datetime import datetime, timezone
import shutil
import zipfile
from werkzeug.utils import secure_filename
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file
from app.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256
bp = Blueprint("main", __name__)
def portal_session_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "otb_user_id" not in session or "otb_tenant_id" not in session:
return redirect(url_for("auth.login_required_notice"))
return view_func(*args, **kwargs)
return wrapped
def _client_ip():
return request.headers.get("X-Forwarded-For", request.remote_addr)
def _tenant_root() -> Path:
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"]
def _stored_name(original_name: str) -> str:
safe = secure_filename(original_name or "upload.bin")
if not safe:
safe = "upload.bin"
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}"
def _recovered_filename(original_name: str) -> tuple[str, str, str]:
base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
recovered_name = f"{basename}-recovered.{extension}"
return recovered_name, f"{basename}-recovered", extension
recovered_name = f"{base_name}-recovered"
return recovered_name, f"{base_name}-recovered", ""
def _safe_path_from_relative(relative_path: str) -> Path:
return _tenant_root() / relative_path
def _get_device_for_tenant(db, device_id: int):
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path
FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
return cur.fetchone()
def _purge_expired_deleted_files(db):
expired = []
with db.cursor() as cur:
cur.execute(
"""
SELECT id, relative_path, original_filename
FROM files
WHERE tenant_id = %s
AND is_deleted = 1
AND deleted_at IS NOT NULL
AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR)
""",
(session["otb_tenant_id"],),
)
expired = cur.fetchall()
for row in expired:
file_path = _safe_path_from_relative(row["relative_path"])
if file_path.exists():
try:
file_path.unlink()
except FileNotFoundError:
pass
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'system', 'deleted_file_purged', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
row["id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Purged deleted file '{row['original_filename']}' after retention window",
),
)
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"]))
db.commit()
@bp.route("/")
def index():
if "otb_user_id" in session:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login_required_notice"))
@bp.route("/dashboard")
@portal_session_required
def dashboard():
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, device_name, device_type, relative_path, is_active, created_at
FROM devices
WHERE tenant_id = %s
ORDER BY id
""",
(session["otb_tenant_id"],),
)
devices = cur.fetchall()
return render_template(
"cloud/dashboard.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
devices=devices,
)
@bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required
def add_device():
if request.method == "GET":
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
device_name = (request.form.get("device_name") or "").strip()
device_type = (request.form.get("device_type") or "").strip()
if not device_name:
flash("Device name is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
if not device_type:
flash("Device type is required.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
slug = slugify_device_name(device_name)
relative_path = f"devices/{slug}"
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id
FROM devices
WHERE tenant_id = %s AND device_name = %s
""",
(session["otb_tenant_id"], device_name),
)
existing = cur.fetchone()
if existing:
flash("A device with that name already exists.", "warning")
return render_template(
"cloud/device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device_name=device_name,
device_type=device_type,
)
cur.execute(
"""
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(session["otb_tenant_id"], device_name, device_type, relative_path),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created device '{device_name}' ({device_type}) at {relative_path}",
),
)
db.commit()
create_device_directories(session["otb_tenant_slug"], relative_path)
flash("Device added successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/delete/<int:device_id>", methods=["POST"])
@portal_session_required
def delete_device(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
with db.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) AS file_count
FROM files
WHERE tenant_id = %s AND device_id = %s AND is_deleted = 0
""",
(session["otb_tenant_id"], device_id),
)
file_count = cur.fetchone()["file_count"]
if file_count and int(file_count) > 0:
flash("This device cannot be removed because files are still linked to it.", "warning")
return redirect(url_for("main.dashboard"))
cur.execute(
"""
DELETE FROM devices
WHERE id = %s AND tenant_id = %s
""",
(device_id, session["otb_tenant_id"]),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'device_deleted', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}",
),
)
db.commit()
remove_device_directories(session["otb_tenant_slug"], device["relative_path"])
flash("Device removed successfully.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/<int:device_id>/upload", methods=["GET", "POST"])
@portal_session_required
def upload_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
if request.method == "GET":
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
files = request.files.getlist("files")
files = [f for f in files if f and f.filename]
if not files:
flash("Please choose at least one file to upload.", "warning")
return render_template(
"cloud/upload.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
)
upload_base = _tenant_root() / device["relative_path"] / "originals"
upload_base.mkdir(parents=True, exist_ok=True)
uploaded_count = 0
with db.cursor() as cur:
for incoming in files:
original_filename = incoming.filename or "upload.bin"
stored_name = _stored_name(original_filename)
target_path = upload_base / stored_name
incoming.save(target_path)
size_bytes = target_path.stat().st_size
sha256 = compute_sha256(target_path)
base_name = original_filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
basename, extension = base_name.rsplit(".", 1)
else:
basename, extension = base_name, ""
relative_path = f"{device['relative_path']}/originals/{stored_name}"
directory_path = f"{device['relative_path']}/originals"
cur.execute(
"""
INSERT INTO files (
tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path,
original_filename, basename, extension, mime_type, size_bytes, sha256,
capture_date, uploaded_at, is_immutable, is_deleted, deleted_at
) VALUES (
%s, %s, NULL, 'original', %s, %s,
%s, %s, %s, %s, %s, %s,
NULL, UTC_TIMESTAMP(), 1, 0, NULL
)
""",
(
session["otb_tenant_id"],
device["id"],
relative_path,
directory_path,
original_filename,
basename,
extension,
incoming.mimetype or None,
size_bytes,
sha256,
),
)
file_id = cur.lastrowid
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_uploaded', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Uploaded '{original_filename}' to {relative_path}",
),
)
uploaded_count += 1
db.commit()
flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/files/<int:file_id>/download", methods=["GET"])
@portal_session_required
def download_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, relative_path
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row:
flash("File not found.", "warning")
return redirect(url_for("main.dashboard"))
file_path = _safe_path_from_relative(file_row["relative_path"])
if not file_path.exists():
flash("File is missing from storage.", "warning")
return redirect(url_for("main.dashboard"))
return send_file(file_path, as_attachment=True, download_name=file_row["original_filename"])
@bp.route("/devices/<int:device_id>/files/download-selected", methods=["POST"])
@portal_session_required
def download_selected_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
if len(selected_ids) != 1:
flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
return redirect(url_for("main.download_file", file_id=selected_ids[0]))
@bp.route("/devices/<int:device_id>/files/delete-selected", methods=["POST"])
@portal_session_required
def delete_selected_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
deleted_count = 0
with db.cursor() as cur:
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, relative_path, directory_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
(file_id, session["otb_tenant_id"], device_id),
)
file_row = cur.fetchone()
if not file_row or file_row["is_deleted"]:
continue
source_path = _safe_path_from_relative(file_row["relative_path"])
target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}"
target_path = _safe_path_from_relative(target_rel)
target_path.parent.mkdir(parents=True, exist_ok=True)
if source_path.exists():
shutil.move(str(source_path), str(target_path))
cur.execute(
"""
UPDATE files
SET relative_path = %s,
directory_path = %s,
is_deleted = 1,
deleted_at = UTC_TIMESTAMP()
WHERE id = %s AND tenant_id = %s
""",
(
target_rel,
f"{device['relative_path']}/deleted",
file_id,
session["otb_tenant_id"],
),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_soft_deleted', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Soft-deleted '{file_row['original_filename']}' into deleted area",
),
)
deleted_count += 1
db.commit()
flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/devices/<int:device_id>/files/send-to-zip", methods=["POST"])
@portal_session_required
def send_selected_to_zip_workspace(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
if not selected_ids:
flash("Select at least one file first.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
staging_dir = _tenant_root() / "zip_staging"
staging_dir.mkdir(parents=True, exist_ok=True)
copied_count = 0
with db.cursor() as cur:
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
(file_id, session["otb_tenant_id"], device_id),
)
file_row = cur.fetchone()
if not file_row or file_row["is_deleted"]:
continue
source_path = _safe_path_from_relative(file_row["relative_path"])
if not source_path.exists():
continue
target_name = _stored_name(file_row["original_filename"])
target_path = staging_dir / target_name
shutil.copy2(source_path, target_path)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_staged_for_zip', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Copied '{file_row['original_filename']}' into zip workspace",
),
)
copied_count += 1
db.commit()
flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/workspace/zip", methods=["GET"])
@portal_session_required
def zip_workspace():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
exports_dir = tenant_root / "exports"
staging_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
staged_files = []
for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()):
if p.is_file():
staged_files.append({
"name": p.name,
"size_bytes": p.stat().st_size,
"path": str(p.relative_to(tenant_root)),
})
export_files = []
for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file():
export_files.append({
"name": p.name,
"size_bytes": p.stat().st_size,
"path": str(p.relative_to(tenant_root)),
})
return render_template(
"cloud/zip_workspace.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
staged_files=staged_files,
export_files=export_files,
)
@bp.route("/workspace/zip/create", methods=["POST"])
@portal_session_required
def create_zip_from_workspace():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
exports_dir = tenant_root / "exports"
staging_dir.mkdir(parents=True, exist_ok=True)
exports_dir.mkdir(parents=True, exist_ok=True)
staged = [p for p in staging_dir.iterdir() if p.is_file()]
if not staged:
flash("Zip Workspace is empty.", "warning")
return redirect(url_for("main.zip_workspace"))
zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip"
zip_path = exports_dir / zip_name
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in staged:
zf.write(p, arcname=p.name)
for p in staged:
p.unlink(missing_ok=True)
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success",
),
)
db.commit()
flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success")
return redirect(url_for("main.zip_workspace"))
@bp.route("/workspace/exports/<path:filename>/download", methods=["GET"])
@portal_session_required
def download_export(filename: str):
exports_dir = _tenant_root() / "exports"
file_path = exports_dir / filename
if not file_path.exists() or not file_path.is_file():
flash("Export file not found.", "warning")
return redirect(url_for("main.zip_workspace"))
return send_file(file_path, as_attachment=True, download_name=file_path.name)
@bp.route("/deleted", methods=["GET"])
@portal_session_required
def deleted_files():
db = get_db()
_purge_expired_deleted_files(db)
with db.cursor() as cur:
cur.execute(
"""
SELECT
f.id,
f.original_filename,
f.relative_path,
f.size_bytes,
f.deleted_at,
d.device_name,
d.device_type
FROM files f
LEFT JOIN devices d ON f.device_id = d.id
WHERE f.tenant_id = %s
AND f.is_deleted = 1
ORDER BY f.deleted_at DESC, f.id DESC
""",
(session["otb_tenant_id"],),
)
files = cur.fetchall()
return render_template(
"cloud/deleted_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
files=files,
)
@bp.route("/deleted/<int:file_id>/recover", methods=["POST"])
@portal_session_required
def recover_deleted_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT
f.id,
f.device_id,
f.original_filename,
f.relative_path,
f.is_deleted,
d.device_name,
d.device_type,
d.relative_path AS device_relative_path
FROM files f
LEFT JOIN devices d ON f.device_id = d.id
WHERE f.id = %s
AND f.tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row or not file_row["is_deleted"]:
flash("Deleted file not found.", "warning")
return redirect(url_for("main.deleted_files"))
if not file_row["device_relative_path"]:
flash("Cannot recover this file because its device record is missing.", "warning")
return redirect(url_for("main.deleted_files"))
source_path = _safe_path_from_relative(file_row["relative_path"])
if not source_path.exists():
flash("Deleted file is missing from storage.", "warning")
return redirect(url_for("main.deleted_files"))
recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"])
stored_name = _stored_name(recovered_name)
target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}"
target_dir = f"{file_row['device_relative_path']}/originals"
target_path = _safe_path_from_relative(target_rel)
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source_path), str(target_path))
cur.execute(
"""
UPDATE files
SET original_filename = %s,
basename = %s,
extension = %s,
relative_path = %s,
directory_path = %s,
is_deleted = 0,
deleted_at = NULL
WHERE id = %s AND tenant_id = %s
""",
(
recovered_name,
recovered_basename,
recovered_extension,
target_rel,
target_dir,
file_id,
session["otb_tenant_id"],
),
)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_recovered', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Recovered deleted file as '{recovered_name}' back into originals",
),
)
db.commit()
flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/deleted/<int:file_id>/hard-delete", methods=["POST"])
@portal_session_required
def hard_delete_file(file_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s
""",
(file_id, session["otb_tenant_id"]),
)
file_row = cur.fetchone()
if not file_row or not file_row["is_deleted"]:
flash("Deleted file not found.", "warning")
return redirect(url_for("main.deleted_files"))
file_path = _safe_path_from_relative(file_row["relative_path"])
if file_path.exists():
file_path.unlink(missing_ok=True)
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'file_hard_deleted', %s, %s, %s, %s)
""",
(
session["otb_tenant_id"],
session["otb_user_id"],
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area",
),
)
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"]))
db.commit()
flash("File permanently deleted.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/devices/<int:device_id>/files", methods=["GET"])
@portal_session_required
def browse_device_files(device_id: int):
db = get_db()
device = _get_device_for_tenant(db, device_id)
if not device:
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
with db.cursor() as cur:
cur.execute(
"""
SELECT
id,
file_kind,
relative_path,
directory_path,
original_filename,
basename,
extension,
mime_type,
size_bytes,
sha256,
uploaded_at,
is_immutable
FROM files
WHERE tenant_id = %s
AND device_id = %s
AND is_deleted = 0
ORDER BY uploaded_at DESC, id DESC
""",
(session["otb_tenant_id"], device_id),
)
files = cur.fetchall()
return render_template(
"cloud/device_files.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
device=device,
files=files,
file_count=len(files),
)

1068
app/main/routes.py.bak.thumb.20260413-073152

File diff suppressed because it is too large Load Diff

1134
app/main/routes.py.bak.tree.20260413-200734

File diff suppressed because it is too large Load Diff

1038
app/main/routes.py.bak.viewmode.1776064865

File diff suppressed because it is too large Load Diff

1529
app/main/routes.py.broken.1776293208

File diff suppressed because it is too large Load Diff

104
app/models/schema.sql.bak.android.20260414-002002

@ -1,104 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
portal_user_id INT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL
);
CREATE TABLE IF NOT EXISTS tenants (
id INT AUTO_INCREMENT PRIMARY KEY,
owner_user_id INT NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
storage_root VARCHAR(500) NOT NULL,
service_status VARCHAR(50) NOT NULL DEFAULT 'active',
retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS devices (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_name VARCHAR(100) NOT NULL,
device_type VARCHAR(50) NOT NULL,
relative_path VARCHAR(255) NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name),
CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
CREATE TABLE IF NOT EXISTS files (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_id INT NOT NULL,
parent_file_id BIGINT NULL,
file_kind VARCHAR(20) NOT NULL,
relative_path VARCHAR(1000) NOT NULL,
directory_path VARCHAR(1000) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
display_filename VARCHAR(255) NULL,
basename VARCHAR(255) NOT NULL,
extension VARCHAR(50) NOT NULL,
mime_type VARCHAR(255) NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
sha256 CHAR(64) NULL,
capture_date DATETIME NULL,
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_immutable TINYINT(1) NOT NULL DEFAULT 1,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
deleted_at DATETIME NULL,
CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id),
CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id)
);
CREATE TABLE IF NOT EXISTS jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
file_id BIGINT NOT NULL,
job_type VARCHAR(100) NOT NULL,
options_json LONGTEXT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'queued',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL,
completed_at DATETIME NULL,
output_file_id BIGINT NULL,
log_text LONGTEXT NULL,
CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id)
);
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NULL,
user_id INT NULL,
actor_type VARCHAR(20) NOT NULL,
event_type VARCHAR(100) NOT NULL,
file_id BIGINT NULL,
job_id BIGINT NULL,
ip_address VARCHAR(64) NULL,
user_agent VARCHAR(500) NULL,
event_detail LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_tenant_created (tenant_id, created_at),
INDEX idx_audit_event_type (event_type)
);
CREATE TABLE IF NOT EXISTS admin_access_tokens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
issued_by_user_id INT NOT NULL,
used_by_admin_id INT NULL,
token_hash CHAR(64) NOT NULL,
purpose VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'issued',
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id)
);

103
app/models/schema.sql.bak.rename.20260413-064038

@ -1,103 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
portal_user_id INT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL
);
CREATE TABLE IF NOT EXISTS tenants (
id INT AUTO_INCREMENT PRIMARY KEY,
owner_user_id INT NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
storage_root VARCHAR(500) NOT NULL,
service_status VARCHAR(50) NOT NULL DEFAULT 'active',
retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS devices (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_name VARCHAR(100) NOT NULL,
device_type VARCHAR(50) NOT NULL,
relative_path VARCHAR(255) NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name),
CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
CREATE TABLE IF NOT EXISTS files (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_id INT NOT NULL,
parent_file_id BIGINT NULL,
file_kind VARCHAR(20) NOT NULL,
relative_path VARCHAR(1000) NOT NULL,
directory_path VARCHAR(1000) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
basename VARCHAR(255) NOT NULL,
extension VARCHAR(50) NOT NULL,
mime_type VARCHAR(255) NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
sha256 CHAR(64) NULL,
capture_date DATETIME NULL,
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_immutable TINYINT(1) NOT NULL DEFAULT 1,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
deleted_at DATETIME NULL,
CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id),
CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id)
);
CREATE TABLE IF NOT EXISTS jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
file_id BIGINT NOT NULL,
job_type VARCHAR(100) NOT NULL,
options_json LONGTEXT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'queued',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL,
completed_at DATETIME NULL,
output_file_id BIGINT NULL,
log_text LONGTEXT NULL,
CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id)
);
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NULL,
user_id INT NULL,
actor_type VARCHAR(20) NOT NULL,
event_type VARCHAR(100) NOT NULL,
file_id BIGINT NULL,
job_id BIGINT NULL,
ip_address VARCHAR(64) NULL,
user_agent VARCHAR(500) NULL,
event_detail LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_tenant_created (tenant_id, created_at),
INDEX idx_audit_event_type (event_type)
);
CREATE TABLE IF NOT EXISTS admin_access_tokens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
issued_by_user_id INT NOT NULL,
used_by_admin_id INT NULL,
token_hash CHAR(64) NOT NULL,
purpose VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'issued',
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id)
);

103
app/models/schema.sql.bak.rename.20260413-064842

@ -1,103 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
portal_user_id INT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL
);
CREATE TABLE IF NOT EXISTS tenants (
id INT AUTO_INCREMENT PRIMARY KEY,
owner_user_id INT NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
storage_root VARCHAR(500) NOT NULL,
service_status VARCHAR(50) NOT NULL DEFAULT 'active',
retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS devices (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_name VARCHAR(100) NOT NULL,
device_type VARCHAR(50) NOT NULL,
relative_path VARCHAR(255) NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name),
CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
CREATE TABLE IF NOT EXISTS files (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_id INT NOT NULL,
parent_file_id BIGINT NULL,
file_kind VARCHAR(20) NOT NULL,
relative_path VARCHAR(1000) NOT NULL,
directory_path VARCHAR(1000) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
basename VARCHAR(255) NOT NULL,
extension VARCHAR(50) NOT NULL,
mime_type VARCHAR(255) NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
sha256 CHAR(64) NULL,
capture_date DATETIME NULL,
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_immutable TINYINT(1) NOT NULL DEFAULT 1,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
deleted_at DATETIME NULL,
CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id),
CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id)
);
CREATE TABLE IF NOT EXISTS jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
file_id BIGINT NOT NULL,
job_type VARCHAR(100) NOT NULL,
options_json LONGTEXT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'queued',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL,
completed_at DATETIME NULL,
output_file_id BIGINT NULL,
log_text LONGTEXT NULL,
CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id)
);
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NULL,
user_id INT NULL,
actor_type VARCHAR(20) NOT NULL,
event_type VARCHAR(100) NOT NULL,
file_id BIGINT NULL,
job_id BIGINT NULL,
ip_address VARCHAR(64) NULL,
user_agent VARCHAR(500) NULL,
event_detail LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_tenant_created (tenant_id, created_at),
INDEX idx_audit_event_type (event_type)
);
CREATE TABLE IF NOT EXISTS admin_access_tokens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
issued_by_user_id INT NOT NULL,
used_by_admin_id INT NULL,
token_hash CHAR(64) NOT NULL,
purpose VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'issued',
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id)
);

103
app/models/schema.sql.bak.rename.20260413-065605

@ -1,103 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
portal_user_id INT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL
);
CREATE TABLE IF NOT EXISTS tenants (
id INT AUTO_INCREMENT PRIMARY KEY,
owner_user_id INT NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
storage_root VARCHAR(500) NOT NULL,
service_status VARCHAR(50) NOT NULL DEFAULT 'active',
retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS devices (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_name VARCHAR(100) NOT NULL,
device_type VARCHAR(50) NOT NULL,
relative_path VARCHAR(255) NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name),
CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
CREATE TABLE IF NOT EXISTS files (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_id INT NOT NULL,
parent_file_id BIGINT NULL,
file_kind VARCHAR(20) NOT NULL,
relative_path VARCHAR(1000) NOT NULL,
directory_path VARCHAR(1000) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
basename VARCHAR(255) NOT NULL,
extension VARCHAR(50) NOT NULL,
mime_type VARCHAR(255) NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
sha256 CHAR(64) NULL,
capture_date DATETIME NULL,
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_immutable TINYINT(1) NOT NULL DEFAULT 1,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
deleted_at DATETIME NULL,
CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id),
CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id)
);
CREATE TABLE IF NOT EXISTS jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
file_id BIGINT NOT NULL,
job_type VARCHAR(100) NOT NULL,
options_json LONGTEXT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'queued',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL,
completed_at DATETIME NULL,
output_file_id BIGINT NULL,
log_text LONGTEXT NULL,
CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id)
);
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NULL,
user_id INT NULL,
actor_type VARCHAR(20) NOT NULL,
event_type VARCHAR(100) NOT NULL,
file_id BIGINT NULL,
job_id BIGINT NULL,
ip_address VARCHAR(64) NULL,
user_agent VARCHAR(500) NULL,
event_detail LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_tenant_created (tenant_id, created_at),
INDEX idx_audit_event_type (event_type)
);
CREATE TABLE IF NOT EXISTS admin_access_tokens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
issued_by_user_id INT NOT NULL,
used_by_admin_id INT NULL,
token_hash CHAR(64) NOT NULL,
purpose VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'issued',
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id)
);

10
app/services/video_jobs.py

@ -98,7 +98,7 @@ def resolve_source_from_file_id(db, tenant_id, device_id, source_file_id):
f"Tried tables: files, device_files, uploaded_files"
)
def create_video_job(tenant, device_id, source_file_id, profile="default", rotation_override=None):
def create_video_job(tenant, device_id, source_file_id, profile="default", rotation_override=None, batch_id=None):
db = get_db()
tenant_row = get_tenant_row(db, tenant)
@ -123,12 +123,13 @@ def create_video_job(tenant, device_id, source_file_id, profile="default", rotat
source_file_id,
source_relative_path,
source_original_filename,
batch_id,
requested_profile,
requested_rotation_degrees,
requested_gpu_preference,
status,
progress_percent
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'auto', 'queued', 0)
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'auto', 'queued', 0)
""",
(
tenant_id,
@ -136,6 +137,7 @@ def create_video_job(tenant, device_id, source_file_id, profile="default", rotat
int(source_file_id),
file_meta["source_relative_path"],
file_meta["source_original_filename"],
batch_id,
profile,
rotation_override,
)
@ -169,10 +171,12 @@ def list_jobs_for_tenant(tenant):
"""
SELECT
id,
tenant_id,
device_id,
source_file_id,
source_relative_path,
source_original_filename,
batch_id,
requested_profile,
requested_rotation_degrees,
status,
@ -200,9 +204,11 @@ def list_jobs_for_tenant(tenant):
out.append({
"id": r["id"],
"tenant_id": r["tenant_id"],
"device_id": r["device_id"],
"source_file_id": r["source_file_id"],
"filename": r["source_original_filename"],
"batch_id": r["batch_id"],
"profile": r["requested_profile"],
"rotation_override": r["requested_rotation_degrees"],
"status": r["status"],

218
app/services/video_worker.py

@ -243,6 +243,8 @@ def process_job(db, job, processor):
"-ac", "2",
"-ar", "48000",
"-movflags", "+faststart",
"-metadata:s:v:0", "rotate=0",
"-metadata:s:v:0", "rotate=0",
output
]
else:
@ -261,6 +263,8 @@ def process_job(db, job, processor):
"-ac", "2",
"-ar", "48000",
"-movflags", "+faststart",
"-metadata:s:v:0", "rotate=0",
"-metadata:s:v:0", "rotate=0",
output
]
@ -341,50 +345,189 @@ def process_job(db, job, processor):
db.commit()
bump_metrics(db, job["tenant_id"], complete=False, failed=True, gpu_seconds=0)
def claim_next_job(db, processor):
def _claim_from_query(cur, processor, where_sql="", args=()):
"""
Intel: prefer default/compress, then anything.
AMD: prefer hq, then anything.
Scheduling policy:
- Intel prefers default/compress and can fall back to any queued job.
- AMD prefers HQ.
- AMD may help with default/compress only when there is light-job backlog
or Intel is already processing a job. This avoids AMD stealing single
default/compress jobs when Intel is free.
"""
preferred_profile = "hq" if processor == "amd" else None
job = None
with db.cursor() as cur:
cur.execute("START TRANSACTION")
if processor == "amd":
# 1) AMD always prefers HQ first.
cur.execute(
f"""
SELECT *
FROM video_jobs
WHERE status='queued' {where_sql} AND requested_profile = %s
ORDER BY id ASC
LIMIT 1
FOR UPDATE
""",
tuple(args) + ("hq",),
)
job = cur.fetchone()
job = None
if job:
return job
if preferred_profile:
# 2) Only let AMD help with light jobs if there is backlog
# or Intel is already busy.
cur.execute(
f"""
SELECT COUNT(*) AS c
FROM video_jobs
WHERE status='queued' {where_sql}
AND requested_profile IN ('default','compress')
""",
tuple(args),
)
row = cur.fetchone()
light_backlog = row["c"] if isinstance(row, dict) else row[0]
cur.execute(
"""
SELECT COUNT(*) AS c
FROM video_jobs
WHERE status='processing'
AND assigned_processor = 'intel'
"""
)
row = cur.fetchone()
intel_busy = (row["c"] if isinstance(row, dict) else row[0]) > 0
if light_backlog >= 2 or intel_busy:
cur.execute(
"""
f"""
SELECT *
FROM video_jobs
WHERE status='queued' AND requested_profile = %s
WHERE status='queued' {where_sql}
AND requested_profile IN ('default','compress')
ORDER BY id ASC
LIMIT 1
FOR UPDATE
""",
(preferred_profile,),
tuple(args),
)
job = cur.fetchone()
if not job:
if processor == "intel":
return job
# Intel path: default/compress first.
cur.execute(
f"""
SELECT *
FROM video_jobs
WHERE status='queued' {where_sql} AND requested_profile IN ('default','compress')
ORDER BY id ASC
LIMIT 1
FOR UPDATE
""",
tuple(args),
)
job = cur.fetchone()
# Intel may fall back to HQ/anything if no light work exists.
if not job:
cur.execute(
f"""
SELECT *
FROM video_jobs
WHERE status='queued' {where_sql}
ORDER BY id ASC
LIMIT 1
FOR UPDATE
""",
tuple(args),
)
job = cur.fetchone()
return job
def claim_next_job(db, processor, current_batch_id=None, current_tenant_id=None):
with db.cursor() as cur:
cur.execute("START TRANSACTION")
# 1) Keep this worker on its current batch until the batch is finished.
if current_batch_id:
job = _claim_from_query(cur, processor, "AND batch_id = %s", (current_batch_id,))
if job:
cur.execute(
"""
SELECT *
FROM video_jobs
WHERE status='queued' AND requested_profile IN ('default','compress')
ORDER BY id ASC
LIMIT 1
FOR UPDATE
"""
UPDATE video_jobs
SET status='processing',
assigned_processor=%s,
started_at=COALESCE(started_at, UTC_TIMESTAMP()),
progress_percent=5
WHERE id=%s
""",
(processor, job["id"]),
)
job = cur.fetchone()
db.commit()
job["assigned_processor"] = processor
return job, current_batch_id, job["tenant_id"]
# 2) Count active tenants. If only one tenant is active, let them use all GPUs.
cur.execute("""
SELECT COUNT(DISTINCT tenant_id) AS c
FROM video_jobs
WHERE status IN ('queued','processing')
""")
row = cur.fetchone()
active_tenants = row["c"] if isinstance(row, dict) else row[0]
if active_tenants <= 1:
job = _claim_from_query(cur, processor)
if not job:
db.rollback()
return None, None, None
if not job:
cur.execute(
"""
SELECT *
UPDATE video_jobs
SET status='processing',
assigned_processor=%s,
started_at=COALESCE(started_at, UTC_TIMESTAMP()),
progress_percent=5
WHERE id=%s
""",
(processor, job["id"]),
)
db.commit()
job["assigned_processor"] = processor
return job, job.get("batch_id"), job["tenant_id"]
# 3) Multiple active tenants:
# only allow a tenant without an already-processing job to get a GPU slot.
cur.execute("""
SELECT DISTINCT tenant_id
FROM video_jobs
WHERE status='processing'
""")
busy_rows = cur.fetchall()
busy_tenants = {r["tenant_id"] if isinstance(r, dict) else r[0] for r in busy_rows}
if busy_tenants:
placeholders = ",".join(["%s"] * len(busy_tenants))
cur.execute(
f"""
SELECT tenant_id
FROM video_jobs
WHERE status='queued'
AND tenant_id NOT IN ({placeholders})
ORDER BY id ASC
LIMIT 1
FOR UPDATE
""",
tuple(busy_tenants),
)
else:
cur.execute(
"""
SELECT tenant_id
FROM video_jobs
WHERE status='queued'
ORDER BY id ASC
@ -392,11 +535,18 @@ def claim_next_job(db, processor):
FOR UPDATE
"""
)
job = cur.fetchone()
tenant_row = cur.fetchone()
if not tenant_row:
db.rollback()
return None, None, None
tenant_id = tenant_row["tenant_id"] if isinstance(tenant_row, dict) else tenant_row[0]
job = _claim_from_query(cur, processor, "AND tenant_id = %s", (tenant_id,))
if not job:
db.rollback()
return None
return None, None, None
cur.execute(
"""
@ -411,9 +561,12 @@ def claim_next_job(db, processor):
)
db.commit()
job["assigned_processor"] = processor
return job
return job, job.get("batch_id"), job["tenant_id"]
def worker_loop(app, processor):
current_batch_id = None
current_tenant_id = None
with app.app_context():
print(f"{processor} worker started", flush=True)
@ -425,12 +578,21 @@ def worker_loop(app, processor):
except Exception:
pass
job = claim_next_job(db, processor)
job, new_batch_id, new_tenant_id = claim_next_job(
db,
processor,
current_batch_id=current_batch_id,
current_tenant_id=current_tenant_id,
)
if job:
print(f"{processor} worker picked job id={job['id']} source={job['source_relative_path']}", flush=True)
current_batch_id = new_batch_id
current_tenant_id = new_tenant_id
print(f"{processor} worker picked job id={job['id']} batch={job.get('batch_id')} tenant={job['tenant_id']} source={job['source_relative_path']}", flush=True)
process_job(db, job, processor)
else:
current_batch_id = None
current_tenant_id = None
time.sleep(2)
except Exception as e:

10
app/templates/auth/handoff_error.html.bak.20260412-235158

@ -1,10 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Handoff Error{% endblock %}
{% block content %}
<div class="card">
<h1>Portal handoff failed</h1>
<p class="muted">{{ message }}</p>
</div>
{% endblock %}

16
app/templates/auth/login_required.html.bak.20260412-235158

@ -1,16 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Portal Login Required{% endblock %}
{% block content %}
<div class="card">
<h1>Portal login required</h1>
<p class="muted">
OTB Cloud does not allow direct unauthenticated access.
This app is intended to be reached through the OTB Billing portal handoff.
</p>
<div class="warn" style="margin-top:16px;">
Current status: direct local scaffold only. Real portal handoff wiring is next.
</div>
</div>
{% endblock %}

37
app/templates/cloud/dashboard.html.bak.20260412-235158

@ -1,37 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block content %}
<div class="card" style="margin-bottom:16px;">
<h1 style="margin-top:0;">OTB Cloud Dashboard</h1>
<p class="muted" style="margin-bottom:10px;">Authenticated user: {{ user_email }}</p>
<p class="muted" style="margin:0;">Tenant slug: <span class="badge">{{ tenant_slug }}</span></p>
</div>
<div class="grid">
<div class="card">
<h2 style="margin-top:0;">Devices</h2>
{% if devices %}
<ul style="padding-left:18px; margin-bottom:0;">
{% for device in devices %}
<li style="margin-bottom:8px;">
<strong>{{ device.device_name }}</strong>
<span class="muted">({{ device.device_type }})</span><br>
<span class="muted">{{ device.relative_path }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No devices have been created yet.</p>
{% endif %}
</div>
<div class="card">
<h2 style="margin-top:0;">Current scope</h2>
<p class="muted">
v0.1.1 provides portal-handoff scaffolding, tenant bootstrap, device records, and an authenticated dashboard.
</p>
</div>
</div>
{% endblock %}

77
app/templates/cloud/dashboard.html.bak.20260413-015405

@ -1,77 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
{% if devices %}
<ul style="padding-left:18px; margin:0;">
{% for device in devices %}
<li style="margin-bottom:10px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p>No devices have been created yet.</p>
{% endif %}
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is now operating inside the branded OTB portal shell.</p>
</div>
<div>
<span class="service-badge service-badge-beta">In Progress</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are the searchable file library, bulk upload endpoints, zip export, and media processing jobs.
</p>
</div>
</article>
</section>
{% endblock %}

124
app/templates/cloud/dashboard.html.bak.20260413-021018

@ -1,124 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0;">
{% for device in devices %}
<li style="margin-bottom:10px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is now operating inside the branded OTB portal shell.</p>
</div>
<div>
<span class="service-badge service-badge-beta">In Progress</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are the searchable file library, bulk upload endpoints, zip export, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices.</p>
</div>
<div>
<span class="service-badge service-badge-beta">In Progress</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, the next phase is browser upload, file library browsing, and export tools.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

127
app/templates/cloud/dashboard.html.bak.20260413-024827

@ -1,127 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0; list-style:disc;">
{% for device in devices %}
<li style="margin-bottom:18px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}" style="margin-top:10px;">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is now operating inside the branded OTB portal shell.</p>
</div>
<div>
<span class="service-badge service-badge-beta">In Progress</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are the searchable file library, bulk upload endpoints, zip export, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices.</p>
</div>
<div>
<span class="service-badge service-badge-beta">In Progress</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, the next phase is browser upload, file library browsing, and export tools.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

130
app/templates/cloud/dashboard.html.bak.20260413-032130

@ -1,130 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0; list-style:disc;">
{% for device in devices %}
<li style="margin-bottom:18px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form>
</div>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud now supports browser uploads to device originals.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are uploaded file listing, searchable library pages, zip export, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices and browser uploads.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, you can upload one or more files into that device’s originals storage.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

131
app/templates/cloud/dashboard.html.bak.20260413-051439

@ -1,131 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0; list-style:disc;">
{% for device in devices %}
<li style="margin-bottom:18px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id) }}">Browse Files</a>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form>
</div>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud now supports browser uploads and device file browsing.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are single-file download, searchable library pages, zip export, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices and browser uploads.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, you can upload one or more files into that device’s originals storage and browse them here.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

133
app/templates/cloud/dashboard.html.bak.android.20260414-002002

@ -1,133 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.deleted_files') }}">Deleted Files</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0; list-style:disc;">
{% for device in devices %}
<li style="margin-bottom:18px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id) }}">Browse Files</a>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form>
</div>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud now supports browser uploads, browsing, zip staging, and soft delete.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are basename-only rename, searchable library pages, folder upload, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices and browser uploads.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, you can upload files, browse them, soft-delete them, and stage them for zip export.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

135
app/templates/cloud/dashboard.html.bak.androidlock.20260414-014204

@ -1,135 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn primary" href="{{ url_for('main.create_android_device') }}">Add Android Device</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.deleted_files') }}">Deleted Files</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0; list-style:disc;">
{% for device in devices %}
<li style="margin-bottom:18px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id) }}">Browse Files</a>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form>
</div>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud now supports browser uploads, browsing, zip staging, and soft delete.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are basename-only rename, searchable library pages, folder upload, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn primary" href="{{ url_for('main.create_android_device') }}">Add Android Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices and browser uploads.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, you can upload files, browse them, soft-delete them, and stage them for zip export.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

13
app/templates/cloud/deleted_files.html

@ -74,7 +74,10 @@
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<form method="post" action="{{ url_for('main.recover_deleted_file', file_id=file.id) }}">
<button class="portal-btn" type="button" onclick="selectAllDeleted(true)">Select All</button>
<button class="portal-btn" type="button" onclick="selectAllDeleted(false)">Unselect All</button>
<form method="post" action="{{ url_for('main.recover_deleted_file', file_id=file.id) }}">
<button class="portal-btn primary" type="submit" onclick="return confirm('Recover {{ file.original_filename|e }}? It will return to originals with -recovered appended to the filename.');">Recover</button>
</form>
<form method="post" action="{{ url_for('main.hard_delete_file', file_id=file.id) }}">
@ -105,3 +108,11 @@
</section>
{% endif %}
{% endblock %}
<script>
function selectAllDeleted(state) {
document.querySelectorAll("input[type='checkbox'][name='selected_files'], input[type='checkbox'].row-check, input[type='checkbox']").forEach(cb => {
if (!cb.disabled) cb.checked = state;
});
}
</script>

102
app/templates/cloud/deleted_files.html.bak.20260413-054006

@ -1,102 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Deleted Files - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Deleted Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Deleted files are retained for up to 24 hours unless you hard-delete them immediately.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Deleted Files</h2>
<p>Files here are pending retention expiry or hard delete.</p>
</div>
<div>
<span class="service-badge service-badge-beta">{{ files|length }} items</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Device</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Deleted At</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Action</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">{{ file.relative_path }}</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.device_name or 'Unknown' }}<br>
<span style="opacity:0.75;font-size:0.9rem;">{{ file.device_type or '' }}</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.deleted_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<form method="post" action="{{ url_for('main.hard_delete_file', file_id=file.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Permanently delete {{ file.original_filename|e }} now? This cannot be undone.');">Hard Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No deleted files</h2>
<p>There are currently no files in the deleted area.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Clear</span>
</div>
</div>
</article>
</section>
{% endif %}
{% endblock %}

79
app/templates/cloud/device_files.html

@ -253,7 +253,12 @@
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Archive Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
<a class="portal-btn" href="{{ url_for('main.image_workshop', device_id=device.id) }}">Image Workspace</a>
<a class="portal-btn" href="{{ url_for('main.workshop', device_id=device.id) }}">Video Workspace</a>
<a class="portal-btn" href="{{ url_for('main.lts_view') }}">LTS</a>
<a class="portal-btn" href="{{ url_for('main.deleted_files') }}">Deleted Files</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a class="portal-btn" href="/health">Health</a>
</div>
<div class="otb-view-toggle">
@ -296,7 +301,7 @@
{% if folders %}
<div class="otb-folder-grid">
{% for folder in folders %}
<a class="otb-folder-card" href="{{ url_for('main.browse_device_files', device_id=device.id, view=view_mode, path=folder.path) }}">
<a class="otb-folder-card" href="{{ url_for('main.browse_device_files', device_id=device.id, path=folder.path) }}">
<div class="otb-folder-name">📁 {{ folder.name }}</div>
<div class="otb-folder-sub">Open folder</div>
</a>
@ -308,6 +313,24 @@
</article>
</section>
{% if total_files is defined and total_files > per_page %}
<div class="service-card" style="margin:12px 0;">
<div class="service-card-body" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<strong>Showing {{ file_count }} of {{ total_files }} files</strong>
<span>Page {{ page }}</span>
{% if has_prev %}
<a class="portal-btn" href="?path={{ current_path }}&view={{ view_mode }}&page={{ page - 1 }}">Previous</a>
{% endif %}
{% if has_next %}
<a class="portal-btn" href="?path={{ current_path }}&view={{ view_mode }}&page={{ page + 1 }}">Next</a>
{% endif %}
</div>
</div>
{% endif %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
@ -330,10 +353,11 @@
<div class="service-card-actions" style="overflow-x:auto;">
<form id="bulk-actions-form" method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Archive Workspace</button>
<button class="portal-btn primary" type="button" onclick="sendToWorkshop()">Send to Workshop</button>
<button class="portal-btn" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Archive Workspace</button>
<button class="portal-btn" type="button" onclick="sendToLTS()">Send to LTS</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
<button class="portal-btn primary" type="button" onclick="sendToWorkshop()">Send to Workshop</button>
</div>
</form>
@ -375,9 +399,17 @@
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-gallery-rename">
<input type="hidden" name="path" value="{{ current_path }}">
<input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="page" value="{{ page }}">
<input
type="text"
name="display_basename"
name="display_basename">
<input type="hidden" name="path" value="{{ current_path }}">
<input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="page" value="{{ page }}">
value="{{ rename_value }}"
maxlength="200"
placeholder="Custom file name"
@ -429,9 +461,17 @@
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-list-rename">
<input type="hidden" name="path" value="{{ current_path }}">
<input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="page" value="{{ page }}">
<input
type="text"
name="display_basename"
name="display_basename">
<input type="hidden" name="path" value="{{ current_path }}">
<input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="page" value="{{ page }}">
value="{{ rename_value }}"
maxlength="200"
placeholder="Enter custom file name"
@ -563,7 +603,25 @@ window.sendToWorkshop = function () {
const deviceId = parts[2];
localStorage.setItem("videoSelection", JSON.stringify(selected));
window.location.href = "/workshop/" + deviceId;
const pathParams = new URLSearchParams(window.location.search);
const currentPath = (pathParams.get("path") || "").toLowerCase();
const allVideos = selected.every(f => (f.mime_type || f.mime || "").startsWith("video/"));
const allImages = selected.every(f => (f.mime_type || f.mime || "").startsWith("image/"));
if (allVideos || currentPath === "video" || currentPath.startsWith("video/")) {
window.location.href = "/workshop/" + deviceId;
} else if (allImages || currentPath === "images" || currentPath.startsWith("images/")) {
if (selected.length > 25) {
alert("Image Workshop is limited to 25 images per batch.");
return;
}
localStorage.setItem("imageSelection", JSON.stringify(selected));
window.location.href = "/image-workshop/" + deviceId;
} else {
alert("Cannot mix images and videos in the same workshop job.");
}
};
</script>
@ -572,3 +630,10 @@ window.sendToWorkshop = function () {
<script>
window.sendToLTS = function () {
alert("Send to LTS is staged but not wired yet.");
};
</script>

101
app/templates/cloud/device_files.html.bak.20260413-051439

@ -1,101 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Device Files - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Files</h2>
<p>Files recorded in the database for this device.</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">
SHA256: {{ file.sha256 }}
</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

146
app/templates/cloud/device_files.html.bak.gallery.20260413-071415

@ -1,146 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Device Files - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Files</h2>
<p>Select files to delete, download, or send to Zip Workspace. Rename changes only the customer-facing name, not the immutable stored file.</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form id="bulk-actions-form" method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
</form>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
{% set visible_name = file.display_filename or file.original_filename %}
{% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;min-width:320px;">
<strong>{{ visible_name }}</strong><br>
{% if file.display_filename %}
<span style="opacity:0.75;font-size:0.9rem;">Original: {{ file.original_filename }}</span><br>
{% endif %}
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Enter custom file name"
style="min-width:220px;padding:8px 10px;border-radius:10px;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.06);color:#fff;"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

454
app/templates/cloud/device_files.html.bak.previewfix.1776065858

@ -1,454 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Device Files - OTB Cloud{% endblock %}
{% block portal_content %}
<style>
.otb-view-toggle {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-view-toggle a {
text-decoration: none;
}
.otb-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-top: 18px;
}
.otb-gallery-card {
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
background: rgba(255,255,255,0.03);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 320px;
}
.otb-gallery-thumb-wrap {
position: relative;
aspect-ratio: 1 / 1;
background: rgba(255,255,255,0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.otb-gallery-thumb {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
display: block;
}
.otb-gallery-check {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
transform: scale(1.2);
}
.otb-gallery-meta {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.otb-gallery-name {
font-weight: 700;
word-break: break-word;
line-height: 1.25;
}
.otb-gallery-sub {
font-size: 0.9rem;
opacity: 0.8;
line-height: 1.35;
}
.otb-gallery-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-gallery-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-gallery-rename input {
min-width: 120px;
flex: 1 1 120px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
.otb-modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
}
.otb-modal-backdrop.active {
display: flex;
}
.otb-modal-card {
max-width: min(96vw, 1400px);
max-height: 94vh;
width: 100%;
background: rgba(11,18,37,0.98);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
}
.otb-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.10);
}
.otb-modal-title {
font-weight: 700;
word-break: break-word;
}
.otb-modal-body {
display: flex;
align-items: center;
justify-content: center;
background: #000;
max-height: calc(94vh - 64px);
overflow: auto;
}
.otb-modal-body img {
max-width: 100%;
max-height: calc(94vh - 100px);
object-fit: contain;
display: block;
}
.otb-list-name-wrap {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 320px;
}
.otb-list-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-list-rename input {
min-width: 220px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
</style>
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div class="otb-view-toggle">
<a class="portal-btn {% if view_mode == 'list' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='list') }}">List View</a>
<a class="portal-btn {% if view_mode == 'gallery' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='gallery') }}">Gallery View</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>{% if view_mode == 'gallery' %}Image Gallery{% else %}Files{% endif %}</h2>
<p>
{% if view_mode == 'gallery' %}
Browse uploaded images visually. Click a thumbnail to preview the full-size image.
{% else %}
Select files to delete, download, or send to Zip Workspace. Rename changes only the customer-facing name, not the immutable stored file.
{% endif %}
</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form id="bulk-actions-form" method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
</form>
{% if view_mode == 'gallery' %}
<div class="otb-gallery-grid">
{% for file in files %}
{% set visible_name = file.display_filename or file.original_filename %}
{% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %}
{% set ext = (file.extension or '')|lower %}
{% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %}
<div class="otb-gallery-card" title="Name: {{ visible_name }}&#10;Original: {{ file.original_filename }}&#10;Type: {{ file.mime_type or file.file_kind }}&#10;Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes&#10;Uploaded: {{ file.uploaded_at }}">
<div class="otb-gallery-thumb-wrap">
<input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
{% if is_image %}
<img
src="{{ url_for('main.thumbnail_file', file_id=file.id) }}"
alt="{{ visible_name }}"
class="otb-gallery-thumb preview-trigger"
data-preview-url="{{ url_for('main.thumbnail_file', file_id=file.id) }}"
data-preview-title="{{ visible_name }}"
>
{% else %}
<div style="padding:20px;text-align:center;opacity:0.75;">No preview</div>
{% endif %}
</div>
<div class="otb-gallery-meta">
<div class="otb-gallery-name">{{ visible_name }}</div>
<div class="otb-gallery-sub">
{{ file.mime_type or file.file_kind }}<br>
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</div>
<div class="otb-gallery-actions">
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.thumbnail_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}">Preview</button>
{% endif %}
<a class="portal-btn" href="{{ url_for('main.download_file', file_id=file.id) }}">Download</a>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-gallery-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
{% set visible_name = file.display_filename or file.original_filename %}
{% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %}
{% set ext = (file.extension or '')|lower %}
{% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<div class="otb-list-name-wrap">
<div>
<strong>{{ visible_name }}</strong><br>
{% if file.display_filename %}
<span style="opacity:0.75;font-size:0.9rem;">Original: {{ file.original_filename }}</span><br>
{% endif %}
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.thumbnail_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}" style="margin-top:8px;">Preview</button>
{% endif %}
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-list-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Enter custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</article>
</section>
<div id="otb-image-modal" class="otb-modal-backdrop" aria-hidden="true">
<div class="otb-modal-card">
<div class="otb-modal-header">
<div id="otb-image-modal-title" class="otb-modal-title">Preview</div>
<button type="button" class="portal-btn" id="otb-image-modal-close">Close</button>
</div>
<div class="otb-modal-body">
<img id="otb-image-modal-img" src="" alt="">
</div>
</div>
</div>
<script>
(function () {
const modal = document.getElementById('otb-image-modal');
const modalImg = document.getElementById('otb-image-modal-img');
const modalTitle = document.getElementById('otb-image-modal-title');
const closeBtn = document.getElementById('otb-image-modal-close');
if (!modal || !modalImg || !modalTitle || !closeBtn) return;
function openPreview(url, title) {
modalImg.src = url;
modalImg.alt = title || 'Preview image';
modalTitle.textContent = title || 'Preview';
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
function closePreview() {
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
modalImg.src = '';
modalImg.alt = '';
}
document.querySelectorAll('.preview-trigger').forEach(function (el) {
el.addEventListener('click', function () {
const url = this.dataset.previewUrl || this.getAttribute('src');
const title = this.dataset.previewTitle || this.getAttribute('alt') || 'Preview';
if (url) openPreview(url, title);
});
});
closeBtn.addEventListener('click', closePreview);
modal.addEventListener('click', function (e) {
if (e.target === modal) closePreview();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePreview();
});
})();
</script>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

126
app/templates/cloud/device_files.html.bak.rename.20260413-064038

@ -1,126 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Device Files - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Files</h2>
<p>Select files to delete, download, or send to Zip Workspace.</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

126
app/templates/cloud/device_files.html.bak.rename.20260413-064842

@ -1,126 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Device Files - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Files</h2>
<p>Select files to delete, download, or send to Zip Workspace.</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

126
app/templates/cloud/device_files.html.bak.rename.20260413-065605

@ -1,126 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Device Files - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Files</h2>
<p>Select files to delete, download, or send to Zip Workspace.</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

454
app/templates/cloud/device_files.html.bak.thumb.20260413-073152

@ -1,454 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Device Files - OTB Cloud{% endblock %}
{% block portal_content %}
<style>
.otb-view-toggle {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-view-toggle a {
text-decoration: none;
}
.otb-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-top: 18px;
}
.otb-gallery-card {
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
background: rgba(255,255,255,0.03);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 320px;
}
.otb-gallery-thumb-wrap {
position: relative;
aspect-ratio: 1 / 1;
background: rgba(255,255,255,0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.otb-gallery-thumb {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
display: block;
}
.otb-gallery-check {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
transform: scale(1.2);
}
.otb-gallery-meta {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.otb-gallery-name {
font-weight: 700;
word-break: break-word;
line-height: 1.25;
}
.otb-gallery-sub {
font-size: 0.9rem;
opacity: 0.8;
line-height: 1.35;
}
.otb-gallery-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-gallery-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-gallery-rename input {
min-width: 120px;
flex: 1 1 120px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
.otb-modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
}
.otb-modal-backdrop.active {
display: flex;
}
.otb-modal-card {
max-width: min(96vw, 1400px);
max-height: 94vh;
width: 100%;
background: rgba(11,18,37,0.98);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
}
.otb-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.10);
}
.otb-modal-title {
font-weight: 700;
word-break: break-word;
}
.otb-modal-body {
display: flex;
align-items: center;
justify-content: center;
background: #000;
max-height: calc(94vh - 64px);
overflow: auto;
}
.otb-modal-body img {
max-width: 100%;
max-height: calc(94vh - 100px);
object-fit: contain;
display: block;
}
.otb-list-name-wrap {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 320px;
}
.otb-list-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-list-rename input {
min-width: 220px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
</style>
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div class="otb-view-toggle">
<a class="portal-btn {% if view_mode == 'list' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='list') }}">List View</a>
<a class="portal-btn {% if view_mode == 'gallery' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='gallery') }}">Gallery View</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>{% if view_mode == 'gallery' %}Image Gallery{% else %}Files{% endif %}</h2>
<p>
{% if view_mode == 'gallery' %}
Browse uploaded images visually. Click a thumbnail to preview the full-size image.
{% else %}
Select files to delete, download, or send to Zip Workspace. Rename changes only the customer-facing name, not the immutable stored file.
{% endif %}
</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form id="bulk-actions-form" method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
</form>
{% if view_mode == 'gallery' %}
<div class="otb-gallery-grid">
{% for file in files %}
{% set visible_name = file.display_filename or file.original_filename %}
{% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %}
{% set ext = (file.extension or '')|lower %}
{% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %}
<div class="otb-gallery-card" title="Name: {{ visible_name }}&#10;Original: {{ file.original_filename }}&#10;Type: {{ file.mime_type or file.file_kind }}&#10;Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes&#10;Uploaded: {{ file.uploaded_at }}">
<div class="otb-gallery-thumb-wrap">
<input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
{% if is_image %}
<img
src="{{ url_for('main.inline_file', file_id=file.id) }}"
alt="{{ visible_name }}"
class="otb-gallery-thumb preview-trigger"
data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}"
data-preview-title="{{ visible_name }}"
>
{% else %}
<div style="padding:20px;text-align:center;opacity:0.75;">No preview</div>
{% endif %}
</div>
<div class="otb-gallery-meta">
<div class="otb-gallery-name">{{ visible_name }}</div>
<div class="otb-gallery-sub">
{{ file.mime_type or file.file_kind }}<br>
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</div>
<div class="otb-gallery-actions">
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}">Preview</button>
{% endif %}
<a class="portal-btn" href="{{ url_for('main.download_file', file_id=file.id) }}">Download</a>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-gallery-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
{% set visible_name = file.display_filename or file.original_filename %}
{% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %}
{% set ext = (file.extension or '')|lower %}
{% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<div class="otb-list-name-wrap">
<div>
<strong>{{ visible_name }}</strong><br>
{% if file.display_filename %}
<span style="opacity:0.75;font-size:0.9rem;">Original: {{ file.original_filename }}</span><br>
{% endif %}
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}" style="margin-top:8px;">Preview</button>
{% endif %}
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-list-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Enter custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</article>
</section>
<div id="otb-image-modal" class="otb-modal-backdrop" aria-hidden="true">
<div class="otb-modal-card">
<div class="otb-modal-header">
<div id="otb-image-modal-title" class="otb-modal-title">Preview</div>
<button type="button" class="portal-btn" id="otb-image-modal-close">Close</button>
</div>
<div class="otb-modal-body">
<img id="otb-image-modal-img" src="" alt="">
</div>
</div>
</div>
<script>
(function () {
const modal = document.getElementById('otb-image-modal');
const modalImg = document.getElementById('otb-image-modal-img');
const modalTitle = document.getElementById('otb-image-modal-title');
const closeBtn = document.getElementById('otb-image-modal-close');
if (!modal || !modalImg || !modalTitle || !closeBtn) return;
function openPreview(url, title) {
modalImg.src = url;
modalImg.alt = title || 'Preview image';
modalTitle.textContent = title || 'Preview';
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
function closePreview() {
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
modalImg.src = '';
modalImg.alt = '';
}
document.querySelectorAll('.preview-trigger').forEach(function (el) {
el.addEventListener('click', function () {
const url = this.dataset.previewUrl || this.getAttribute('src');
const title = this.dataset.previewTitle || this.getAttribute('alt') || 'Preview';
if (url) openPreview(url, title);
});
});
closeBtn.addEventListener('click', closePreview);
modal.addEventListener('click', function (e) {
if (e.target === modal) closePreview();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePreview();
});
})();
</script>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

454
app/templates/cloud/device_files.html.bak.tree.20260413-200734

@ -1,454 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Device Files - OTB Cloud{% endblock %}
{% block portal_content %}
<style>
.otb-view-toggle {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-view-toggle a {
text-decoration: none;
}
.otb-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-top: 18px;
}
.otb-gallery-card {
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
background: rgba(255,255,255,0.03);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 320px;
}
.otb-gallery-thumb-wrap {
position: relative;
aspect-ratio: 1 / 1;
background: rgba(255,255,255,0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.otb-gallery-thumb {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
display: block;
}
.otb-gallery-check {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
transform: scale(1.2);
}
.otb-gallery-meta {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.otb-gallery-name {
font-weight: 700;
word-break: break-word;
line-height: 1.25;
}
.otb-gallery-sub {
font-size: 0.9rem;
opacity: 0.8;
line-height: 1.35;
}
.otb-gallery-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-gallery-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-gallery-rename input {
min-width: 120px;
flex: 1 1 120px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
.otb-modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
}
.otb-modal-backdrop.active {
display: flex;
}
.otb-modal-card {
max-width: min(96vw, 1400px);
max-height: 94vh;
width: 100%;
background: rgba(11,18,37,0.98);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
}
.otb-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.10);
}
.otb-modal-title {
font-weight: 700;
word-break: break-word;
}
.otb-modal-body {
display: flex;
align-items: center;
justify-content: center;
background: #000;
max-height: calc(94vh - 64px);
overflow: auto;
}
.otb-modal-body img {
max-width: 100%;
max-height: calc(94vh - 100px);
object-fit: contain;
display: block;
}
.otb-list-name-wrap {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 320px;
}
.otb-list-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-list-rename input {
min-width: 220px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
</style>
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div class="otb-view-toggle">
<a class="portal-btn {% if view_mode == 'list' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='list') }}">List View</a>
<a class="portal-btn {% if view_mode == 'gallery' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='gallery') }}">Gallery View</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>{% if view_mode == 'gallery' %}Image Gallery{% else %}Files{% endif %}</h2>
<p>
{% if view_mode == 'gallery' %}
Browse uploaded images visually. Click a thumbnail to preview the full-size image.
{% else %}
Select files to delete, download, or send to Zip Workspace. Rename changes only the customer-facing name, not the immutable stored file.
{% endif %}
</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form id="bulk-actions-form" method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
</form>
{% if view_mode == 'gallery' %}
<div class="otb-gallery-grid">
{% for file in files %}
{% set visible_name = file.display_filename or file.original_filename %}
{% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %}
{% set ext = (file.extension or '')|lower %}
{% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %}
<div class="otb-gallery-card" title="Name: {{ visible_name }}&#10;Original: {{ file.original_filename }}&#10;Type: {{ file.mime_type or file.file_kind }}&#10;Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes&#10;Uploaded: {{ file.uploaded_at }}">
<div class="otb-gallery-thumb-wrap">
<input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
{% if is_image %}
<img
src="{{ url_for('main.thumbnail_file', file_id=file.id) }}"
alt="{{ visible_name }}"
class="otb-gallery-thumb preview-trigger"
data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}"
data-preview-title="{{ visible_name }}"
>
{% else %}
<div style="padding:20px;text-align:center;opacity:0.75;">No preview</div>
{% endif %}
</div>
<div class="otb-gallery-meta">
<div class="otb-gallery-name">{{ visible_name }}</div>
<div class="otb-gallery-sub">
{{ file.mime_type or file.file_kind }}<br>
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</div>
<div class="otb-gallery-actions">
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}">Preview</button>
{% endif %}
<a class="portal-btn" href="{{ url_for('main.download_file', file_id=file.id) }}">Download</a>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-gallery-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
{% set visible_name = file.display_filename or file.original_filename %}
{% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %}
{% set ext = (file.extension or '')|lower %}
{% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<div class="otb-list-name-wrap">
<div>
<strong>{{ visible_name }}</strong><br>
{% if file.display_filename %}
<span style="opacity:0.75;font-size:0.9rem;">Original: {{ file.original_filename }}</span><br>
{% endif %}
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}" style="margin-top:8px;">Preview</button>
{% endif %}
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-list-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Enter custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</article>
</section>
<div id="otb-image-modal" class="otb-modal-backdrop" aria-hidden="true">
<div class="otb-modal-card">
<div class="otb-modal-header">
<div id="otb-image-modal-title" class="otb-modal-title">Preview</div>
<button type="button" class="portal-btn" id="otb-image-modal-close">Close</button>
</div>
<div class="otb-modal-body">
<img id="otb-image-modal-img" src="" alt="">
</div>
</div>
</div>
<script>
(function () {
const modal = document.getElementById('otb-image-modal');
const modalImg = document.getElementById('otb-image-modal-img');
const modalTitle = document.getElementById('otb-image-modal-title');
const closeBtn = document.getElementById('otb-image-modal-close');
if (!modal || !modalImg || !modalTitle || !closeBtn) return;
function openPreview(url, title) {
modalImg.src = url;
modalImg.alt = title || 'Preview image';
modalTitle.textContent = title || 'Preview';
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
function closePreview() {
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
modalImg.src = '';
modalImg.alt = '';
}
document.querySelectorAll('.preview-trigger').forEach(function (el) {
el.addEventListener('click', function () {
const url = this.dataset.previewUrl || this.getAttribute('src');
const title = this.dataset.previewTitle || this.getAttribute('alt') || 'Preview';
if (url) openPreview(url, title);
});
});
closeBtn.addEventListener('click', closePreview);
modal.addEventListener('click', function (e) {
if (e.target === modal) closePreview();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePreview();
});
})();
</script>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

89
app/templates/cloud/device_new.html.bak.20260413-022612

@ -1,89 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Add Device - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Add Device</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Create a named source for uploads like a laptop, phone, tablet, or workstation.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
<section class="services-grid">
<article class="service-card status-beta" style="max-width:900px;">
<div class="service-card-header">
<div>
<h2>Device Details</h2>
<p>This creates a device source and its storage folders.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Setup</span>
</div>
</div>
<div class="service-card-actions">
<form method="post" action="{{ url_for('main.add_device') }}">
<div style="display:grid;gap:16px;max-width:680px;">
<div>
<label for="device_name" style="display:block;margin-bottom:6px;font-weight:700;">Device Name</label>
<input
id="device_name"
name="device_name"
type="text"
value="{{ device_name or '' }}"
placeholder="Example: Main Laptop"
style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:rgba(255,255,255,0.04);color:white;"
required
>
</div>
<div>
<label for="device_type" style="display:block;margin-bottom:6px;font-weight:700;">Device Type</label>
<select
id="device_type"
name="device_type"
style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:#0f1a2b;color:white;"
required
>
<option value="">Select type</option>
<option value="laptop" {% if device_type == 'laptop' %}selected{% endif %}>Laptop</option>
<option value="phone" {% if device_type == 'phone' %}selected{% endif %}>Phone</option>
<option value="tablet" {% if device_type == 'tablet' %}selected{% endif %}>Tablet</option>
<option value="desktop" {% if device_type == 'desktop' %}selected{% endif %}>Desktop</option>
<option value="workstation" {% if device_type == 'workstation' %}selected{% endif %}>Workstation</option>
<option value="other" {% if device_type == 'other' %}selected{% endif %}>Other</option>
</select>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="portal-btn primary" type="submit">Create Device</button>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Cancel</a>
</div>
</div>
</form>
</div>
</article>
</section>
{% endblock %}

324
app/templates/cloud/image_workshop.html

@ -0,0 +1,324 @@
{% extends "portal_base.html" %}
{% block portal_content %}
<style>
.image-workshop-wrap { max-width: 1200px; margin: 0 auto; }
.iw-toolbar { display:flex; gap:10px; flex-wrap:wrap; margin: 12px 0 18px; }
.iw-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:14px; }
.iw-tile { background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.12); border-radius:14px; padding:10px; }
.iw-tile img { width:100%; height:130px; object-fit:cover; border-radius:10px; cursor:pointer; background:#111; }
.iw-name { margin-top:8px; font-weight:700; word-break:break-word; font-size:.9rem; }
.iw-mini { display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; }
.iw-mini button, .iw-toolbar button, .iw-modal-actions button { cursor:pointer; }
.iw-modal {
display:none; position:fixed; z-index:9999; inset:0;
background:rgba(0,0,0,.82); padding:30px; overflow:auto;
}
.iw-modal-inner {
max-width:1100px; margin:0 auto; background:#0f172a;
border:1px solid rgba(255,255,255,.14); border-radius:18px; padding:18px;
}
.iw-preview-wrap { display:flex; justify-content:center; background:#050816; border-radius:14px; padding:16px; min-height:360px; }
#modalImage { max-width:100%; max-height:70vh; object-fit:contain; transition:filter .15s, transform .15s; }
.iw-modal-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; align-items:center; }
.iw-modal-actions input { min-width:260px; padding:8px; }
.iw-note { opacity:.8; margin-top:8px; }
.iw-modal-actions select {
background:#0f172a;
color:#e6eefc;
border:1px solid rgba(255,255,255,0.2);
border-radius:8px;
padding:6px 10px;
}
.iw-modal-actions select,
.iw-modal-actions select option,
#formatSelect,
#formatSelect option,
#sizeSelect,
#sizeSelect option {
background-color: #0f172a !important;
color: #e6eefc !important;
border: 1px solid rgba(255,255,255,0.28) !important;
}
.iw-modal-actions select,
#formatSelect,
#sizeSelect {
border-radius: 8px !important;
padding: 8px 10px !important;
min-width: 160px !important;
}
</style>
<div class="image-workshop-wrap">
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Image Workshop</h1>
<p class="portal-page-subtitle">Double-click an image to edit it. Max 25 images per batch.</p>
</div>
<div class="portal-toolbar">
<a class="portal-btn" href="/devices/{{ device_id }}/files?path=images">Back to Images</a>
<a class="portal-btn" href="/dashboard">Dashboard</a>
<a class="portal-btn" href="/health">Health</a>
</div>
</div>
<div class="service-card">
<div class="service-card-body">
<div class="iw-toolbar">
<button type="button" onclick="bulkRotate(0)">Original Rotation</button>
<button type="button" onclick="bulkRotate(90)">Rotate 90</button>
<button type="button" onclick="bulkRotate(180)">Rotate 180</button>
<button type="button" onclick="bulkRotate(270)">Rotate 270</button>
<button type="button" onclick="processAll()">Save All Changed</button>
</div>
<div id="grid" class="iw-grid"></div>
</div>
</div>
</div>
<div id="editorModal" class="iw-modal">
<div class="iw-modal-inner">
<h2 id="modalTitle">Edit Image</h2>
<div class="iw-preview-wrap">
<img id="modalImage" src="">
</div>
<div class="iw-modal-actions">
<button type="button" onclick="modalRotate(0)">Original</button>
<button type="button" onclick="modalRotate(90)">Rotate 90</button>
<button type="button" onclick="modalRotate(180)">Rotate 180</button>
<button type="button" onclick="modalRotate(270)">Rotate 270</button>
<button type="button" onclick="modalFilter(null)">Normal</button>
<button type="button" onclick="modalFilter('bw')">B/W</button>
<button type="button" onclick="modalFilter('sepia')">Sepia</button>
</div>
<div class="iw-modal-actions">
<label>New image name:</label>
<input id="saveName" type="text" placeholder="required name, no extension">
</div>
<div class="iw-modal-actions">
<label>Format:</label>
<select id="formatSelect">
<option value="">Keep Format</option>
<option value="jpg">JPG</option>
<option value="webp">WebP</option>
<option value="png">PNG</option>
</select>
<label>Size:</label>
<select id="sizeSelect">
<option value="">Original Size</option>
<option value="2000">2000px</option>
<option value="1600" selected>1600px Web</option>
<option value="1200">1200px Small Web</option>
<option value="800">800px Preview</option>
</select>
</div>
<div class="iw-modal-actions">
<button type="button" onclick="saveCurrentImage()">Save Image</button>
<button type="button" onclick="revertCurrent()">Revert to Original</button>
<button type="button" onclick="closeModal()">Close</button>
</div>
<p class="iw-note">Saving creates a new image in the images folder. Original files are not overwritten.</p>
</div>
</div>
<script>
let items = [];
let state = {};
let currentId = null;
function safeName(v){ return (v || "image").toString(); }
function loadSelection() {
try { items = JSON.parse(localStorage.getItem("imageSelection") || "[]"); }
catch { items = []; }
if (items.length > 25) {
alert("Image Workshop is limited to 25 images per batch.");
items = items.slice(0, 25);
}
items.forEach(i => {
if (!state[i.id]) state[i.id] = { rotation: 0, filter: null, format: null, name: "" };
});
render();
}
function render() {
const grid = document.getElementById("grid");
if (!items.length) {
grid.innerHTML = '<div class="job-empty">No images staged.</div>';
return;
}
grid.innerHTML = "";
items.forEach(item => {
const s = state[item.id] || { rotation:0, filter:null };
const filename = safeName(item.display_filename || item.original_filename || item.filename || item.name);
const filterCss = s.filter === "bw" ? "grayscale(1)" : (s.filter === "sepia" ? "sepia(1)" : "none");
const div = document.createElement("div");
div.className = "iw-tile";
div.innerHTML = `
<img ondblclick="openModal('${item.id}')" src="/files/${item.id}/thumb" style="transform:rotate(${s.rotation}deg);filter:${filterCss};">
<div class="iw-name">${filename}</div>
<div class="iw-mini">
<button type="button" onclick="rotateOne('${item.id}',0)">0</button>
<button type="button" onclick="rotateOne('${item.id}',90)">90</button>
<button type="button" onclick="rotateOne('${item.id}',180)">180</button>
<button type="button" onclick="rotateOne('${item.id}',270)">270</button>
</div>
`;
grid.appendChild(div);
});
}
function rotateOne(id, deg) {
state[id].rotation = deg;
render();
}
function bulkRotate(deg) {
Object.keys(state).forEach(id => state[id].rotation = deg);
render();
}
function getItem(id) {
return items.find(x => String(x.id) === String(id));
}
function openModal(id) {
currentId = id;
const item = getItem(id);
const s = state[id];
document.getElementById("modalTitle").textContent = safeName(item.filename || item.display_filename || item.original_filename || item.name);
document.getElementById("modalImage").src = "/files/" + id + "/inline";
document.getElementById("saveName").value = s.name || "";
const fmt = document.getElementById("formatSelect");
const size = document.getElementById("sizeSelect");
if (fmt) fmt.value = s.format || "";
if (size) size.value = s.max_size || "1600";
applyModalPreview();
document.getElementById("editorModal").style.display = "block";
}
function closeModal() {
document.getElementById("editorModal").style.display = "none";
currentId = null;
}
function applyModalPreview() {
if (!currentId) return;
const s = state[currentId];
const img = document.getElementById("modalImage");
img.style.transform = `rotate(${s.rotation || 0}deg)`;
img.style.filter = s.filter === "bw" ? "grayscale(1)" : (s.filter === "sepia" ? "sepia(1)" : "none");
}
function modalRotate(deg) {
state[currentId].rotation = deg;
applyModalPreview();
render();
}
function modalFilter(f) {
state[currentId].filter = f;
applyModalPreview();
render();
}
function revertCurrent() {
state[currentId] = { rotation: 0, filter: null, format: null, name: "" };
document.getElementById("saveName").value = "";
applyModalPreview();
render();
}
async function saveCurrentImage() {
if (!currentId) return;
const name = document.getElementById("saveName").value.trim();
if (!name) {
alert("Please enter a name for the new image.");
return;
}
state[currentId].name = name;
state[currentId].format = document.getElementById("formatSelect").value || null;
state[currentId].max_size = document.getElementById("sizeSelect").value || null;
const item = getItem(currentId);
const payload = { items: [item], state: { [currentId]: state[currentId] } };
const r = await fetch("/api/image/process", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload)
});
const d = await r.json();
if (!d.ok) {
alert(d.error || "Processing failed");
return;
}
alert("Saved image.");
closeModal();
}
async function processAll() {
const changed = items.filter(item => {
const s = state[item.id];
return s && (s.rotation || s.filter || s.format || s.name);
});
if (!changed.length) {
alert("No changed images to save.");
return;
}
for (const item of changed) {
if (!state[item.id].name) {
alert("Each changed image needs a new name before saving.");
openModal(item.id);
return;
}
}
const r = await fetch("/api/image/process", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ items: changed, state: state })
});
const d = await r.json();
if (!d.ok) {
alert(d.error || "Processing failed");
return;
}
alert("Saved " + d.processed.length + " image(s).");
}
document.getElementById("editorModal").addEventListener("click", function(e){
if (e.target === this) closeModal();
});
loadSelection();
</script>
{% endblock %}

96
app/templates/cloud/upload.html.bak.20260413-210211

@ -1,96 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Upload Files - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Upload Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Upload files into the <strong>{{ device.device_name }}</strong> device originals area.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
<section class="services-grid">
<article class="service-card status-beta" style="max-width:900px;">
<div class="service-card-header">
<div>
<h2>Upload to {{ device.device_name }}</h2>
<p>Selected files will be stored as immutable originals.</p>
</div>
<div>
<span class="service-badge service-badge-beta">{{ device.device_type|capitalize }}</span>
</div>
</div>
<div class="service-card-actions">
<form method="post" action="{{ url_for('main.upload_files', device_id=device.id) }}" enctype="multipart/form-data">
<div style="display:grid;gap:16px;max-width:780px;">
<div>
<label for="files" style="display:block;margin-bottom:6px;font-weight:700;">Choose Files</label>
<input
id="files"
name="files"
type="file" webkitdirectory directory
multiple
required
style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:rgba(255,255,255,0.04);color:white;"
>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="portal-btn primary" type="submit">Upload Selected Files</button>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Cancel</a>
</div>
<div style="opacity:0.8;font-size:0.95rem;">
Files uploaded here are stored in the device originals folder and recorded in the database.
</div>
</div>
<div id="upload-info" style="margin-top:10px;font-size:0.95rem;opacity:0.85;"></div>
<div id="upload-warning" style="margin-top:10px;color:#ffcc00;font-size:0.9rem;"></div>
<script>
const fileInput = document.getElementById('files');
const info = document.getElementById('upload-info');
const warn = document.getElementById('upload-warning');
fileInput.addEventListener('change', function() {
const count = this.files.length;
info.innerText = `Selected ${count} file(s)`;
if (count > 100) {
warn.innerText = "Large upload detected. Use WiFi or wired connection. Keep this tab open.";
} else {
warn.innerText = "";
}
});
</script>
</form>
</div>
</article>
</section>
{% endblock %}

74
app/templates/cloud/upload.html.bak.folder.20260413-190117

@ -1,74 +0,0 @@
{% extends "portal_base.html" %}
{% block title %}Upload Files - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Upload Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Upload files into the <strong>{{ device.device_name }}</strong> device originals area.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
<section class="services-grid">
<article class="service-card status-beta" style="max-width:900px;">
<div class="service-card-header">
<div>
<h2>Upload to {{ device.device_name }}</h2>
<p>Selected files will be stored as immutable originals.</p>
</div>
<div>
<span class="service-badge service-badge-beta">{{ device.device_type|capitalize }}</span>
</div>
</div>
<div class="service-card-actions">
<form method="post" action="{{ url_for('main.upload_files', device_id=device.id) }}" enctype="multipart/form-data">
<div style="display:grid;gap:16px;max-width:780px;">
<div>
<label for="files" style="display:block;margin-bottom:6px;font-weight:700;">Choose Files</label>
<input
id="files"
name="files"
type="file"
multiple
required
style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:rgba(255,255,255,0.04);color:white;"
>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="portal-btn primary" type="submit">Upload Selected Files</button>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Cancel</a>
</div>
<div style="opacity:0.8;font-size:0.95rem;">
Files uploaded here are stored in the device originals folder and recorded in the database.
</div>
</div>
</form>
</div>
</article>
</section>
{% endblock %}

29
app/templates/cloud/workshop.html

@ -78,7 +78,8 @@
<div class="service-card-header">
<div>
<h2>Queue Video Jobs</h2>
<p>Selected files from the device browser are staged here. Only checked staged files will be processed.</p>
<p>Selected files from the device browser are staged here. Only checked staged files will be processed. Limit: 5 original files per batch.</p>
<div id="queue-summary" class="job-empty" style="margin-top:10px;">Queue: loading... | Active users: loading...</div>
</div>
<div>
<span class="service-badge service-badge-beta">alpha3-l</span>
@ -164,6 +165,10 @@ function getSel(){
}
function setSel(items){
if(Array.isArray(items) && items.length > 5){
alert("Workshop staging is limited to 5 files at a time.");
items = items.slice(0, 5);
}
localStorage.setItem("videoSelection", JSON.stringify(items));
}
@ -336,9 +341,26 @@ function renderJobs(jobs){
}).join("")+'</div>';
}
function updateQueueSummary(){
fetch("/api/video/queue-summary")
.then(r=>r.json())
.then(d=>{
const el = document.getElementById("queue-summary");
if(!el) return;
el.innerHTML = `
<div class="job-empty">
Queue: ${d.queue_count} jobs &nbsp; | &nbsp; Active users: ${d.active_users}
</div>`;
})
.catch(()=>{});
}
function processWorkshop(){
let files=getCheckedStaged();
if(!files.length){alert("No staged files checked");return;}
if(files.length > 5){alert("Only 5 files may be processed in one batch.");return;}
const profiles = getSelectedProfiles();
if(!profiles.length){
@ -390,6 +412,9 @@ document.getElementById("manualRotationToggle").addEventListener("change", funct
renderSel();
loadJobs();
setInterval(loadJobs,5000);
setInterval(()=>{
loadJobs();
updateQueueSummary();
},5000);
</script>
{% endblock %}

112
app/templates/portal_base.html.bak.20260412-235158

@ -1,112 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}OTB Cloud{% endblock %}</title>
<style>
:root {
--bg: #0b1220;
--panel: #121b2d;
--panel-2: #16233b;
--text: #e9eef8;
--muted: #9fb0cf;
--line: rgba(255,255,255,0.10);
--accent: #4da3ff;
--danger: #ff6b6b;
--ok: #4fd18b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
background: linear-gradient(180deg, #08101d 0%, #0b1220 100%);
color: var(--text);
}
a { color: var(--accent); text-decoration: none; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 20px; }
.topbar {
border-bottom: 1px solid var(--line);
background: rgba(255,255,255,0.03);
}
.nav {
display: flex;
gap: 18px;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
max-width: 1200px;
margin: 0 auto;
}
.brand {
font-weight: bold;
font-size: 20px;
letter-spacing: 0.3px;
}
.nav-links {
display: flex;
gap: 14px;
flex-wrap: wrap;
align-items: center;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 20px;
box-shadow: 0 10px 24px rgba(0,0,0,0.18);
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.muted { color: var(--muted); }
.badge {
display: inline-block;
padding: 6px 10px;
border-radius: 999px;
background: rgba(77,163,255,0.12);
border: 1px solid rgba(77,163,255,0.22);
color: var(--text);
font-size: 12px;
}
.footer {
border-top: 1px solid var(--line);
margin-top: 28px;
color: var(--muted);
}
.footer .wrap {
padding-top: 16px;
padding-bottom: 24px;
}
.warn {
border-left: 4px solid var(--danger);
background: rgba(255,107,107,0.08);
padding: 14px 16px;
border-radius: 12px;
}
</style>
</head>
<body>
<div class="topbar">
<div class="nav">
<div class="brand">OTB Cloud</div>
<div class="nav-links">
<a href="/dashboard">Dashboard</a>
<a href="/auth/logout">Logout</a>
</div>
</div>
</div>
<div class="wrap">
{% block content %}{% endblock %}
</div>
<div class="footer">
<div class="wrap">
Temporary local portal base template for OTB Cloud v0.1.1. Replace this with the shared OTB portal base during integration.
</div>
</div>
</body>
</html>

170
patch.sh

@ -1,170 +0,0 @@
cd /opt/otb_cloud || exit 1
echo "===== backups ====="
STAMP=$(date +%Y%m%d-%H%M%S)
cp app/models/schema.sql /home/def/backuphere/schema.sql.$STAMP.bak
cp app/auth/utils.py /home/def/backuphere/utils.py.$STAMP.bak
cp VERSION /home/def/backuphere/VERSION.$STAMP.bak 2>/dev/null || true
cp PROJECT_STATE.md /home/def/backuphere/PROJECT_STATE.md.$STAMP.bak 2>/dev/null || true
cp README.md /home/def/backuphere/README.md.$STAMP.bak 2>/dev/null || true
echo "===== extend schema ====="
cat >> app/models/schema.sql <<'EOF'
-- ===============================
-- v1.1.0-alpha1 VIDEO JOB SYSTEM
-- ===============================
CREATE TABLE IF NOT EXISTS video_jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_id INT NOT NULL,
source_file_id BIGINT NULL,
source_relative_path VARCHAR(1000) NOT NULL,
source_original_filename VARCHAR(255) NOT NULL,
requested_profile VARCHAR(50) NOT NULL,
requested_gpu_preference VARCHAR(20) NOT NULL DEFAULT 'auto',
assigned_processor VARCHAR(20) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'queued',
progress_percent INT NOT NULL DEFAULT 0,
output_relative_path VARCHAR(1000) NULL,
output_file_id BIGINT NULL,
log_excerpt LONGTEXT NULL,
error_message LONGTEXT NULL,
gpu_seconds INT NOT NULL DEFAULT 0,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by_user_id INT NULL,
CONSTRAINT fk_video_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_video_jobs_device FOREIGN KEY (device_id) REFERENCES devices(id)
);
CREATE TABLE IF NOT EXISTS tenant_usage_metrics (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
storage_bytes_originals BIGINT NOT NULL DEFAULT 0,
storage_bytes_video BIGINT NOT NULL DEFAULT 0,
storage_bytes_archive BIGINT NOT NULL DEFAULT 0,
storage_bytes_lts BIGINT NOT NULL DEFAULT 0,
storage_bytes_total BIGINT NOT NULL DEFAULT 0,
gpu_seconds_intel BIGINT NOT NULL DEFAULT 0,
gpu_seconds_amd BIGINT NOT NULL DEFAULT 0,
gpu_seconds_cpu BIGINT NOT NULL DEFAULT 0,
completed_jobs INT NOT NULL DEFAULT 0,
failed_jobs INT NOT NULL DEFAULT 0,
accrued_storage_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00,
accrued_gpu_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00,
calculated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_metrics_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
EOF
echo "===== update device directory structure ====="
python3 <<'PY'
from pathlib import Path
p = Path("app/auth/utils.py")
txt = p.read_text()
old = """for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:"""
new = """for subdir in ["originals", "video", "video-workshop", "archive", "lts", "derived", "exports", "deleted", "tmp"]:"""
if old not in txt:
raise SystemExit("PATCH FAIL: device dir block not found")
txt = txt.replace(old, new)
p.write_text(txt)
print("device directory structure updated")
PY
echo "===== create service scaffolding ====="
mkdir -p app/services
cat > app/services/video_jobs.py <<'EOF'
def create_job(db, tenant_id, device_id, source_path, filename, profile):
return {
"tenant_id": tenant_id,
"device_id": device_id,
"source_path": source_path,
"filename": filename,
"profile": profile,
"status": "queued"
}
EOF
cat > app/services/video_metrics.py <<'EOF'
def recalc_metrics(db, tenant_id):
# placeholder for v1.1.0
return {"ok": True}
EOF
cat > app/services/gpu_select.py <<'EOF'
def select_processor():
# v1.1.0 logic placeholder
return "intel"
EOF
cat > app/services/video_profiles.py <<'EOF'
PROFILES = {
"portrait_web": "portrait web encode",
"landscape_web": "landscape web encode",
"high_quality_cpu": "cpu encode",
"archive_only": "no processing"
}
EOF
cat > app/services/video_paths.py <<'EOF'
def device_paths(base):
return {
"originals": f"{base}/originals",
"video": f"{base}/video",
"video_workshop": f"{base}/video-workshop",
"archive": f"{base}/archive",
"lts": f"{base}/lts"
}
EOF
echo "===== create worker scaffold ====="
cat > app/services/video_worker.py <<'EOF'
import time
def run_worker():
print("video worker starting (stub)")
while True:
time.sleep(10)
EOF
echo "===== bump version ====="
echo "v1.1.0-alpha1" > VERSION
echo "===== update PROJECT_STATE.md ====="
cat >> PROJECT_STATE.md <<'EOF'
## v1.1.0-alpha1 — Video System Foundation
- Added video_jobs table (processing queue)
- Added tenant_usage_metrics table (dashboard metrics)
- Added video service scaffolding (jobs, metrics, gpu select, profiles)
- Extended device structure to include:
- video
- video-workshop
- archive
- lts
- Prepared system for background worker architecture
Next step:
- Build video worker processing engine
EOF
echo "===== update README.md ====="
sed -i '1i\
## v1.1.0-alpha1 — Video System Foundation\n- Introduced video job queue system\n- Introduced tenant usage metrics\n- Added video processing scaffolding\n- Prepared for GPU worker processing\n' README.md
echo "===== verify ====="
python3 -m py_compile app/auth/utils.py
echo "===== done ====="

22
patch1.sh

@ -1,22 +0,0 @@
cd /opt/otb_cloud || exit 1
echo "===== version ====="
cat VERSION
echo
echo "===== new tables present in schema ====="
grep -n "CREATE TABLE IF NOT EXISTS video_jobs" -A25 app/models/schema.sql
echo
grep -n "CREATE TABLE IF NOT EXISTS tenant_usage_metrics" -A20 app/models/schema.sql
echo
echo "===== new services ====="
find app/services -maxdepth 1 -type f | sort
echo
echo "===== updated device dirs helper ====="
grep -n 'for subdir in \[' -A2 app/auth/utils.py
echo
echo "===== git status ====="
git status --short

27
scripts/make_test_handoff.py.bak.20260412-235158

@ -1,27 +0,0 @@
#!/usr/bin/env python3
import hashlib
import hmac
import os
import time
import urllib.parse
from dotenv import load_dotenv
load_dotenv()
secret = os.getenv("OTB_PORTAL_SHARED_SECRET", "change-me")
uid = os.getenv("OTB_TEST_UID", "1001")
email = os.getenv("OTB_TEST_EMAIL", "client@example.com")
ts = str(int(time.time()))
payload = f"{uid}|{email}|{ts}".encode("utf-8")
sig = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
params = urllib.parse.urlencode({
"uid": uid,
"email": email,
"ts": ts,
"sig": sig,
})
print(f"http://127.0.0.1:5090/auth/handoff?{params}")
Loading…
Cancel
Save