Browse Source

Add selection actions, zip workspace, and deleted files workflow

master
Don Kingdon 3 weeks ago
parent
commit
1f8863f449
  1. 67
      PROJECT_STATE.md
  2. 26
      README.md
  3. 2
      VERSION
  4. 2
      app/auth/utils.py
  5. 475
      app/main/routes.py
  6. 8
      app/templates/cloud/dashboard.html
  7. 102
      app/templates/cloud/deleted_files.html
  8. 103
      app/templates/cloud/device_files.html
  9. 93
      app/templates/cloud/zip_workspace.html

67
PROJECT_STATE.md

@ -4,7 +4,7 @@
OTB Cloud OTB Cloud
## Current version ## Current version
v0.2.1 v0.2.2
## Build date ## Build date
2026-04-12 2026-04-12
@ -18,53 +18,30 @@ vault3
## Purpose ## Purpose
Portal-authenticated secure backup and storage platform for customer files, including images, videos, documents, and other uploaded data. Portal-authenticated secure backup and storage platform for customer files, including images, videos, documents, and other uploaded data.
## Core requirements locked in
- Shared OTB branding, nav, footer, favicon
- Portal login / auth handoff through OTB Billing
- No unauthenticated file/account access
- MariaDB backend
- Vault3 storage root at `/tank/backups/otb-cloud`
- Tenant-isolated storage
- User-created devices
- Immutable originals
- Derived-file processing workflow
- Search by filename and date
- Bulk zip export
- Audit logging
- Owner-approved admin support access using one-time token
## Current implemented scaffold ## Current implemented scaffold
- Flask app factory - Portal handoff from OTB Billing
- Main blueprint
- Auth blueprint
- MariaDB connection helper
- Signed handoff endpoint
- Auth-protected dashboard
- Branded OTB portal shell styling - Branded OTB portal shell styling
- SQL schema file - User-created devices
- DB bootstrap script - Device add/remove
- Storage bootstrap scripts - Browser upload to device originals
- Gunicorn systemd service on vault3 - Device file browser
- Mintme reverse proxy in place - Checkbox selection actions
- OTB Billing signed handoff working - Soft-delete to deleted folder
- Add Device flow - Zip workspace staging and zip export
- Remove Device flow for empty devices - Deleted files page with hard delete
- Browser upload flow to device originals - Exports page
- Device file browser page
## Retention and safety notes
- Original files are stored as immutable originals
- Deleted files are retained in the deleted area for up to 24 hours
- Deleted files can also be hard-deleted immediately by the user
- Zip staging copies are temporary working copies
- Successful zip creation clears staged copies but does not affect original source files
## Immediate next tasks ## Immediate next tasks
1. Add single-file download 1. Add single-file download buttons in more places
2. Add searchable file listing 2. Add basename-only rename flow
3. Add rename basename-only flow 3. Add searchable file listing
4. Add zip export flow 4. Add bulk folder upload
5. Add media processing jobs 5. Add media processing jobs
6. Add derived/original filtering 6. Add derived/original filtering
## Notes
Original uploaded files should remain preserved and effectively read-only.
Any user-facing edits or processing outputs should create derivative files.
Admin access should require owner-issued one-time support authorization.
New tenants no longer receive default devices automatically; devices are now user-created.
Devices can only be removed when no files are associated with them.
Browser uploads write original files into device-specific originals directories and create DB records.
The device browser is DB-backed and tenant-scoped.

26
README.md

@ -1,5 +1,15 @@
# OTB Cloud # OTB Cloud
## v0.2.2 - 2026-04-12
- Added checkbox selection to device file browser
- Added soft-delete selected files workflow
- Added single-selection download action
- Added zip workspace staging flow
- Added zip creation into tenant exports directory
- Added exports listing page
- Added deleted files page with hard delete option
- Added 24-hour deleted-file retention note and purge-on-view behavior
## v0.2.1 - 2026-04-12 ## v0.2.1 - 2026-04-12
- Added device file browser page - Added device file browser page
- Added Browse Files action per device - Added Browse Files action per device
@ -50,19 +60,3 @@
- Device-based tenant storage model defined - Device-based tenant storage model defined
- Shared OTB portal template architecture planned - Shared OTB portal template architecture planned
- Core project documentation files added - Core project documentation files added
---
## Summary
OTB Cloud is a private portal-authenticated backup and storage platform for Outsidethebox.top.
Primary goals:
- Secure backup and storage for documents, images, videos, and uploaded files
- Per-customer tenant isolation
- Device-based organization
- Immutable original uploads
- Derived file workflow for processing and edits
- Searchable file library
- Bulk upload and bulk export support
- Audit logging
- Owner-approved admin support access using one-time token workflow

