Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 110 additions & 7 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import List
from pathlib import Path
import random
import asyncio

import csv
import os
Expand Down Expand Up @@ -122,6 +123,10 @@ def setup_logging():
from auth.supabase.connection import create_supabase_client


# Lock to serialize backup and restore operations
db_lock = asyncio.Lock()


# local udata prefernces
UDAT_FILE = "./etc/udat.json"
# Create if not exists
Expand Down Expand Up @@ -255,6 +260,14 @@ def highlight_json_changes(old_json_str, new_json_str):
return '\n'.join(old_json_highlighted), '\n'.join(new_json_highlighted)


def apply_column_order(columns, order_pref):
"""Reorder columns based on user preference."""
if isinstance(order_pref, list):
ordered = [c for c in order_pref if c in columns]
remaining = [c for c in columns if c not in ordered]
return ordered + remaining
return columns

async def get_relationship_data(obj):
relationship_data = {}
for relationship in obj.__mapper__.relationships:
Expand Down Expand Up @@ -311,6 +324,47 @@ async def get_relationship_data(obj):
return relationship_data


def _pg_env():
env = os.environ.copy()
env.setdefault("PGHOST", "localhost")
env.setdefault("PGPORT", "5445")
env.setdefault("PGUSER", env.get("USER", "bloom"))
env.setdefault("PGPASSWORD", env.get("PGPASSWORD", "passw0rd"))
env.setdefault("PGDBNAME", env.get("PGDBNAME", "bloom"))
return env


def pg_dump_file(out_path: Path):
env = _pg_env()
cmd = ["pg_dump", "-Fp", env["PGDBNAME"]]
with open(out_path, "w") as fh:
subprocess.run(cmd, stdout=fh, check=True, env=env)


def pg_restore_file(sql_path: Path):
env = _pg_env()
# Drop existing objects to ensure the restore can proceed
drop_cmd = ["psql", env["PGDBNAME"], "-c", "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;"]
subprocess.run(drop_cmd, check=True, env=env)

cmd = ["psql", env["PGDBNAME"], "-v", "ON_ERROR_STOP=1"]

with open(sql_path, "r") as fh:
try:
subprocess.run(
cmd,
stdin=fh,
check=True,
env=env,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Database restore failed: {e.stderr.strip()}"
) from e


class RequireAuthException(HTTPException):
def __init__(self, detail: str):
super().__init__(status_code=403, detail=detail)
Expand Down Expand Up @@ -729,6 +783,8 @@ async def query_by_euids(request: Request, file_euids: str = Form(...)):
table_data.append(row)

user_data = request.session.get("user_data", {})
order_pref = user_data.get("search_columns_order")
columns = apply_column_order(columns, order_pref)
style = {"skin_css": user_data.get("style_css", "static/skins/bloom.css")}

content = templates.get_template("search_results.html").render(
Expand Down Expand Up @@ -804,6 +860,13 @@ async def admin(request: Request, _auth=Depends(require_auth), dest="na"):
] # Get just the file names

printer_info["style_css"] = csss

backup_path = user_data.get("db_backup_path", "./db_backups")
if os.path.isdir(backup_path):
backup_files = sorted([p.name for p in Path(backup_path).glob("*.sql")], reverse=True)
else:
backup_files = []

style = {"skin_css": user_data.get("style_css", "static/skins/bloom.css")}

# Rendering the template with the dynamic content
Expand All @@ -813,6 +876,8 @@ async def admin(request: Request, _auth=Depends(require_auth), dest="na"):
user_data=user_data,
printer_info=printer_info,
dest_section=dest_section,
backup_path=backup_path,
backups=backup_files,
udat=request.session["user_data"],
)

