diff --git a/.env.example b/.env.example index c965ea1..2190bd7 100644 --- a/.env.example +++ b/.env.example @@ -26,11 +26,12 @@ R2_BUCKET= # See docs/how-to/connect-to-locuscompare2-database.md and docs/reference/locuscompare2-database.md. # Password is injected by the deployment secret store — never commit a real value. # -# A read-only TEST/DEMO account (54.254.162.217:31987, user/pass locusview_test; scoped to exclude -# the `user` PII table) is documented in the how-to (§0) for development/CI — NOT for production. -LOCUSCOMPARE2_DB_HOST= # authoritative production host (not the NodePort test endpoint) -LOCUSCOMPARE2_DB_PORT=3306 +# The read-only serving account is `locuscompare` (54.254.162.217:31987); its password comes from the +# secret store / local `.env`, never committed. A public demo login (`locusview_test`) is kept for +# quick exploration only. Both exclude the `user` PII table. See the how-to (§0). +LOCUSCOMPARE2_DB_HOST=54.254.162.217 # provisioned endpoint (swap for a replica/prod host if used) +LOCUSCOMPARE2_DB_PORT=31987 # Kubernetes NodePort LOCUSCOMPARE2_DB_NAME=colotool -LOCUSCOMPARE2_DB_USER=locusview_ro -LOCUSCOMPARE2_DB_PASSWORD= -LOCUSCOMPARE2_DB_SSL_MODE=REQUIRED # require TLS for non-local connections +LOCUSCOMPARE2_DB_USER=locuscompare # read-only serving account +LOCUSCOMPARE2_DB_PASSWORD= # secret store / local .env — never commit a real value +LOCUSCOMPARE2_DB_SSL_MODE=REQUIRED # require TLS for non-local connections diff --git a/docs/how-to/connect-to-locuscompare2-database.md b/docs/how-to/connect-to-locuscompare2-database.md index 97b65d1..fc79b4a 100644 --- a/docs/how-to/connect-to-locuscompare2-database.md +++ b/docs/how-to/connect-to-locuscompare2-database.md @@ -28,7 +28,7 @@ Two locusview principals, least privilege each: | Principal | Used by | Privilege | When | |---|---|---|---| -| `locusview_ro` | Public read/serving path | `SELECT` only, on QTL tables (**not** `user`/PII) | Phase 1 | +| `locuscompare` | Public read/serving path | `SELECT` only, PII (`user`) denied | Phase 1 — **provisioned** | | `locusview_rw` | Contribution ingest only | `SELECT` + `INSERT`/`UPDATE` on an explicit, scoped table set | Phase 5 (created but unused/disabled until then) | locusview must **never** use the backend's `root` account (`db_config.py` ships a hardcoded shared @@ -37,10 +37,30 @@ not reproduced here). --- -## 0. Shared test/demo read-only account (verified 2026-07-02) +## 0. Read-only serving account — `locuscompare` (provisioned 2026-07-07) -A **non-production, read-only** account for development and the CI contract tests. Use it to explore -the schema and run the read-path examples — **never** for production serving. +**This is the account locusview uses.** A dedicated, named, read-only account on `colotool`, provided +by the locuscompare2 owners (Junbin). Its password lives in the deployment secret store (and, locally, +in the **gitignored `.env`**) — **never committed**. Wire it via the `LOCUSCOMPARE2_DB_*` env vars +(§4) and the app picks it up automatically (`get_db_settings()` reads `.env`). + +| Field | Value | +|---|---| +| Host | `54.254.162.217` (= `mysql.locuscompare2.com`) | +| Port | `31987` (Kubernetes NodePort) | +| Database | `colotool` | +| User | `locuscompare` | +| Password | *(secret store / local `.env` — not in this repo)* | + +**Verified read-only + PII-safe (2026-07-07):** `SHOW GRANTS` → 990 grants, all `SELECT`/`USAGE`, +**no write/DDL**; the `user` PII table is **denied** (`SELECT … FROM user` → `ERROR 1142`) and is not +in the grant set. This account fulfils the read-only serving role designed in §2. + +## 0b. Public demo login (`locusview_test`) — exploration only, superseded + +A deliberately public, low-value read-only login (password = username), kept for quick schema +exploration and offline examples. **Superseded by `locuscompare` for anything real** — do not use it +for serving. Same safety profile (read-only; `user` PII denied, verified 2026-07-02). | Field | Value | |---|---| @@ -102,6 +122,9 @@ is Option B in §2. ## 2. Create the read-only serving role +> The provisioned account (`locuscompare`, §0) already fulfils this role. The recipes below are the +> reference for **how** a least-privilege reader is scoped (and for minting a replica/prod one). + Table names include **dynamic shards** (`eqtl_snp_{id}`, `gwas_snp_{id}`, `colocalization_gene_result_{id}`), so per-table grants are impractical — new datasets would silently be unreadable until re-granted. Two viable shapes: @@ -169,17 +192,17 @@ Configuration lives in the environment (12-Factor), never in source — matching `.env.example`. The reader uses the same driver family as the backend (`PyMySQL`); the SQLAlchemy URL: ``` -mysql+pymysql://locusview_ro:@:/colotool?charset=utf8mb4 +mysql+pymysql://locuscompare:@:/colotool?charset=utf8mb4 ``` Environment variables (added to `.env.example`): ```bash -LOCUSCOMPARE2_DB_HOST= # authoritative host (NOT a laptop) -LOCUSCOMPARE2_DB_PORT=3306 +LOCUSCOMPARE2_DB_HOST=54.254.162.217 # provisioned endpoint (swap for a replica/prod host if used) +LOCUSCOMPARE2_DB_PORT=31987 # Kubernetes NodePort LOCUSCOMPARE2_DB_NAME=colotool -LOCUSCOMPARE2_DB_USER=locusview_ro -LOCUSCOMPARE2_DB_PASSWORD= # injected by the secret store; never committed +LOCUSCOMPARE2_DB_USER=locuscompare # read-only serving account (§0) +LOCUSCOMPARE2_DB_PASSWORD= # secret store / local .env; never committed LOCUSCOMPARE2_DB_SSL_MODE=REQUIRED ``` diff --git a/src/locusview/web.py b/src/locusview/web.py index 7c47226..edf5739 100644 --- a/src/locusview/web.py +++ b/src/locusview/web.py @@ -12,6 +12,8 @@ from __future__ import annotations +import csv +import io from pathlib import Path from fastapi import FastAPI, Response @@ -115,4 +117,46 @@ def gene_page(name: str) -> HTMLResponse: total_tissues=len(all_datasets), ) + @app.get("/gene/{key}/download") + def download(key: str, format: str = "csv") -> Response: + """Download a gene's eQTL table (the current gene-page view) as CSV or TSV.""" + if format not in ("csv", "tsv"): + return Response( + "format must be 'csv' or 'tsv'", status_code=400, media_type="text/plain" + ) + gene = repo.resolve_gene(key) + if gene is None: + return Response(f"gene not found: {key}", status_code=404, media_type="text/plain") + + datasets = repo.datasets()[:_MAX_TISSUES] + by_id: dict[int, Dataset] = {d.id: d for d in datasets} + eqtls = repo.eqtls_for_gene(gene.gene_id, [d.id for d in datasets], limit=50) + + buffer = io.StringIO() + writer = csv.writer(buffer, delimiter="," if format == "csv" else "\t") + writer.writerow( + ["gene", "ensembl_id", "tissue", "variant", "chrom", "position", "pvalue", "beta", "se"] + ) + for a in eqtls: + dataset = by_id[a.dataset_id] # eqtls_for_gene only returns the datasets we asked for + writer.writerow( + [ + gene.symbol, + gene.ensembl_id, + dataset.tissue, + f"rs{a.rs_id}" if a.rs_id is not None else "", + a.chrom, + a.position, + a.pvalue, + a.beta, + a.se, + ] + ) + media_type = "text/csv" if format == "csv" else "text/tab-separated-values" + return Response( + content=buffer.getvalue(), + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{gene.symbol}_eqtls.{format}"'}, + ) + return app diff --git a/tests/test_web.py b/tests/test_web.py index 61df1f3..0601b7c 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -69,3 +69,31 @@ def test_search_unsupported_query_is_404() -> None: response = _gene_client().get("/search", params={"q": "rs12345"}, follow_redirects=False) assert response.status_code == 404 assert "gene search" in response.text.lower() + + +# ── download (CSV / TSV) ──────────────────────────────────────────────────── + + +def test_download_csv() -> None: + response = _gene_client().get("/gene/TP53/download") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/csv") + assert 'attachment; filename="TP53_eqtls.csv"' in response.headers["content-disposition"] + lines = response.text.strip().splitlines() + assert lines[0] == "gene,ensembl_id,tissue,variant,chrom,position,pvalue,beta,se" + assert "TP53" in lines[1] and "Whole_Blood" in lines[1] and "rs12345" in lines[1] + + +def test_download_tsv() -> None: + response = _gene_client().get("/gene/TP53/download", params={"format": "tsv"}) + assert response.status_code == 200 + assert "\t" in response.text + assert 'filename="TP53_eqtls.tsv"' in response.headers["content-disposition"] + + +def test_download_unknown_gene_is_404() -> None: + assert _gene_client().get("/gene/NOPE/download").status_code == 404 + + +def test_download_bad_format_is_400() -> None: + assert _gene_client().get("/gene/TP53/download", params={"format": "xml"}).status_code == 400