2
VERSION

@ -1 +1 @@
v0.2.1 v0.2.2

2
app/auth/utils.py

@ -123,6 +123,8 @@ def create_tenant_root(tenant_slug: str):
(tenant_root / "logs").mkdir(parents=True, exist_ok=True) (tenant_root / "logs").mkdir(parents=True, exist_ok=True)
(tenant_root / "support").mkdir(parents=True, exist_ok=True) (tenant_root / "support").mkdir(parents=True, exist_ok=True)
(tenant_root / "devices").mkdir(parents=True, exist_ok=True) (tenant_root / "devices").mkdir(parents=True, exist_ok=True)
(tenant_root / "zip_staging").mkdir(parents=True, exist_ok=True)
(tenant_root / "exports").mkdir(parents=True, exist_ok=True)
def create_device_directories(tenant_slug: str, device_path: str): def create_device_directories(tenant_slug: str, device_path: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug

475
app/main/routes.py

@ -1,9 +1,11 @@
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from werkzeug.utils import secure_filename import shutil
import zipfile
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app 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.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256
@ -31,6 +33,65 @@ def _stored_name(original_name: str) -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}" 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("/") @bp.route("/")
def index(): def index():
if "otb_user_id" in session: if "otb_user_id" in session:
@ -154,22 +215,13 @@ def add_device():
@portal_session_required @portal_session_required
def delete_device(device_id: int): def delete_device(device_id: int):
db = get_db() db = get_db()
device = _get_device_for_tenant(db, device_id)
with db.cursor() as cur: if not device:
cur.execute( flash("Device not found.", "warning")
""" return redirect(url_for("main.dashboard"))
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"))
with db.cursor() as cur:
cur.execute( cur.execute(
""" """
SELECT COUNT(*) AS file_count SELECT COUNT(*) AS file_count
@ -218,17 +270,7 @@ def delete_device(device_id: int):
@portal_session_required @portal_session_required
def upload_files(device_id: int): def upload_files(device_id: int):
db = get_db() db = get_db()
device = _get_device_for_tenant(db, device_id)
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: if not device:
flash("Device not found.", "warning") flash("Device not found.", "warning")
@ -330,26 +372,387 @@ def upload_files(device_id: int):
flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success") flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success")
return redirect(url_for("main.dashboard")) return redirect(url_for("main.dashboard"))
@bp.route("/devices/<int:device_id>/files", methods=["GET"]) @bp.route("/files/<int:file_id>/download", methods=["GET"])
@portal_session_required @portal_session_required
def browse_device_files(device_id: int): def download_file(file_id: int):
db = get_db() db = get_db()
with db.cursor() as cur: with db.cursor() as cur:
cur.execute( cur.execute(
""" """
SELECT id, device_name, device_type, relative_path SELECT id, original_filename, relative_path
FROM devices FROM files
WHERE id = %s AND tenant_id = %s WHERE id = %s AND tenant_id = %s
""", """,
(device_id, session["otb_tenant_id"]), (file_id, session["otb_tenant_id"]),
) )
device = cur.fetchone() file_row = cur.fetchone()
if not device: if not file_row:
flash("Device not found.", "warning") flash("File not found.", "warning")
return redirect(url_for("main.dashboard")) 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( cur.execute(
""" """
SELECT SELECT

8
app/templates/cloud/dashboard.html

@ -15,6 +15,8 @@
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;"> <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;"> <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.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="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a> <a class="portal-btn" href="/auth/logout">Logout</a>
</div> </div>
@ -77,7 +79,7 @@
<div class="service-card-header"> <div class="service-card-header">
<div> <div>
<h2>Current scope</h2> <h2>Current scope</h2>
<p>OTB Cloud now supports browser uploads and device file browsing.</p> <p>OTB Cloud now supports browser uploads, browsing, zip staging, and soft delete.</p>
</div> </div>
<div> <div>
<span class="service-badge service-badge-beta">Live</span> <span class="service-badge service-badge-beta">Live</span>
@ -86,7 +88,7 @@
<div class="service-card-actions"> <div class="service-card-actions">
<p style="margin:0;"> <p style="margin:0;">
Next steps are single-file download, searchable library pages, zip export, and media processing jobs. Next steps are basename-only rename, searchable library pages, folder upload, and media processing jobs.
</p> </p>
</div> </div>
</article> </article>
@ -122,7 +124,7 @@
<div class="service-card-actions"> <div class="service-card-actions">
<p style="margin:0;"> <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. After adding a device, you can upload files, browse them, soft-delete them, and stage them for zip export.
</p> </p>
</div> </div>
</article> </article>

102
app/templates/cloud/deleted_files.html

@ -0,0 +1,102 @@
{% 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 %}

103
app/templates/cloud/device_files.html

@ -15,6 +15,7 @@
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;"> <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;"> <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 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> <a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div> </div>
<div style="text-align:right;font-size:14px;opacity:0.95;"> <div style="text-align:right;font-size:14px;opacity:0.95;">
@ -24,13 +25,25 @@
</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 %} {% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;"> <section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta"> <article class="service-card status-beta">
<div class="service-card-header"> <div class="service-card-header">
<div> <div>
<h2>Files</h2> <h2>Files</h2>
<p>Files recorded in the database for this device.</p> <p>Select files to delete, download, or send to Zip Workspace.</p>
</div> </div>
<div> <div>
<span class="service-badge service-badge-beta">DB-backed</span> <span class="service-badge service-badge-beta">DB-backed</span>
@ -38,44 +51,56 @@
</div> </div>
<div class="service-card-actions" style="overflow-x:auto;"> <div class="service-card-actions" style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;"> <form method="post">
<thead> <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<tr> <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>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th> <button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th> <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>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th> </div>
<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> <table style="width:100%;border-collapse:collapse;">
</tr> <thead>
</thead> <tr>
<tbody> <th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
{% for file in files %} <input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
<tr> </th>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> <th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<strong>{{ file.original_filename }}</strong><br> <th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<span style="opacity:0.75;font-size:0.9rem;"> <th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
SHA256: {{ file.sha256 }} <th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
</span> <th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</td> </tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> </thead>
{{ file.file_kind }} <tbody>
{% if file.is_immutable %} {% for file in files %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span> <tr>
{% endif %} <td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
</td> <input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}">
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> </td>
{{ "{:,}".format(file.size_bytes or 0) }} bytes <td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
</td> <strong>{{ file.original_filename }}</strong><br>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> <span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
{{ file.uploaded_at }} </td>
</td> <td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;"> {{ file.file_kind }}
{{ file.relative_path }} {% if file.is_immutable %}
</td> <br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
</tr> {% endif %}
{% endfor %} </td>
</tbody> <td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
</table> {{ "{:,}".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> </div>
</article> </article>
</section> </section>

93
app/templates/cloud/zip_workspace.html

@ -0,0 +1,93 @@
{% extends "portal_base.html" %}
{% block title %}Zip Workspace - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Zip Workspace</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Stage selected files here, then create a zip archive in your exports 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">
<div class="service-card-header">
<div>
<h2>Staged Files</h2>
<p>These are temporary working copies only.</p>
</div>
<div>
<span class="service-badge service-badge-beta">{{ staged_files|length }} staged</span>
</div>
</div>
<div class="service-card-actions">
{% if staged_files %}
<form method="post" action="{{ url_for('main.create_zip_from_workspace') }}" style="margin-bottom:14px;">
<button class="portal-btn primary" type="submit">Zip It</button>
</form>
<ul style="padding-left:18px; margin:0;">
{% for item in staged_files %}
<li style="margin-bottom:8px;">
<strong>{{ item.name }}</strong><br>
<span style="opacity:0.75;">{{ "{:,}".format(item.size_bytes) }} bytes • {{ item.path }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p style="margin:0;">No files are currently staged.</p>
{% endif %}
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Exports</h2>
<p>Completed zip files are stored here for download.</p>
</div>
<div>
<span class="service-badge service-badge-beta">{{ export_files|length }} exports</span>
</div>
</div>
<div class="service-card-actions">
{% if export_files %}
<ul style="padding-left:18px; margin:0;">
{% for item in export_files %}
<li style="margin-bottom:10px;">
<strong>{{ item.name }}</strong><br>
<span style="opacity:0.75;">{{ "{:,}".format(item.size_bytes) }} bytes • {{ item.path }}</span><br>
<a class="portal-btn" style="margin-top:8px;display:inline-block;" href="{{ url_for('main.download_export', filename=item.name) }}">Download Zip</a>
</li>
{% endfor %}
</ul>
{% else %}
<p style="margin:0;">No export zip files yet.</p>
{% endif %}
</div>
</article>
</section>
{% endblock %}
Loading…
Cancel
Save