Expand Down Expand Up @@ -851,6 +916,33 @@ async def update_preference(request: Request, auth: dict = Depends(require_auth)
return {"status": "error", "message": "User not found in user data"}


@app.post("/db_backup")
async def db_backup(request: Request, _auth=Depends(require_auth)):
backup_path = request.session["user_data"].get("db_backup_path", "./db_backups")
os.makedirs(backup_path, exist_ok=True)
outfile = Path(backup_path) / f"backup_{get_clean_timestamp()}.sql"
async with db_lock:
await asyncio.to_thread(pg_dump_file, outfile)
return RedirectResponse(url="/admin?dest=backup", status_code=303)


@app.post("/db_restore")
async def db_restore(request: Request, filename: str = Form(...), _auth=Depends(require_auth)):
backup_path = request.session["user_data"].get("db_backup_path", "./db_backups")
target = Path(backup_path) / filename
if not target.exists():
raise HTTPException(status_code=404, detail="Backup not found")
os.makedirs(backup_path, exist_ok=True)
new_backup = Path(backup_path) / f"pre_restore_{get_clean_timestamp()}.sql"
async with db_lock:
await asyncio.to_thread(pg_dump_file, new_backup)
try:
await asyncio.to_thread(pg_restore_file, target)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return RedirectResponse(url="/admin?dest=backup", status_code=303)


@app.get("/queue_details", response_class=HTMLResponse)
async def queue_details(
request: Request, queue_euid, page=1, _auth=Depends(require_auth)
Expand Down Expand Up @@ -1940,10 +2032,12 @@ async def delete_edge(request: Request, _auth=Depends(require_auth)):


def generate_unique_upload_key():
color = random.choice(BVARS.pantone_colors)
invertebrate = random.choice(BVARS.marine_invertebrates)
number = random.randint(0, 1000000)
return f"{color.replace(' ','_')}_{invertebrate.replace(' ','_')}_{number}"
"""Return a datestamp for default file set names.

The datestamp format follows ``YYYYMMDDTHHMMSS`` as required when a
file set name isn't explicitly provided.
"""
return datetime.utcnow().strftime("%Y%m%dT%H%M%S")



Expand Down Expand Up @@ -2276,7 +2370,12 @@ async def create_file(
user_data = request.session.get("user_data", {})
style = {"skin_css": user_data.get("style_css", "static/skins/bloom.css")}
content = templates.get_template("create_file_report.html").render(
request=request, results=results, style=style, udat=user_data
request=request,
results=results,
style=style,
udat=user_data,
file_set_euid=new_file_set.euid,
file_set_name=file_set_name,
)

return HTMLResponse(content=content)
Expand Down Expand Up @@ -2505,6 +2604,8 @@ async def search_files(
table_data.append(row)

user_data = request.session.get("user_data", {})
order_pref = user_data.get("search_columns_order")
columns = apply_column_order(columns, order_pref)
style = {"skin_css": user_data.get("style_css", "static/skins/bloom.css")}
fset_templates = bobdb.query_template_by_component_v2("file","file_set","generic","1.0")

Expand Down Expand Up @@ -2714,6 +2815,8 @@ async def search_file_sets(
table_data.append(row)

user_data = request.session.get("user_data", {})
order_pref = user_data.get("file_set_search_columns_order")
columns = apply_column_order(columns, order_pref)
style = {"skin_css": user_data.get("style_css", "static/skins/bloom.css")}

num_results = len(table_data)
Expand Down Expand Up @@ -3115,11 +3218,11 @@ def directory_listing(directory: Path, file_path: str) -> HTMLResponse:
for item in items:
if item.is_dir():
files.append(
f'<li><a href="/serve_endpoint/{file_path.lstrip('/')}/{item.name}/">{item.name}/</a></li>'
f"<li><a href='/serve_endpoint/{file_path.lstrip('/')}/{item.name}/'>{item.name}/</a></li>"
)
else:
files.append(
f'<li><a href="/serve_endpoint/{file_path.lstrip('/')}/{item.name}">{item.name}</a></li>'
f"<li><a href='/serve_endpoint/{file_path.lstrip('/')}/{item.name}'>{item.name}</a></li>"
)
print('PPPPPP', str(parent_path))
html_content = f"""
Expand Down
35 changes: 35 additions & 0 deletions templates/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,41 @@ <h2>
<a style="display: none;" href="/workflow_summary">Original Raw Workflow View (not intended for operational use)</a>
</h2>

<div id="backup-section">
<h3>Database Backups</h3>
<table>
<tr>
<td>Backup Directory:</td>
<td>
<input type="text" value="{{ user_data.get('db_backup_path', './db_backups') }}" onchange="updatePreferenceAndReload('db_backup_path', this.value)">
<form method="post" action="/db_backup" style="display:inline;">
<button type="submit">Dump Backup</button>
</form>
</td>
</tr>
</table>

{% if backups %}
<table border="1">
<tr><th>Backup File</th><th>Action</th></tr>
{% for b in backups %}
<tr>
<td>{{ b }}</td>
<td>
<form method="post" action="/db_restore" onsubmit="return confirm('Restore from {{ b }}? This will overwrite the current database.');">

<input type="hidden" name="filename" value="{{ b }}">
<button type="submit">Restore</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>No backups found in {{ backup_path }}</p>
{% endif %}
</div>


<script>
function updatePreferenceAndReload(key, value) {
Expand Down
7 changes: 7 additions & 0 deletions templates/create_file_report.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@
{% include 'bloom_header.html' %}

<h1>Create File Report</h1>
{% if file_set_euid %}
<p>File Set:
<a target="_blank" href="euid_details?euid={{ file_set_euid }}">
{{ file_set_name }}
</a>
</p>
{% endif %}
<hr>
<table>
<thead>
Expand Down
56 changes: 33 additions & 23 deletions templates/file_set_search_results.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,34 @@
{% set page_title = 'File Search Results' %}
<title>{{ page_title }}</title>
{% set bloom_mod = 'dewey' %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/base/jquery-ui.css">
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js"></script>

<link rel="stylesheet" type="text/css" href="{{ style.skin_css }}">
<link rel="stylesheet" type="text/css" href="static/style.css">
</head>
<body>
{% include 'bloom_header.html' %}

<h1>{{ num_results }} Results</h1>
<div style="display:flex; justify-content:space-between; align-items:center;">
<h1 style="margin:0;">{{ num_results }} Results</h1>
<div>
<button onclick="openColumnModal()" type="button">Edit Column Order</button>
<button onclick="downloadTableAsTSV()" type="button">⬇️</button>
</div>
</div>
<div id="column-modal" title="Column Order" style="display:none;">
<ul id="column-list">{% for col in columns %}<li class="ui-state-default" data-col="{{ col }}">{{ col }}</li>{% endfor %}</ul>
<button onclick="saveColumnOrder()" type="button">Save</button>
</div>

<table id="results-table" border="1">
<thead>
<tr>
<th>Link</th> <!-- New column header for the link -->
{% for column in columns %}
<th onclick="sortTable({{ loop.index0 }})">{{ column }}</th>
<th onclick="sortTableByHeader(this)">{{ column }}</th>
{% endfor %}
</tr>
</thead>
Expand Down Expand Up @@ -47,20 +60,20 @@ <h1>{{ num_results }} Results</h1>

<a href="/dewey">Go Back</a>

<button class="floating-button" onclick="downloadTableAsTSV()">⬇️ Download TSV</button>

<script>
let sortDirections = Array({{ columns | length }}).fill(true); // Track sort direction for each column

function sortTable(columnIndex) {
function sortTableByHeader(header) {
const table = document.getElementById('results-table');
const columnIndex = Array.from(header.parentNode.children).indexOf(header) - 1; // subtract 1 for the link column
if (columnIndex < 0) return;
const tbody = table.tBodies[0];
const rows = Array.from(tbody.rows);

const direction = sortDirections[columnIndex] ? 1 : -1;

const sortedRows = rows.sort((a, b) => {
const cellA = a.cells[columnIndex + 1].innerText.toLowerCase(); // +1 because of new first column
const cellA = a.cells[columnIndex + 1].innerText.toLowerCase();
const cellB = b.cells[columnIndex + 1].innerText.toLowerCase();

if (cellA < cellB) return -1 * direction;
Expand All @@ -69,7 +82,7 @@ <h1>{{ num_results }} Results</h1>
});

tbody.append(...sortedRows);
sortDirections[columnIndex] = !sortDirections[columnIndex]; // Toggle sort direction
sortDirections[columnIndex] = !sortDirections[columnIndex];
}

function downloadTableAsTSV() {
Expand All @@ -91,24 +104,21 @@ <h1>{{ num_results }} Results</h1>
document.body.removeChild(downloadLink);
}
</script>
<script>
$(function(){
$("#column-list").sortable();
$("#column-modal").dialog({ autoOpen: false, modal: true });
});
function openColumnModal(){
$("#column-modal").dialog("open");
}
function saveColumnOrder(){
const order = $("#column-list").sortable("toArray", { attribute: "data-col" });
$.ajax({url: "/update_preference", type: "POST", contentType: "application/json", data: JSON.stringify({key: "file_set_search_columns_order", value: order}), success: function(){ location.reload(); }});
}
</script>

<style>
.floating-button {
position: fixed;
bottom: 10px;
right: 10px;
padding: 10px;
background-color: #008CBA; /* Blue background */
color: white; /* White text */
border: none;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
cursor: pointer;
border-radius: 50%;
}

th {
cursor: pointer;
}
Expand Down
Loading
Loading