From 1f8863f449bfc527291843a13e63862eddeb1d03 Mon Sep 17 00:00:00 2001 From: Don Kingdon Date: Mon, 13 Apr 2026 05:14:46 +0000 Subject: [PATCH] Add selection actions, zip workspace, and deleted files workflow --- PROJECT_STATE.md | 67 ++-- README.md | 26 +- VERSION | 2 +- app/auth/utils.py | 2 + app/main/routes.py | 475 +++++++++++++++++++++++-- app/templates/cloud/dashboard.html | 8 +- app/templates/cloud/deleted_files.html | 102 ++++++ app/templates/cloud/device_files.html | 103 ++++-- app/templates/cloud/zip_workspace.html | 93 +++++ 9 files changed, 738 insertions(+), 140 deletions(-) create mode 100644 app/templates/cloud/deleted_files.html create mode 100644 app/templates/cloud/zip_workspace.html diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index e22fa7d..d187693 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,7 +4,7 @@ OTB Cloud ## Current version -v0.2.1 +v0.2.2 ## Build date 2026-04-12 @@ -18,53 +18,30 @@ vault3 ## Purpose 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 -- Flask app factory -- Main blueprint -- Auth blueprint -- MariaDB connection helper -- Signed handoff endpoint -- Auth-protected dashboard +- Portal handoff from OTB Billing - Branded OTB portal shell styling -- SQL schema file -- DB bootstrap script -- Storage bootstrap scripts -- Gunicorn systemd service on vault3 -- Mintme reverse proxy in place -- OTB Billing signed handoff working -- Add Device flow -- Remove Device flow for empty devices -- Browser upload flow to device originals -- Device file browser page +- User-created devices +- Device add/remove +- Browser upload to device originals +- Device file browser +- Checkbox selection actions +- Soft-delete to deleted folder +- Zip workspace staging and zip export +- Deleted files page with hard delete +- Exports 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 -1. Add single-file download -2. Add searchable file listing -3. Add rename basename-only flow -4. Add zip export flow +1. Add single-file download buttons in more places +2. Add basename-only rename flow +3. Add searchable file listing +4. Add bulk folder upload 5. Add media processing jobs 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. diff --git a/README.md b/README.md index 1951d56..1280339 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # 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 - Added device file browser page - Added Browse Files action per device @@ -50,19 +60,3 @@ - Device-based tenant storage model defined - Shared OTB portal template architecture planned - 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 diff --git a/VERSION b/VERSION index 22c08f7..f0cfd3b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.2.1 +v0.2.2 diff --git a/app/auth/utils.py b/app/auth/utils.py index 9e8d265..04c3cc0 100644 --- a/app/auth/utils.py +++ b/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 / "support").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): tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug diff --git a/app/main/routes.py b/app/main/routes.py index 67b4a28..a3d21ad 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -1,9 +1,11 @@ from functools import wraps from pathlib import Path 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.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") 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: @@ -154,22 +215,13 @@ def add_device(): @portal_session_required def delete_device(device_id: int): 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: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) + 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 @@ -218,17 +270,7 @@ def delete_device(device_id: int): @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() + device = _get_device_for_tenant(db, device_id) if not device: 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") return redirect(url_for("main.dashboard")) -@bp.route("/devices//files", methods=["GET"]) +@bp.route("/files//download", methods=["GET"]) @portal_session_required -def browse_device_files(device_id: int): +def download_file(file_id: int): db = get_db() with db.cursor() as cur: cur.execute( """ - SELECT id, device_name, device_type, relative_path - FROM devices + SELECT id, original_filename, relative_path + FROM files 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: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) + 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//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//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//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//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//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//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 diff --git a/app/templates/cloud/dashboard.html b/app/templates/cloud/dashboard.html index 8319bcc..0b14f0a 100644 --- a/app/templates/cloud/dashboard.html +++ b/app/templates/cloud/dashboard.html @@ -15,6 +15,8 @@
@@ -77,7 +79,7 @@

Current scope

-

OTB Cloud now supports browser uploads and device file browsing.

+

OTB Cloud now supports browser uploads, browsing, zip staging, and soft delete.

Live @@ -86,7 +88,7 @@

- 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.

@@ -122,7 +124,7 @@

- 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.

