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
15 changes: 8 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 32 additions & 9 deletions docs/how-to/connect-to-locuscompare2-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
|---|---|
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:<secret>@<host>:<port>/colotool?charset=utf8mb4
mysql+pymysql://locuscompare:<secret>@<host>:<port>/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
```

Expand Down
44 changes: 44 additions & 0 deletions src/locusview/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

from __future__ import annotations

import csv
import io
from pathlib import Path

from fastapi import FastAPI, Response
Expand Down Expand Up @@ -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
28 changes: 28 additions & 0 deletions tests/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading