@@ -70,6 +70,8 @@ def ensure_uploads_log_schema():
7070 """ )
7171 conn .commit ()
7272
73+ #==================================================#
74+
7375def auto_log_material_files ():
7476 """
7577 Walk UPLOAD_FOLDER and upsert one row per (property, tab, filename).
@@ -121,7 +123,9 @@ def auto_log_material_files():
121123 conn .commit ()
122124
123125 return {"status" : "ok" , "added_or_updated" : added_or_updated }
124-
126+
127+ #==================================================#
128+
125129# Automation of import to sqlite3 database
126130def auto_import_uploads ():
127131 """
@@ -213,8 +217,9 @@ def tableize(name: str) -> str:
213217 print (f"auto_import_uploads: done, { imported } table(s) updated." )
214218 return imported
215219
216- # Run-once warm-up
220+ #==================================================#
217221
222+ # Run-once warm-up
218223from threading import Lock
219224
220225_startup_done = False
@@ -245,12 +250,185 @@ def _startup_once():
245250 _run_startup_tasks ()
246251
247252
248- # ========== ROUTES ==========
253+ ####################################### ========== ROUTES ==========#####################################
249254
250255#########################################################
251256
252- # Admin only rescanning for duplicates and re-importing
257+ # --- Public home + Admin SQL Query Tool (CRUD, multi-statement) ---
258+ def _list_user_tables ():
259+ """List non-internal SQLite tables for display in the SQL tool and home page."""
260+ with sqlite3 .connect (DB_NAME ) as conn :
261+ cur = conn .cursor ()
262+ cur .execute ("""
263+ SELECT name
264+ FROM sqlite_master
265+ WHERE type='table'
266+ AND name NOT LIKE 'sqlite_%'
267+ ORDER BY 1
268+ """ )
269+ return [r [0 ] for r in cur .fetchall ()]
270+
271+ #########################################################
272+
273+ # Public home used by multiple templates (and health check lands here )
274+ @app .route ("/" , methods = ["GET" ])
275+ @app .route ("/home" , methods = ["GET" ])
276+ def public_home ():
277+ tables = _list_user_tables ()
278+ return render_template ("public_home.html" , tables = tables )
279+
280+ #########################################################
281+
282+ @app .route ("/materials" , methods = ["GET" ])
283+ def materials_portal ():
284+ root = app .config .get ("UPLOAD_FOLDER" , UPLOAD_FOLDER )
285+ props = []
286+ try :
287+ for d in os .listdir (root ):
288+ pdir = os .path .join (root , d )
289+ if not os .path .isdir (pdir ):
290+ continue
291+ # Only show properties that have at least one allowed file in dataset/ or results/
292+ has_any = False
293+ for tab in ("dataset" , "results" ):
294+ sub = os .path .join (pdir , tab )
295+ if not os .path .isdir (sub ):
296+ continue
297+ for _r , _ds , files in os .walk (sub ):
298+ if any (
299+ (f .rsplit ("." , 1 )[- 1 ].lower () in (ALLOWED_DATASET_EXTENSIONS | ALLOWED_RESULTS_EXTENSIONS ))
300+ for f in files
301+ ):
302+ has_any = True
303+ break
304+ if has_any :
305+ break
306+ if has_any :
307+ props .append (d )
308+ except Exception as e :
309+ app .logger .warning ("materials_portal: %s" , e )
310+
311+ props .sort ()
312+ items = []
313+ for p in props :
314+ pretty = p .replace ("_" , " " ).title ()
315+ items .append (
316+ f"<li><b>{ pretty } </b> — "
317+ f"<a href='/materials/{ p } /dataset'>Dataset</a> · "
318+ f"<a href='/materials/{ p } /results'>Results</a></li>"
319+ )
320+
321+ html = (
322+ "<!doctype html><meta charset='utf-8'>"
323+ "<title>Materials</title>"
324+ "<style>body{font-family:system-ui,Segoe UI,Roboto,Arial,sans-serif;max-width:760px;margin:2rem auto;padding:0 1rem;line-height:1.5}</style>"
325+ "<h1>Materials</h1>"
326+ "<ul>" + "" .join (items ) + "</ul>"
327+ "<p><a href='/'>← Back to home</a></p>"
328+ )
329+ return html , 200 , {"Content-Type" : "text/html; charset=utf-8" }
330+
331+ #########################################################
332+
333+ # SQL Query Tool (admin only, CRUD, multi-statement)
334+ DESTRUCTIVE_REGEX = re .compile (r"\b(drop|delete|update|alter|truncate)\b" , re .IGNORECASE )
253335
336+ def _list_user_tables ():
337+ with sqlite3 .connect (DB_NAME ) as conn :
338+ cur = conn .cursor ()
339+ cur .execute ("""
340+ SELECT name
341+ FROM sqlite_master
342+ WHERE type='table'
343+ AND name NOT LIKE 'sqlite_%'
344+ ORDER BY 1
345+ """ )
346+ return [r [0 ] for r in cur .fetchall ()]
347+
348+ def _strip_sql_comments (sql : str ) -> str :
349+ # -- inline comments
350+ sql = re .sub (r"--.*?$" , "" , sql , flags = re .MULTILINE )
351+ # /* block comments */
352+ sql = re .sub (r"/\*.*?\*/" , "" , sql , flags = re .DOTALL )
353+ return sql
354+
355+ def _is_destructive (sql : str ) -> bool :
356+ plain = _strip_sql_comments (sql )
357+ return bool (DESTRUCTIVE_REGEX .search (plain ))
358+
359+ @app .route ("/admin/sql" , methods = ["GET" , "POST" ])
360+ @app .route ("/query_sql" , methods = ["GET" , "POST" ])
361+ def query_sql ():
362+ if not session .get ("admin" ):
363+ return redirect (url_for ("login" ))
364+
365+ tables = _list_user_tables ()
366+ sql = ""
367+ result_html = ""
368+ error_msg = ""
369+ needs_confirm = False
370+
371+ if request .method == "POST" :
372+ sql = (request .form .get ("sql" ) or "" ).strip ()
373+ user_confirmed = (request .form .get ("confirm" ) in ("on" , "1" , "true" , "yes" ))
374+
375+ if not sql :
376+ error_msg = "Please enter SQL."
377+ elif re .search (r"\bsqlite_\w+" , sql , re .IGNORECASE ):
378+ error_msg = "Queries that reference internal tables (sqlite_*) are blocked."
379+ else :
380+ try :
381+ # If destructive, require explicit confirmation
382+ if _is_destructive (sql ) and not user_confirmed :
383+ needs_confirm = True
384+ error_msg = (
385+ "This query contains destructive statements "
386+ "(DROP/DELETE/UPDATE/ALTER/TRUNCATE). Check the box below to confirm and resubmit."
387+ )
388+ else :
389+ statements = [s .strip () for s in sql .split (";" ) if s .strip ()]
390+ total_changed = 0
391+ last_select_html = None
392+
393+ with sqlite3 .connect (DB_NAME ) as conn :
394+ conn .execute ("PRAGMA foreign_keys=ON;" )
395+ cur = conn .cursor ()
396+
397+ for stmt in statements :
398+ if re .match (r"^\s*(with\s+.*?select|select)\b" , stmt , re .IGNORECASE | re .DOTALL ):
399+ cur .execute (stmt )
400+ rows = cur .fetchall ()
401+ cols = [d [0 ] for d in cur .description ] if cur .description else []
402+ df = pd .DataFrame (rows , columns = cols )
403+ last_select_html = df .to_html (classes = "data" , index = False )
404+ else :
405+ cur .execute (stmt )
406+ total_changed = conn .total_changes
407+
408+ conn .commit ()
409+
410+ result_html = (
411+ last_select_html
412+ if last_select_html is not None
413+ else f"<p><b>OK.</b> Executed { len (statements )} statement(s). "
414+ f"Total changed rows: { total_changed } .</p>"
415+ )
416+
417+ except Exception as e :
418+ error_msg = str (e )
419+
420+ return render_template (
421+ "sql_query.html" , # <-- matches your existing template filename
422+ tables = tables ,
423+ sql = sql ,
424+ result_html = result_html ,
425+ error_msg = error_msg ,
426+ needs_confirm = needs_confirm ,
427+ )
428+
429+ #########################################################
430+
431+ # Admin only rescanning for duplicates and re-importing
254432@app .route ('/admin/rescan_uploads' )
255433def rescan_uploads ():
256434 """
0 commit comments