diff --git a/backend/db_utils/refresh_db.py b/backend/db_utils/refresh_db.py
new file mode 100644
index 0000000..654ac94
--- /dev/null
+++ b/backend/db_utils/refresh_db.py
@@ -0,0 +1,180 @@
+import os
+import shutil
+import sys
+
+import toml
+from sqlmodel import Session
+
+from backend.db import engine, create_db_and_tables
+from backend.db_utils.crud import insert_study, insert_dataset, import_sample_sheet
+from backend.models import Study, Dataset
+
+DATASETS_DIR = "backend/datasets"
+SAMPLESHEETS_DIR = "backend/SampleSheets"
+
+
+def check_toml_file(toml_data):
+ if "datasetfile" not in toml_data:
+ return {"message": "Error: Missing [datasetfile] config.", "success": False}
+ else:
+ required_keys = ["datatype"]
+ for key in required_keys:
+ if (
+ key not in toml_data["datasetfile"]
+ or toml_data["datasetfile"][key].strip() == ""
+ ):
+ return {
+ "message": f"Error: Missing [datasetfile - {key}] config.",
+ "success": False,
+ }
+
+ if "dataset" not in toml_data:
+ return {"message": "Error: Missing [dataset] config.", "success": False}
+ else:
+ required_keys = [
+ "dataset_name",
+ "n_samples",
+ "brain_super_region",
+ "brain_region",
+ "organism",
+ "tissue",
+ "disease",
+ ]
+ for key in required_keys:
+ if (
+ key not in toml_data["dataset"]
+ or str(toml_data["dataset"][key]).strip() == ""
+ ):
+ return {
+ "message": f"Error: Missing [dataset - {key}] config.",
+ "success": False,
+ }
+
+ if "study" not in toml_data:
+ return {"message": "Error: Missing [study] config.", "success": False}
+ else:
+ required_keys = ["study_name"]
+ for key in required_keys:
+ if key not in toml_data["study"] or toml_data["study"][key].strip() == "":
+ return {
+ "message": f"Error: Missing [study - {key}] config.",
+ "success": False,
+ }
+
+ if "protocol" not in toml_data:
+ return {"message": "Error: Missing [protocol] config.", "success": False}
+ else:
+ required_keys = ["protocol_id"]
+ for key in required_keys:
+ if (
+ key not in toml_data["protocol"]
+ or toml_data["protocol"][key].strip() == ""
+ ):
+ return {
+ "message": f"Error: Missing [protocol - {key}] config.",
+ "success": False,
+ }
+
+ return {"message": "Config file is valid", "success": True}
+
+
+def refresh_dataset(dataset_i, session: Session):
+ """Import a single dataset folder. Returns the check_toml_file-style result."""
+ dataset_path = f"{DATASETS_DIR}/{dataset_i}"
+ ## load dataset info
+ dataset_info_file = f"{dataset_path}/dataset_info.toml"
+ if not os.path.exists(dataset_info_file):
+ return {"message": "Skipped: no dataset_info.toml.", "success": None}
+
+ with open(dataset_info_file, "r") as f:
+ dataset_info = toml.load(f)
+
+ ## check if dataset configuration is valid
+ check_result = check_toml_file(dataset_info)
+ if not check_result["success"]:
+ return check_result
+
+ print("=======insert info into database==========")
+ study_dict = dataset_info["study"]
+ study_dict["study_id"] = study_dict["study_name"]
+ study = Study(**study_dict)
+
+ dataset_dict = dataset_info["dataset"]
+ dataset_dict["dataset_id"] = dataset_dict["dataset_name"]
+ dataset_dict["assay"] = dataset_info["datasetfile"]["datatype"]
+ dataset_dict["study_id"] = study_dict["study_id"]
+ dataset_dict["dataset_file"] = dataset_info["datasetfile"]["file"]
+ dataset = Dataset(**dataset_dict)
+
+ insert_study(study, session)
+ insert_dataset(dataset, session)
+
+ sample_sheet = dataset_dict.get("sample_sheet")
+ if sample_sheet not in (None, "None", ""):
+ sample_sheet_path = f"{SAMPLESHEETS_DIR}/{sample_sheet}"
+ shutil.copyfile(sample_sheet_path, f"{dataset_path}/{sample_sheet}")
+ import_sample_sheet(sample_sheet_path, session)
+
+ return {"message": f"Imported '{dataset_i}'.", "success": True}
+
+
+def refresh_database(session: Session):
+ """Import every dataset folder. A dataset with an invalid or unreadable
+ dataset_info.toml is reported and skipped, the remaining ones still run."""
+ if not os.path.isdir(DATASETS_DIR):
+ return {
+ "message": f"Error: '{DATASETS_DIR}' not found. Run this from the repository root.",
+ "success": False,
+ "imported": 0,
+ "skipped": 0,
+ "errors": [],
+ }
+
+ imported = 0
+ errors = []
+ ## loop through all datasets
+ for dataset_i in sorted(os.listdir(DATASETS_DIR)):
+ if not os.path.isdir(f"{DATASETS_DIR}/{dataset_i}"):
+ continue
+ try:
+ result = refresh_dataset(dataset_i, session)
+ except Exception as e:
+ errors.append(f"{dataset_i}: {e}")
+ continue
+
+ if result["success"]:
+ imported += 1
+ elif result["success"] is False:
+ errors.append(f"{dataset_i}: {result['message']}")
+
+ if errors:
+ return {
+ "message": f"Database refreshed with problems: {imported} imported, {len(errors)} skipped.",
+ "success": False,
+ "imported": imported,
+ "skipped": len(errors),
+ "errors": errors,
+ }
+
+ return {
+ "message": f"Database refreshed successfully: {imported} dataset(s).",
+ "success": True,
+ "imported": imported,
+ "skipped": 0,
+ "errors": [],
+ }
+
+
+def main():
+ create_db_and_tables()
+ with Session(engine) as session:
+ result = refresh_database(session)
+
+ for error in result["errors"]:
+ print(f"[SKIPPED] {error}", file=sys.stderr)
+ print(result["message"])
+ sys.exit(0 if result["success"] else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/funcs/get_data.py b/backend/funcs/get_data.py
index 34ea6e8..cc55437 100644
--- a/backend/funcs/get_data.py
+++ b/backend/funcs/get_data.py
@@ -9,6 +9,17 @@
import re
from functools import lru_cache
+from backend.funcs.remote_io import (
+ is_remote,
+ ds_locator,
+ ds_exists,
+ ds_read_json,
+ ds_read_text,
+ ds_read_toml,
+ ds_text_stream,
+ ds_bytes_stream,
+)
+
def safe_filename(name):
return re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
@@ -64,22 +75,17 @@ def get_gene_location(dataset, gene):
return f"Error: Could not parse positions from gene {gene}: {str(e)}"
# Case 2: eQTL dataset - need to look up in JSON file
- genes_file = os.path.join(
- "backend", "datasets", dataset, "gene_jsons",
- get_gene_file_name(safe_filename(gene)) + ".json"
- )
+ gene_file = get_gene_file_name(safe_filename(gene)) + ".json"
+ data = ds_read_json(dataset, "gene_jsons", gene_file)
- if not os.path.exists(genes_file):
- print(genes_file + " not found")
+ if data is None:
+ print(f"gene_jsons/{gene_file} not found for {dataset}")
return "Error: Gene list file not found for the specified dataset."
-
- with open(genes_file, "r") as f:
- data = json.load(f)
-
- if isinstance(data, dict) and gene in data:
- gene_x = data[gene]
- else:
- gene_x = data
+
+ if isinstance(data, dict) and gene in data:
+ gene_x = data[gene]
+ else:
+ gene_x = data
if not gene_x:
return f"Error: Gene {gene} not found in dataset."
@@ -105,19 +111,10 @@ def get_snp_location(dataset, snp):
if dataset == "all":
return "Error: Dataset is not specified."
- def load_snp_from_file(file_path):
- if os.path.exists(file_path):
- with open(file_path, "r") as f:
- data = json.load(f)
- return data.get(snp, None)
- return None
-
try:
group = get_snp_group(snp)
- snps_file = os.path.join(
- "backend", "datasets", dataset, "snp_jsons_merged", group + ".json"
- )
- snp_data = load_snp_from_file(snps_file)
+ group_map = ds_read_json(dataset, "snp_jsons_merged", group + ".json")
+ snp_data = group_map.get(snp) if isinstance(group_map, dict) else None
if snp_data:
return {
"chromosome": snp_data["chromosome"],
@@ -128,12 +125,14 @@ def load_snp_from_file(file_path):
pass
default_file = os.path.join("backend", "datasets", "snp_locations.json")
- snp_data = load_snp_from_file(default_file)
- if snp_data:
- return {
- "chromosome": snp_data["chromosome"],
- "position": snp_data["position"],
- }
+ if os.path.exists(default_file):
+ with open(default_file, "r") as f:
+ snp_data = json.load(f).get(snp, None)
+ if snp_data:
+ return {
+ "chromosome": snp_data["chromosome"],
+ "position": snp_data["position"],
+ }
return None
@@ -142,13 +141,11 @@ def get_gene_locations_in_chromosome(dataset, chromosome, start, end):
if dataset == "all":
return "Error: Dataset is not specified."
else:
- chromosome_file = os.path.join(
- "backend", "datasets", dataset, "gene_locations", chromosome + ".parquet"
- )
+ rel = ("gene_locations", chromosome + ".parquet")
# significant_genes_list = get_qtl_gene_list(dataset)
- if os.path.exists(chromosome_file):
- df = pl.read_parquet(chromosome_file).filter(
+ if ds_exists(dataset, *rel):
+ df = pl.read_parquet(ds_locator(dataset, *rel)).filter(
(pl.col("position_end") >= start)
& (pl.col("position_start") <= end)
# & pl.col("gene_id").is_in(significant_genes_list)
@@ -161,7 +158,7 @@ def get_gene_locations_in_chromosome(dataset, chromosome, start, end):
else:
return f"No genes found in the region."
else:
- print(chromosome_file + " not found")
+ print(f"gene_locations/{chromosome}.parquet not found for {dataset}")
return "Error: Chromosome file not found for the specified dataset."
@@ -169,13 +166,11 @@ def get_snp_locations_in_chromosome(dataset, chromosome, start, end):
if dataset == "all":
return "Error: Dataset is not specified."
else:
- chromosome_file = os.path.join(
- "backend", "datasets", dataset, "snp_locations", chromosome + ".parquet"
- )
+ rel = ("snp_locations", chromosome + ".parquet")
significant_snps_list = get_qtl_snp_list(dataset)
- if os.path.exists(chromosome_file):
- df = pl.read_parquet(chromosome_file).filter(
+ if ds_exists(dataset, *rel):
+ df = pl.read_parquet(ds_locator(dataset, *rel)).filter(
(pl.col("position") >= start)
& (pl.col("position") <= end)
& pl.col("snp_id").is_in(significant_snps_list)
@@ -186,31 +181,28 @@ def get_snp_locations_in_chromosome(dataset, chromosome, start, end):
else:
return f"No SNPs found in the region."
else:
- print(chromosome_file + " not found")
+ print(f"snp_locations/{chromosome}.parquet not found for {dataset}")
return "Error: Chromosome file not found for the specified dataset."
def get_gwas_datasets(dataset):
- config_path = os.path.join("backend", "datasets", dataset, "gwas", "gwas_datasets.toml")
- if not os.path.exists(config_path):
- return []
- with open(config_path, "r") as f:
- config = toml.load(f)
+ config = ds_read_toml(dataset, "gwas", "gwas_datasets.toml")
+ if config is None:
+ return []
return config.get("datasets", [])
def get_gwas_in_chromosome(qtl_dataset, gwas_dataset, chromosome, start, end):
- base_dir = os.path.join("backend", "datasets", qtl_dataset, "gwas", gwas_dataset)
- file_path = os.path.join(base_dir, f"{chromosome}.tsv")
- if not os.path.exists(file_path):
- # Return empty
- return {
- "snp_id": [],
- "position": [],
- "beta_value": [],
- "p_value": [],
- }
- df = pl.read_csv(file_path, separator="\t").filter(
+ empty = {
+ "snp_id": [],
+ "position": [],
+ "beta_value": [],
+ "p_value": [],
+ }
+ tsv_stream = ds_bytes_stream(qtl_dataset, "gwas", gwas_dataset, f"{chromosome}.tsv")
+ if tsv_stream is None:
+ return empty
+ df = pl.read_csv(tsv_stream, separator="\t").filter(
(pl.col("position") >= start) & (pl.col("position") <= end)
)
if df.is_empty():
@@ -234,16 +226,10 @@ def get_gene_chromosome(dataset, gene):
chromosome = gene.split("-")[0]
return chromosome
- genes_file = os.path.join(
- "backend", "datasets", dataset, "gene_jsons",
- get_gene_file_name(safe_filename(gene)) + ".json"
- )
-
- if os.path.exists(genes_file):
- with open(genes_file, "r") as f:
- data = json.load(f)
- gene_x = data
+ gene_file = get_gene_file_name(safe_filename(gene)) + ".json"
+ gene_x = ds_read_json(dataset, "gene_jsons", gene_file)
+ if gene_x is not None:
if gene_x:
chromosome = gene_x.get("chromosome")
if chromosome:
@@ -253,7 +239,7 @@ def get_gene_chromosome(dataset, gene):
else:
return f"Error: Gene {gene} not found in {dataset} dataset."
else:
- print(genes_file + " not found")
+ print(f"gene_jsons/{gene_file} not found for {dataset}")
return f"Error: Gene list file not found for the specified dataset {dataset}."
@@ -261,32 +247,28 @@ def get_snp_chromosome(dataset, snp):
if dataset == "all":
return "Error: Dataset is not specified."
else:
- snps_file = os.path.join(
- "backend", "datasets", dataset, "snp_jsons_merged", get_snp_group(snp) + ".json"
- )
+ snps_file = ("snp_jsons_merged", get_snp_group(snp) + ".json")
- if os.path.exists(snps_file):
- with open(snps_file, "r") as f:
- data = json.load(f)
- snp = data.get(snp, None)
+ data = ds_read_json(dataset, *snps_file)
+ if data is not None:
+ snp = data.get(snp, None)
if data and snp:
return snp["chromosome"]
else:
return f"Error: SNP {snp} not found in {dataset} dataset."
else:
- print(snps_file + " not found")
+ print(f"{'/'.join(snps_file)} not found for {dataset}")
return "Error: SNP list file not found for the specified dataset."
def get_gene_list(dataset, query_str="AB"):
if dataset == "all":
genes_file = os.path.join("backend", "datasets", "gene_list.json")
+ data = json.load(open(genes_file)) if os.path.exists(genes_file) else None
else:
- genes_file = os.path.join("backend", "datasets", dataset, "gene_list.json")
+ data = ds_read_json(dataset, "gene_list.json")
- if os.path.exists(genes_file):
- with open(genes_file, "r") as f:
- data = json.load(f)
+ if data is not None:
if query_str == "all":
return data
elif query_str == "default":
@@ -294,22 +276,16 @@ def get_gene_list(dataset, query_str="AB"):
else:
return [gene for gene in data if gene.lower().startswith(query_str.lower())]
else:
- print(genes_file + " not found")
+ print(f"gene_list.json not found for {dataset}")
return "Error: Gene list file not found"
def get_qtl_gene_list(dataset, query_str="all"):
if dataset == "all":
return "Error: Gene dataset not specified."
- else:
- genes_file = os.path.join(
- "backend", "datasets", dataset, "gene_list.json"
- )
-
- if os.path.exists(genes_file):
- with open(genes_file, "r") as f:
- data = json.load(f)
+ data = ds_read_json(dataset, "gene_list.json")
+ if data is not None:
data = _normalize_gene_list(data)
if query_str == "all":
@@ -329,72 +305,62 @@ def get_qtl_gene_list(dataset, query_str="all"):
"name": [g["name"] for g in filtered],
}
else:
- print(genes_file + " not found")
+ print(f"gene_list.json not found for {dataset}")
return "Error: Gene list file not found"
def get_qtl_snp_list(dataset, query_str="all"):
if dataset == "all":
return "Error: SNP dataset not specified."
- else:
- snps_file = os.path.join("backend", "datasets", dataset, "snp_list.json")
- if os.path.exists(snps_file):
- with open(snps_file, "r") as f:
- data = json.load(f)
+ data = ds_read_json(dataset, "snp_list.json")
+ if data is not None:
if query_str == "all":
return data
else:
return [snp for snp in data if snp.lower().startswith(query_str.lower())]
else:
- print(snps_file + " not found")
+ print(f"snp_list.json not found for {dataset}")
return "Error: SNP list file not found"
def get_gene_celltypes(dataset, gene):
if dataset == "all":
return "Error: Dataset is not specified."
- else:
- genes_file = os.path.join(
- "backend", "datasets", dataset, "gene_jsons", get_gene_file_name(safe_filename(gene)) + ".json"
- )
- if os.path.exists(genes_file):
- with open(genes_file, "r") as f:
- data = json.load(f)
+ gene_file = get_gene_file_name(safe_filename(gene)) + ".json"
+ data = ds_read_json(dataset, "gene_jsons", gene_file)
- if gene.startswith("chr"):
- gene_x = data.get(gene, None)
- else:
- gene_x = data
+ if data is not None:
+ if gene.startswith("chr"):
+ gene_x = data.get(gene, None)
+ else:
+ gene_x = data
if gene_x:
return gene_x.get("celltypes", [])
else:
return f"Error: Gene not found in dataset."
else:
- print(genes_file + " not found")
+ print(f"gene_jsons/{gene_file} not found for {dataset}")
return "Error: Gene list file not found for the specified dataset."
def get_snp_celltypes(dataset, snp):
if dataset == "all":
return "Error: Dataset is not specified."
- else:
- snps_file = os.path.join(
- "backend", "datasets", dataset, "snp_jsons_merged", get_snp_group(snp) + ".json"
- )
- if os.path.exists(snps_file):
- with open(snps_file, "r") as f:
- data = json.load(f)
- snp = data.get(snp, None)
+ snps_file = ("snp_jsons_merged", get_snp_group(snp) + ".json")
+ data = ds_read_json(dataset, *snps_file)
+
+ if data is not None:
+ snp = data.get(snp, None)
if data and snp:
return snp["celltypes"]
else:
return f"Error: SNP not found in dataset."
else:
- print(snps_file + " not found")
+ print(f"{'/'.join(snps_file)} not found for {dataset}")
return "Error: SNP list file not found for the specified dataset."
@@ -411,22 +377,16 @@ def get_snp_data_for_gene(dataset, gene, celltype=""):
gene_start = gene_loc["start"]
gene_end = gene_loc["end"]
- celltype_mapping_file = os.path.join(
- "backend", "datasets", dataset, "celltypes", "celltype_parquet.json"
- )
- if not os.path.exists(celltype_mapping_file):
- print(celltype_mapping_file + " not found")
+ celltype_mapping = ds_read_json(dataset, "celltypes", "celltype_parquet.json")
+ if celltype_mapping is None:
+ print(f"celltypes/celltype_parquet.json not found for {dataset}")
return "Error: Celltype mapping file not found for the specified dataset."
- with open(celltype_mapping_file, "r") as f:
- celltype_mapping = json.load(f)
-
celltype_file = celltype_mapping.get(celltype, celltype)
- data_file = os.path.join("backend", "datasets", dataset, "celltypes", celltype_file)
-
- if not os.path.exists(data_file):
- print(data_file + " not found")
+ if not ds_exists(dataset, "celltypes", celltype_file):
+ print(f"celltypes/{celltype_file} not found for {dataset}")
return "Error: QTL data file not found for the specified dataset and cell type."
+ data_file = ds_locator(dataset, "celltypes", celltype_file)
gene_df = (
pl.scan_parquet(data_file)
@@ -475,22 +435,16 @@ def get_gene_data_for_snp(dataset, is_caqtl, snp, celltype=""):
return f"Error: {snp_loc}"
chrom = snp_loc["chromosome"]
- celltype_mapping_file = os.path.join(
- "backend", "datasets", dataset, "celltypes", "celltype_parquet.json"
- )
- if not os.path.exists(celltype_mapping_file):
- print(celltype_mapping_file + " not found")
+ celltype_mapping = ds_read_json(dataset, "celltypes", "celltype_parquet.json")
+ if celltype_mapping is None:
+ print(f"celltypes/celltype_parquet.json not found for {dataset}")
return "Error: Celltype mapping file not found for the specified dataset."
- with open(celltype_mapping_file, "r") as f:
- celltype_mapping = json.load(f)
-
celltype_file = celltype_mapping.get(celltype, celltype)
- data_file = os.path.join("backend", "datasets", dataset, "celltypes", celltype_file)
-
- if not os.path.exists(data_file):
- print(data_file + " not found")
+ if not ds_exists(dataset, "celltypes", celltype_file):
+ print(f"celltypes/{celltype_file} not found for {dataset}")
return "Error: QTL data file not found for the specified dataset and cell type."
+ data_file = ds_locator(dataset, "celltypes", celltype_file)
snp_df = (
pl.scan_parquet(data_file)
@@ -528,14 +482,11 @@ def parse_region(region: str):
)
return snp_df.to_dict(as_series=False)
- gene_loc_file = os.path.join(
- "backend", "datasets", dataset, "gene_locations", f"{chrom}.parquet"
- )
- if not os.path.exists(gene_loc_file):
- print(gene_loc_file + " not found")
+ if not ds_exists(dataset, "gene_locations", f"{chrom}.parquet"):
+ print(f"gene_locations/{chrom}.parquet not found for {dataset}")
return "Error: Gene locations file not found for the specified dataset."
- gene_loc_df = pl.read_parquet(gene_loc_file)
+ gene_loc_df = pl.read_parquet(ds_locator(dataset, "gene_locations", f"{chrom}.parquet"))
if "gene_name" not in gene_loc_df.columns:
return "Error: gene_locations parquet has no gene_name column for eQTL dataset."
@@ -623,15 +574,12 @@ def get_meta_list(dataset, query_str="all"):
def get_config_info(dataset):
if dataset == "all":
return "Error: Dataset is not specified."
- else:
- config_file = os.path.join("backend", "datasets", dataset, "dataset_info.toml")
- if os.path.exists(config_file):
- with open(config_file, "r") as f:
- config = toml.load(f)
+ config = ds_read_toml(dataset, "dataset_info.toml")
+ if config is not None:
return config
else:
- print(config_file + " not found")
+ print(f"dataset_info.toml not found for {dataset}")
return "Error: Config info file not found"
@@ -1040,6 +988,11 @@ def get_bw_data_exists(dataset):
if dataset == "all":
return "Error: Dataset is not specified."
+ # For remote datasets there is no listable folder; we use the bigwig
+ # celltype mapping file to determine if signal tracks are available
+ if is_remote(dataset):
+ return ds_exists(dataset, "bigwig", "celltype_bigwig.json")
+
bw_folder = os.path.join("backend", "datasets", dataset,"bigwig")
if not os.path.exists(bw_folder):
return False
@@ -1047,16 +1000,11 @@ def get_bw_data_exists(dataset):
@lru_cache(maxsize=128)
def get_cached_bigwig_handle(dataset, celltype, strand=None):
- celltype_mapping_file = os.path.join(
- "backend", "datasets", dataset,"bigwig","celltype_bigwig.json"
- )
- if not os.path.exists(celltype_mapping_file):
- print(celltype_mapping_file + " not found")
+ celltype_mapping = ds_read_json(dataset, "bigwig", "celltype_bigwig.json")
+ if celltype_mapping is None:
+ print(f"bigwig/celltype_bigwig.json not found for {dataset}")
return None
- with open(celltype_mapping_file, "r") as f:
- celltype_mapping = json.load(f)
-
entry = celltype_mapping.get(celltype, celltype)
if isinstance(entry, str):
@@ -1073,13 +1021,7 @@ def get_cached_bigwig_handle(dataset, celltype, strand=None):
print(f"No matching bigWig for celltype={celltype}, strand={strand}")
return None
- bw_path = os.path.join(
- "backend",
- "datasets",
- dataset,
- "bigwig",
- celltype_file,
- )
+ bw_path = ds_locator(dataset, "bigwig", celltype_file)
try:
return pyBigWig.open(bw_path)
@@ -1110,16 +1052,11 @@ def get_region_signal_data(dataset, chromosome, start, end, celltype="", bin_siz
try:
# Read mapping once here to know if we have plus/minus
- celltype_mapping_file = os.path.join(
- "backend", "datasets", dataset, "bigwig", "celltype_bigwig.json"
- )
- if not os.path.exists(celltype_mapping_file):
- print(celltype_mapping_file + " not found")
+ celltype_mapping = ds_read_json(dataset, "bigwig", "celltype_bigwig.json")
+ if celltype_mapping is None:
+ print(f"bigwig/celltype_bigwig.json not found for {dataset}")
return "Error: BigWig mapping file not found"
- with open(celltype_mapping_file, "r") as f:
- celltype_mapping = json.load(f)
-
entry = celltype_mapping.get(celltype, celltype)
if strand in {"+", "-"}:
@@ -1270,19 +1207,12 @@ def get_gene_annotation_info(dataset):
if dataset == "all":
return {}
- ann_file = os.path.join(
- "backend", "datasets", dataset, "gene_locations", "gene_annotations.toml"
- )
- if not os.path.exists(ann_file):
+ try:
+ cfg = ds_read_toml(dataset, "gene_locations", "gene_annotations.toml")
+ except Exception as e:
+ print(f"Error reading gene_annotations.toml for {dataset}: {e}")
return {}
- with open(ann_file, "r") as f:
- try:
- cfg = toml.load(f)
- except Exception as e:
- print(f"Error reading {ann_file}: {e}")
- return {}
-
return cfg or {}
@@ -1297,16 +1227,11 @@ def get_celltype_list(dataset):
if dataset == "all":
return "Error: Dataset is not specified."
- celltype_mapping_file = os.path.join(
- "backend", "datasets", dataset,'celltypes', "celltype_parquet.json"
- )
- if not os.path.exists(celltype_mapping_file):
- print(celltype_mapping_file + " not found")
+ celltype_mapping = ds_read_json(dataset, "celltypes", "celltype_parquet.json")
+ if celltype_mapping is None:
+ print(f"celltypes/celltype_parquet.json not found for {dataset}")
return f"Error: Celltype mapping file not found for the dataset"
- with open(celltype_mapping_file, "r") as f:
- celltype_mapping = json.load(f)
-
return list(celltype_mapping.keys())
@@ -1314,19 +1239,59 @@ def get_bigwig_celltype_list(dataset):
if dataset == "all":
return "Error: Dataset is not specified."
- celltype_mapping_file = os.path.join(
- "backend", "datasets", dataset, "bigwig", "celltype_bigwig.json"
- )
- if not os.path.exists(celltype_mapping_file):
- print(celltype_mapping_file + " not found")
+ celltype_mapping = ds_read_json(dataset, "bigwig", "celltype_bigwig.json")
+ if celltype_mapping is None:
+ print(f"bigwig/celltype_bigwig.json not found for {dataset}")
return "Error: BigWig celltype mapping file not found for the dataset"
- with open(celltype_mapping_file, "r") as f:
- celltype_mapping = json.load(f)
-
return list(celltype_mapping.keys())
+def inspect_dataset(dataset):
+ if not dataset or dataset == "all":
+ return {"reachable": False, "error": "No dataset specified."}
+
+ try:
+ has_qtl = bool(ds_exists(dataset, "celltypes", "celltype_parquet.json"))
+ has_bw = get_bw_data_exists(dataset)
+ has_bw = bool(has_bw) if not isinstance(has_bw, str) else False
+
+ config = get_config_info(dataset)
+ datatype = ""
+ info = {}
+ if isinstance(config, dict):
+ datatype = (config.get("datasetfile", {}) or {}).get("datatype", "") or ""
+ info = config.get("dataset", {}) or {}
+
+ is_caqtl = datatype.lower().startswith("ca")
+
+ # Fall back to structural heuristics when there is no dataset_info.toml.
+ if not datatype:
+ if has_qtl:
+ # caQTL groups gene jsons by chromosome (chr1.json ...), eQTL uses
+ # one file per gene symbol.
+ is_caqtl = bool(ds_exists(dataset, "gene_jsons", "chr1.json"))
+ datatype = "caQTL" if is_caqtl else "eQTL"
+ elif has_bw:
+ datatype = "scATACseq"
+
+ reachable = bool(has_qtl or has_bw or isinstance(config, dict))
+
+ return {
+ "reachable": reachable,
+ "dataset_id": dataset,
+ "assay": datatype,
+ "is_caqtl": is_caqtl,
+ "has_qtl": has_qtl,
+ "has_bw": has_bw,
+ "dataset_name": info.get("dataset_name", "") if info else "",
+ "info": info,
+ }
+ except Exception as e:
+ # Includes RemoteDatasetError (unsafe/blocked URL) and transport errors
+ return {"reachable": False, "dataset_id": dataset, "error": str(e)}
+
+
def get_exon_structure_in_chromosome(dataset, chromosome, start, end, max_exons=2000):
if dataset == "all":
return "Error: Dataset is not specified."
@@ -1341,9 +1306,9 @@ def get_exon_structure_in_chromosome(dataset, chromosome, start, end, max_exons=
# return "Error: Annotation BED file not specified for the dataset."
bed_rel = "transcript_annotation.bed"
- bed_path = os.path.join("backend", "datasets", dataset, bed_rel)
- if not os.path.exists(bed_path):
- print(bed_path + " not found")
+ bed_stream = ds_text_stream(dataset, bed_rel)
+ if bed_stream is None:
+ print(f"{bed_rel} not found for {dataset}")
return "Error: Annotation BED file not found for the specified dataset."
chrom_list = []
@@ -1363,7 +1328,7 @@ def get_exon_structure_in_chromosome(dataset, chromosome, start, end, max_exons=
truncated = False
import csv
- with open(bed_path) as f:
+ with bed_stream as f:
reader = csv.reader(f, delimiter="\t")
for row in reader:
if len(row) < 12:
diff --git a/backend/funcs/remote_capabilities.py b/backend/funcs/remote_capabilities.py
new file mode 100644
index 0000000..ca46465
--- /dev/null
+++ b/backend/funcs/remote_capabilities.py
@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+import importlib
+import logging
+from dataclasses import dataclass, field
+from typing import Callable, List, Optional
+
+logger = logging.getLogger("vizit.remote_capabilities")
+
+
+@dataclass
+class CapabilityResult:
+ name: str # short id
+ required_for: str # description of the feature
+ ok: Optional[bool] # True = available, False = missing, None = could not determine
+ detail: str # description of the check and its result
+ fix_hint: str = "" # how to install
+
+
+# --------------------------------------------------------------------------- #
+# Registry
+# --------------------------------------------------------------------------- #
+CapabilityCheck = Callable[[], CapabilityResult]
+_CHECKS: List[CapabilityCheck] = []
+
+
+def register_capability_check(fn: CapabilityCheck) -> CapabilityCheck:
+ _CHECKS.append(fn)
+ return fn
+
+
+def run_capability_checks() -> List[CapabilityResult]:
+ results: List[CapabilityResult] = []
+ for fn in _CHECKS:
+ try:
+ results.append(fn())
+ except Exception as exc:
+ results.append(
+ CapabilityResult(
+ name=getattr(fn, "__name__", "unknown"),
+ required_for="unknown",
+ ok=None,
+ detail=f"capability check raised {type(exc).__name__}: {exc}",
+ )
+ )
+ return results
+
+
+# --------------------------------------------------------------------------- #
+# Probes
+# --------------------------------------------------------------------------- #
+def _module_available(modname: str) -> bool:
+ try:
+ importlib.import_module(modname)
+ return True
+ except Exception:
+ return False
+
+
+@register_capability_check
+def check_polars_http() -> CapabilityResult:
+ have_fsspec = _module_available("fsspec")
+ have_aiohttp = _module_available("aiohttp")
+ ok = have_fsspec and have_aiohttp
+ if ok:
+ detail = "fsspec + aiohttp present; polars can read parquet/CSV over http(s)."
+ fix = ""
+ else:
+ missing = [
+ m
+ for m, present in (("fsspec", have_fsspec), ("aiohttp", have_aiohttp))
+ if not present
+ ]
+ detail = (
+ f"missing {', '.join(missing)}; polars cannot read parquet/CSV from "
+ "remote URLs (gene/SNP locations, QTL parquet, GWAS tables)."
+ )
+ fix = "pip install fsspec aiohttp"
+ return CapabilityResult(
+ name="polars-http",
+ required_for="remote QTL parquet / gene & SNP locations / GWAS tables",
+ ok=ok,
+ detail=detail,
+ fix_hint=fix,
+ )
+
+
+@register_capability_check
+def check_pybigwig_remote() -> CapabilityResult:
+ try:
+ import pyBigWig
+ except Exception as exc:
+ return CapabilityResult(
+ name="pybigwig-remote",
+ required_for="remote BigWig signal tracks",
+ ok=None,
+ detail=f"pyBigWig import failed: {type(exc).__name__}: {exc}",
+ fix_hint="pip install pyBigWig",
+ )
+
+ ok = bool(getattr(pyBigWig, "remote", 0))
+ if ok:
+ detail = (
+ "pyBigWig was built with libcurl; remote .bw/.bigWig URLs are supported."
+ )
+ fix = ""
+ else:
+ detail = (
+ "pyBigWig was built WITHOUT libcurl; "
+ "remote BigWig signal tracks will fail to open."
+ )
+ fix = (
+ "Install a curl-enabled build, e.g. install libcurl "
+ "and then run `pip install --no-binary pyBigWig pyBigWig`"
+ )
+ return CapabilityResult(
+ name="pybigwig-remote",
+ required_for="remote BigWig signal tracks",
+ ok=ok,
+ detail=detail,
+ fix_hint=fix,
+ )
+
+
+# --------------------------------------------------------------------------- #
+# Startup
+# --------------------------------------------------------------------------- #
+def warn_on_missing_remote_capabilities() -> List[CapabilityResult]:
+ results = run_capability_checks()
+ missing = [r for r in results if r.ok is False]
+ unknown = [r for r in results if r.ok is None]
+
+ if not missing and not unknown:
+ logger.info(
+ "Remote datasets enabled; all %d optional capabilities available.",
+ len(results),
+ )
+ return results
+
+ logger.warning(
+ "Remote datasets are ENABLED but %d capability(ies) are unavailable. "
+ "Affected features will error for remote datasets:",
+ len(missing) + len(unknown),
+ )
+ for r in missing + unknown:
+ state = "MISSING" if r.ok is False else "UNKNOWN"
+ logger.warning(" [%s] %s — required for: %s", state, r.name, r.required_for)
+ logger.warning(" %s", r.detail)
+ if r.fix_hint:
+ logger.warning(" fix: %s", r.fix_hint)
+ return results
diff --git a/backend/funcs/remote_io.py b/backend/funcs/remote_io.py
new file mode 100644
index 0000000..67c4dca
--- /dev/null
+++ b/backend/funcs/remote_io.py
@@ -0,0 +1,241 @@
+import io
+import ipaddress
+import json
+import os
+import socket
+from functools import lru_cache
+from urllib.parse import urljoin, urlsplit, quote
+
+import requests
+import toml
+
+from backend.settings import settings
+
+DATASETS_DIR = os.path.join("backend", "datasets")
+
+# Networking limits for remote fetches.
+REMOTE_TIMEOUT = 30 # seconds per request
+MAX_REMOTE_BYTES = 1024 * 1024 * 1024 # 1 GiB cap for whole-file downloads
+MAX_REDIRECTS = 3
+
+
+class RemoteDatasetError(Exception):
+ pass
+
+
+def is_remote(dataset) -> bool:
+ return isinstance(dataset, str) and (
+ dataset.startswith("http://") or dataset.startswith("https://")
+ )
+
+
+# --------------------------------------------------------------------------- #
+# SSRF protection
+# --------------------------------------------------------------------------- #
+def _allowed_hosts() -> set:
+ raw = getattr(settings, "remote_dataset_allowed_hosts", "") or ""
+ return {h.strip().lower() for h in raw.split(",") if h.strip()}
+
+
+def _ip_is_public(ip_text: str) -> bool:
+ try:
+ ip = ipaddress.ip_address(ip_text)
+ except ValueError:
+ return False
+
+ # Unwrap IPv4-mapped / 6to4-style addresses so 127.0.0.1 can't be hidden as ::ffff:127.0.0.1
+ if ip.version == 6 and getattr(ip, "ipv4_mapped", None) is not None:
+ ip = ip.ipv4_mapped
+ if (
+ ip.is_private
+ or ip.is_loopback
+ or ip.is_link_local
+ or ip.is_multicast
+ or ip.is_reserved
+ or ip.is_unspecified
+ ):
+ return False
+ return bool(ip.is_global)
+
+
+def _resolve_ips(host: str) -> set:
+ infos = socket.getaddrinfo(host, None)
+ return {info[4][0] for info in infos}
+
+
+def assert_safe_url(url: str) -> str:
+ if not getattr(settings, "allow_remote_datasets", True):
+ raise RemoteDatasetError("Remote datasets are disabled on this server.")
+
+ parts = urlsplit(url)
+ if parts.scheme not in ("http", "https"):
+ raise RemoteDatasetError(f"Unsupported URL scheme: {parts.scheme!r}")
+ host = parts.hostname
+ if not host:
+ raise RemoteDatasetError("Remote dataset URL has no host.")
+
+ allow = _allowed_hosts()
+ if allow and host.lower() not in allow:
+ raise RemoteDatasetError(f"Host '{host}' is not in the allowed-hosts list.")
+
+ if getattr(settings, "remote_dataset_allow_private", False):
+ return url
+
+ try:
+ ips = _resolve_ips(host)
+ except socket.gaierror as exc:
+ raise RemoteDatasetError(f"Could not resolve host '{host}': {exc}")
+ if not ips:
+ raise RemoteDatasetError(f"Could not resolve host '{host}'.")
+ for ip in ips:
+ if not _ip_is_public(ip):
+ raise RemoteDatasetError(
+ f"Refusing to access non-public address {ip} (host '{host}')."
+ )
+ return url
+
+
+# --------------------------------------------------------------------------- #
+# HTTP helpers
+# --------------------------------------------------------------------------- #
+def _request(method: str, url: str, **kwargs):
+ """Issue a request, re-validating the target on every redirect hop."""
+ assert_safe_url(url)
+ current = url
+ for _ in range(MAX_REDIRECTS + 1):
+ resp = requests.request(
+ method,
+ current,
+ allow_redirects=False,
+ timeout=REMOTE_TIMEOUT,
+ **kwargs,
+ )
+ if resp.status_code in (301, 302, 303, 307, 308):
+ location = resp.headers.get("Location")
+ resp.close()
+ if not location:
+ raise RemoteDatasetError("Redirect response without a Location header.")
+ current = urljoin(current, location)
+ assert_safe_url(current)
+ continue
+ return resp
+ raise RemoteDatasetError("Too many redirects while fetching remote dataset file.")
+
+
+def _remote_exists(url: str) -> bool:
+ try:
+ resp = _request("GET", url, stream=True, headers={"Range": "bytes=0-0"})
+ except RemoteDatasetError:
+ raise
+ except requests.RequestException:
+ return False
+ try:
+ return resp.status_code < 400
+ finally:
+ resp.close()
+
+
+def clear_remote_cache() -> None:
+ _fetch_bytes_cached.cache_clear()
+
+
+@lru_cache(maxsize=512)
+def _fetch_bytes_cached(url: str) -> bytes:
+ try:
+ resp = _request("GET", url, stream=True)
+ except requests.RequestException as exc:
+ raise RemoteDatasetError(f"Failed to fetch {url}: {exc}")
+
+ with resp:
+ if resp.status_code == 404:
+ raise FileNotFoundError(url)
+ if resp.status_code >= 400:
+ raise RemoteDatasetError(
+ f"Remote returned HTTP {resp.status_code} for {url}"
+ )
+
+ total = 0
+ chunks = []
+ for chunk in resp.iter_content(chunk_size=1 << 16):
+ if not chunk:
+ continue
+ total += len(chunk)
+ if total > MAX_REMOTE_BYTES:
+ raise RemoteDatasetError(
+ f"Remote file {url} exceeds the {MAX_REMOTE_BYTES} byte limit."
+ )
+ chunks.append(chunk)
+ return b"".join(chunks)
+
+
+# --------------------------------------------------------------------------- #
+# Path / URL building
+# --------------------------------------------------------------------------- #
+def _local_path(dataset: str, parts) -> str:
+ return os.path.join(DATASETS_DIR, dataset, *parts)
+
+
+def _remote_url(dataset: str, parts) -> str:
+ base = dataset.rstrip("/")
+ suffix = "/".join(quote(str(p), safe="") for p in parts)
+ return f"{base}/{suffix}" if suffix else base
+
+
+def ds_locator(dataset: str, *parts) -> str:
+ if is_remote(dataset):
+ return assert_safe_url(_remote_url(dataset, parts))
+ return _local_path(dataset, parts)
+
+
+def ds_exists(dataset: str, *parts) -> bool:
+ if is_remote(dataset):
+ return _remote_exists(_remote_url(dataset, parts))
+ return os.path.exists(_local_path(dataset, parts))
+
+
+def ds_read_bytes(dataset: str, *parts):
+ if is_remote(dataset):
+ try:
+ return _fetch_bytes_cached(_remote_url(dataset, parts))
+ except FileNotFoundError:
+ return None
+ path = _local_path(dataset, parts)
+ if not os.path.exists(path):
+ return None
+ with open(path, "rb") as fh:
+ return fh.read()
+
+
+def ds_read_text(dataset: str, *parts, encoding="utf-8"):
+ data = ds_read_bytes(dataset, *parts)
+ if data is None:
+ return None
+ return data.decode(encoding)
+
+
+def ds_read_json(dataset: str, *parts):
+ text = ds_read_text(dataset, *parts)
+ if text is None:
+ return None
+ return json.loads(text)
+
+
+def ds_read_toml(dataset: str, *parts):
+ text = ds_read_text(dataset, *parts)
+ if text is None:
+ return None
+ return toml.loads(text)
+
+
+def ds_text_stream(dataset: str, *parts):
+ text = ds_read_text(dataset, *parts)
+ if text is None:
+ return None
+ return io.StringIO(text)
+
+
+def ds_bytes_stream(dataset: str, *parts):
+ data = ds_read_bytes(dataset, *parts)
+ if data is None:
+ return None
+ return io.BytesIO(data)
diff --git a/backend/main.py b/backend/main.py
index 3857b2a..97a367b 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,13 +1,42 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from starlette.requests import Request
from backend.db import create_db_and_tables
-from backend.routes import db_routes, api_routes, visium_routes, qtl_routes, dm_routes,signal_routes
+from backend.routes import (
+ db_routes,
+ api_routes,
+ visium_routes,
+ qtl_routes,
+ dm_routes,
+ signal_routes,
+)
+from backend.funcs.remote_io import RemoteDatasetError
+from backend.funcs.remote_capabilities import (
+ warn_on_missing_remote_capabilities,
+ run_capability_checks,
+)
from backend.settings import settings
app = FastAPI(debug=settings.debug)
+
+def check_remote_capabilities_on_startup():
+ # Only relevant remote datasets is enabled
+ if settings.allow_remote_datasets:
+ warn_on_missing_remote_capabilities()
+
+
+@app.exception_handler(RemoteDatasetError)
+async def remote_dataset_error_handler(request: Request, exc: RemoteDatasetError):
+ # A remote (client-supplied) dataset URL was disallowed or unreachable.
+ return JSONResponse(
+ status_code=400, content={"detail": f"Remote dataset error: {exc}"}
+ )
+
+
app.add_middleware(
CORSMiddleware,
allow_origins=[
@@ -19,11 +48,31 @@
)
app.add_event_handler("startup", create_db_and_tables)
+app.add_event_handler("startup", check_remote_capabilities_on_startup)
+
@app.get("/")
async def root():
return "Hello, Welcome to VizIt!"
+
+@app.get("/serverconfig")
+async def server_config():
+ caps = run_capability_checks() if settings.allow_remote_datasets else []
+ return {
+ "allow_remote_datasets": settings.allow_remote_datasets,
+ "remote_capabilities": [
+ {
+ "name": c.name,
+ "required_for": c.required_for,
+ "ok": c.ok,
+ "detail": c.detail,
+ }
+ for c in caps
+ ],
+ }
+
+
app.include_router(db_routes.router, prefix="/db")
app.include_router(api_routes.router, prefix="/api")
app.include_router(visium_routes.router, prefix="/visium")
@@ -33,6 +82,7 @@ async def root():
if __name__ == "__main__":
import uvicorn
+
print("Starting FastAPI server on port", settings.uvicorn_port)
uvicorn.run(app, host=settings.uvicorn_host, port=settings.uvicorn_port)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index c9e7a88..c0d0966 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -11,5 +11,6 @@ polars == 1.31.0
fastparquet == 2024.11.0
types-pytz == 2025.2.*
pydantic-settings == 2.10.1
-pyBigWig == 0.3.24
+pyBigWig == 0.3.24 # pyBigWig must be built against libcurl so it can stream remote (http/https)
+requests == 2.32.*
diff --git a/backend/routes/dm_routes.py b/backend/routes/dm_routes.py
index f246e50..594c605 100644
--- a/backend/routes/dm_routes.py
+++ b/backend/routes/dm_routes.py
@@ -310,48 +310,48 @@ def check_toml_file(toml_data):
return {"message": "Config file is valid", "success": True}
-@router.get("/refreshdatabase")
-async def refreshdatabase(session: Session = Depends(get_session)):
- try:
- ## loop through all datasets
- for dataset_i in os.listdir("backend/datasets"):
- dataset_path = f"backend/datasets/{dataset_i}"
- ## load dataset info
- dataset_info_file = f"{dataset_path}/dataset_info.toml"
- if not os.path.exists(dataset_info_file):
- continue
-
- with open(f"{dataset_path}/dataset_info.toml", 'r') as f:
- dataset_info = toml.load(f)
-
- ## check if dataset configuration is valid
- check_result = check_toml_file(dataset_info)
- if not check_result["success"]:
- return check_result
-
- print("=======insert info into database==========")
- study_dict= dataset_info["study"]
- study_dict["study_id"] = study_dict["study_name"]
- study = Study(**study_dict)
-
- dataset_dict = dataset_info["dataset"]
- dataset_dict["dataset_id"] = dataset_dict["dataset_name"]
- dataset_dict["assay"] = dataset_info["datasetfile"]["datatype"]
- dataset_dict["study_id"] = study_dict["study_id"]
- dataset_dict["dataset_file"] = dataset_info["datasetfile"]["file"]
- dataset = Dataset(**dataset_dict)
-
- insert_study(study, session)
- insert_dataset(dataset, session)
-
- if not(dataset_dict["sample_sheet"] == "None" or dataset_dict["sample_sheet"] == ""):
- sample_sheet_path = f"backend/SampleSheets/{dataset_dict['sample_sheet']}"
- shutil.copyfile(sample_sheet_path, f"{dataset_path}/{dataset_dict['sample_sheet']}")
- import_sample_sheet(sample_sheet_path, session)
-
- return {"message": "Database refreshed successfully", "success": True}
- except Exception as e:
- return {"message": "Error: " + str(e), "success": False}
+# @router.get("/refreshdatabase")
+# async def refreshdatabase(session: Session = Depends(get_session)):
+# try:
+# ## loop through all datasets
+# for dataset_i in os.listdir("backend/datasets"):
+# dataset_path = f"backend/datasets/{dataset_i}"
+# ## load dataset info
+# dataset_info_file = f"{dataset_path}/dataset_info.toml"
+# if not os.path.exists(dataset_info_file):
+# continue
+
+# with open(f"{dataset_path}/dataset_info.toml", 'r') as f:
+# dataset_info = toml.load(f)
+
+# ## check if dataset configuration is valid
+# check_result = check_toml_file(dataset_info)
+# if not check_result["success"]:
+# return check_result
+
+# print("=======insert info into database==========")
+# study_dict= dataset_info["study"]
+# study_dict["study_id"] = study_dict["study_name"]
+# study = Study(**study_dict)
+
+# dataset_dict = dataset_info["dataset"]
+# dataset_dict["dataset_id"] = dataset_dict["dataset_name"]
+# dataset_dict["assay"] = dataset_info["datasetfile"]["datatype"]
+# dataset_dict["study_id"] = study_dict["study_id"]
+# dataset_dict["dataset_file"] = dataset_info["datasetfile"]["file"]
+# dataset = Dataset(**dataset_dict)
+
+# insert_study(study, session)
+# insert_dataset(dataset, session)
+
+# if not(dataset_dict["sample_sheet"] == "None" or dataset_dict["sample_sheet"] == ""):
+# sample_sheet_path = f"backend/SampleSheets/{dataset_dict['sample_sheet']}"
+# shutil.copyfile(sample_sheet_path, f"{dataset_path}/{dataset_dict['sample_sheet']}")
+# import_sample_sheet(sample_sheet_path, session)
+
+# return {"message": "Database refreshed successfully", "success": True}
+# except Exception as e:
+# return {"message": "Error: " + str(e), "success": False}
@router.delete("/deletedataset")
diff --git a/backend/routes/qtl_routes.py b/backend/routes/qtl_routes.py
index 3bcc1f5..36a5e27 100644
--- a/backend/routes/qtl_routes.py
+++ b/backend/routes/qtl_routes.py
@@ -3,6 +3,7 @@
# from backend.funcs.get_data import *
+from backend.funcs.remote_io import is_remote, clear_remote_cache
from backend.funcs.get_data import (
get_qtl_gene_list,
get_qtl_snp_list,
@@ -19,6 +20,7 @@
get_gwas_datasets,
get_gwas_in_chromosome,
get_gencode_version,
+ inspect_dataset,
)
router = APIRouter()
@@ -29,6 +31,14 @@ async def read_root():
return {"Message": "Hello QTL."}
+@router.get("/inspectdataset")
+async def inspectdataset(request: Request):
+ dataset_id = request.query_params.get("dataset")
+ if is_remote(dataset_id):
+ clear_remote_cache()
+ return inspect_dataset(dataset_id)
+
+
@router.get("/getgenelocation")
async def getgenelocation(request: Request):
print("getgenelocation() called================")
diff --git a/backend/settings.py b/backend/settings.py
index 74abff7..235a0dd 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -5,6 +5,10 @@ class Settings(BaseSettings):
uvicorn_host: str = "0.0.0.0"
debug: bool = False
+ allow_remote_datasets: bool = True
+ remote_dataset_allowed_hosts: str = ""
+ remote_dataset_allow_private: bool = False
+
class Config:
env_file = ".env" # 可选:已手动 load_dotenv 也可以省略
diff --git a/docs/prepare_dataset/scrnaseq.md b/docs/prepare_dataset/scrnaseq.md
index 1dc938c..f6e2f4d 100644
--- a/docs/prepare_dataset/scrnaseq.md
+++ b/docs/prepare_dataset/scrnaseq.md
@@ -296,7 +296,7 @@ After running the pipeline, there will be a dataset folder that contains all nec
* Upload the dataset folder named '<dataset_name>' to the server at __'backend/datasets'__.
* Put the dataset configuration file named __'dataset_info.toml'__ to your dataset folder at __'backend/datasets/<dataset_name>'__.
* Upload __samplesheet file__ to the server at __'backend/SampleSheet'__.
-* Refresh the database: Go to __'Datasets Management Page '(DATASETS -> +ADD DATASET)__ and click __'REFRESH DB'__.
+* Refresh the database: on the server, from the repository root, run `python -m backend.db_utils.refresh_db`.
diff --git a/docs/prepare_dataset/visiumst.md b/docs/prepare_dataset/visiumst.md
index 5bf78a2..e11349b 100644
--- a/docs/prepare_dataset/visiumst.md
+++ b/docs/prepare_dataset/visiumst.md
@@ -304,7 +304,7 @@ After running the pipeline, there will be a dataset folder that contains all nec
* Upload the dataset folder named '<dataset_name>' to the server at __'backend/datasets'__.
* Put the dataset configuration file named __'dataset_info.toml'__ to your dataset folder at __'backend/datasets/<dataset_name>'__.
* Upload __samplesheet file__ to the server at __'backend/SampleSheet'__.
-* Refresh the database: Go to __'Datasets Management Page '(DATASETS -> +ADD DATASET)__ and click __'REFRESH DB'__.
+* Refresh the database: on the server, from the repository root, run `python -m backend.db_utils.refresh_db`.
diff --git a/docs/prepare_dataset/xqtl.md b/docs/prepare_dataset/xqtl.md
index 1a7f20c..089eba2 100644
--- a/docs/prepare_dataset/xqtl.md
+++ b/docs/prepare_dataset/xqtl.md
@@ -229,7 +229,16 @@ After running the pipeline, there will be a dataset folder that contains all nec
* The files and folders within the `your_dataset_name` folder are necessary for upload.
* Upload the dataset folder named '<dataset_name>' to the server at __'backend/datasets'__.
* Put the dataset configuration file named __'dataset_info.toml'__ to your dataset folder at __'backend/datasets/<dataset_name>'__.
-* Refresh the database: Go to __'Datasets Management Page '(DATASETS -> +ADD DATASET)__ and click __'REFRESH DB'__.
+* Refresh the database: on the server, from the repository root, run `python -m backend.db_utils.refresh_db`.
+
+Alternatively, you can host the dataset folder on a web server and add it from the
+__'DATASETS -> + ADD DATASET'__ dialog with the __'Load from URL'__ option. The dataset is then registered
+in your browser only, and no database refresh is needed. This requires:
+
+* A web server that supports __HTTP range requests__ (nginx, Apache, Caddy, S3, ...). Python's `http.server` does __not__ work since it ignores `Range` headers.
+* A URL that is reachable __from the machine running the VizIt backend__.
+* `REMOTE_DATASET_ALLOW_PRIVATE=true` in `backend/.env` if the dataset is served from a private or loopback
+ address, which is otherwise refused.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 1ed2cd9..4151cc0 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -22,7 +22,7 @@ import GenomicRegionView from "./pages/GenomicRegionView";
import XDatasetsView from "./pages/XDatasets";
import CellTypesView from "./pages/CellTypesView";
import ViewsHome from "./pages/ViewsHome";
-import DatasetManagePage from "./pages/DatasetManage";
+// import DatasetManagePage from "./pages/DatasetManage";
import LayerView from "./pages/LayerView/index.jsx";
import HowToUse from "./pages/Help/HowToUse.jsx";
import FAQPage from "./pages/Help/FAQ.jsx";
@@ -113,7 +113,7 @@ function App() {
python -m backend.db_utils.refresh_db
python -m backend.db_utils.refresh_dbpython -m backend.db_utils.refresh_db