diff --git a/app/templates/cloud/deleted_files.html b/app/templates/cloud/deleted_files.html new file mode 100644 index 0000000..c82500e --- /dev/null +++ b/app/templates/cloud/deleted_files.html @@ -0,0 +1,102 @@ +{% extends "portal_base.html" %} + +{% block title %}Deleted Files - OTB Cloud{% endblock %} + +{% block portal_content %} +
+
+

Deleted Files

+

{{ user_email }}

+

+ Deleted files are retained for up to 24 hours unless you hard-delete them immediately. +

+
+ + +
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ category|capitalize }}: {{ message }} +
+ {% endfor %} +
+ {% endif %} +{% endwith %} + +{% if files %} +
+
+
+
+

Deleted Files

+

Files here are pending retention expiry or hard delete.

+
+
+ {{ files|length }} items +
+
+ +
+ + + + + + + + + + + + {% for file in files %} + + + + + + + + {% endfor %} + +
NameDeviceSizeDeleted AtAction
+ {{ file.original_filename }}
+ {{ file.relative_path }} +
+ {{ file.device_name or 'Unknown' }}
+ {{ file.device_type or '' }} +
+ {{ "{:,}".format(file.size_bytes or 0) }} bytes + + {{ file.deleted_at }} + +
+ +
+
+
+
+
+{% else %} +
+
+
+
+

No deleted files

+

There are currently no files in the deleted area.

+
+
+ Clear +
+
+
+
+{% endif %} +{% endblock %} diff --git a/app/templates/cloud/device_files.html b/app/templates/cloud/device_files.html index 29714c1..ff59c40 100644 --- a/app/templates/cloud/device_files.html +++ b/app/templates/cloud/device_files.html @@ -15,6 +15,7 @@ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ category|capitalize }}: {{ message }} +
+ {% endfor %} +
+ {% endif %} +{% endwith %} + {% if files %}

Files

-

Files recorded in the database for this device.

+

Select files to delete, download, or send to Zip Workspace.

DB-backed @@ -38,44 +51,56 @@
- - - - - - - - - - - - {% for file in files %} - - - - - - - - {% endfor %} - -
NameKindSizeUploadedPath
- {{ file.original_filename }}
- - SHA256: {{ file.sha256 }} - -
- {{ file.file_kind }} - {% if file.is_immutable %} -
immutable - {% endif %} -
- {{ "{:,}".format(file.size_bytes or 0) }} bytes - - {{ file.uploaded_at }} - - {{ file.relative_path }} -
+
+
+ + + +
+ + + + + + + + + + + + + + {% for file in files %} + + + + + + + + + {% endfor %} + +
+ + NameKindSizeUploadedPath
+ + + {{ file.original_filename }}
+ SHA256: {{ file.sha256 }} +
+ {{ file.file_kind }} + {% if file.is_immutable %} +
immutable + {% endif %} +
+ {{ "{:,}".format(file.size_bytes or 0) }} bytes + + {{ file.uploaded_at }} + + {{ file.relative_path }} +
+
diff --git a/app/templates/cloud/zip_workspace.html b/app/templates/cloud/zip_workspace.html new file mode 100644 index 0000000..0c6906b --- /dev/null +++ b/app/templates/cloud/zip_workspace.html @@ -0,0 +1,93 @@ +{% extends "portal_base.html" %} + +{% block title %}Zip Workspace - OTB Cloud{% endblock %} + +{% block portal_content %} +
+
+

Zip Workspace

+

{{ user_email }}

+

+ Stage selected files here, then create a zip archive in your exports area. +

+
+ + +
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ category|capitalize }}: {{ message }} +
+ {% endfor %} +
+ {% endif %} +{% endwith %} + +
+
+
+
+

Staged Files

+

These are temporary working copies only.

+
+
+ {{ staged_files|length }} staged +
+
+ +
+ {% if staged_files %} +
+ +
+
    + {% for item in staged_files %} +
  • + {{ item.name }}
    + {{ "{:,}".format(item.size_bytes) }} bytes • {{ item.path }} +
  • + {% endfor %} +
+ {% else %} +

No files are currently staged.

+ {% endif %} +
+
+ +
+
+
+

Exports

+

Completed zip files are stored here for download.

+
+
+ {{ export_files|length }} exports +
+
+ +
+ {% if export_files %} +
    + {% for item in export_files %} +
  • + {{ item.name }}
    + {{ "{:,}".format(item.size_bytes) }} bytes • {{ item.path }}
    + Download Zip +
  • + {% endfor %} +
+ {% else %} +

No export zip files yet.

+ {% endif %} +
+
+
+{% endblock %}