[Feature] Documentation for development setup

## Summary

This PR improves the local development setup experience by adding Docker Compose and Makefile for streamlined setup.

## Changes

- **Added `docker-compose.yml`**: for one-command PostgreSQL database setup
- **Added `Makefile`**: Convenient shortcuts for common dev tasks (\`make setup\`, \`make dev\`, etc.)
- **Updated `README.md`**: Quick development setup instructions using Make
-
- **Added**: RDkit installation pain point documentation
- **Fixed**: Made Java feature properly dependent

## Why these changes?

The application uses PostgreSQL-specific features (\`ArrayField\`) and requires an anonymous user created by the bootstrap command. This PR makes the setup process trivial for new developers:

```bash
cp .env.local.example .env
make setup  # Starts DB, runs migrations, bootstraps data
make dev    # Starts development server
```

Java fix:
Moved global Java import to inline to avoid everyone having to configure the Java path.

Numerous changes to view and settings.
- Applied ruff-formatting

## Testing

Verified complete setup from scratch works with:
- PostgreSQL running in Docker
- All migrations applied
- Bootstrap data loaded successfully
- Anonymous user created
- The development server starts correctly.

Co-authored-by: Tobias O <tobias.olenyi@tum.de>
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-authored-by: Liam <62733830+limmooo@users.noreply.github.com>
Reviewed-on: enviPath/enviPy#143
Reviewed-by: jebus <lorsbach@envipath.com>
Reviewed-by: liambrydon <lbry121@aucklanduni.ac.nz>
Co-authored-by: t03i <mail+envipath@t03i.net>
Co-committed-by: t03i <mail+envipath@t03i.net>
This commit is contained in:
2025-10-08 18:51:50 +13:00
committed by jebus
parent c2c46fbfa7
commit 36879c266b
11 changed files with 1570 additions and 836 deletions

View File

@ -12,7 +12,8 @@ from epdb.logic import PackageManager
from epdb.models import Rule, SimpleAmbitRule, Package, CompoundStructure
from epdb.views import get_base_context, _anonymous_or_real
from utilities.chem import FormatConverter
from envipy_ambit import apply
from rdkit import Chem
from rdkit.Chem.MolStandardize import rdMolStandardize
@ -31,11 +32,19 @@ def normalize_smiles(smiles):
def run_both_engines(SMILES, SMIRKS):
from envipy_ambit import apply
ambit_res = apply(SMIRKS, SMILES)
# ambit_res, ambit_errors = FormatConverter.sanitize_smiles([str(s) for s in ambit_res])
ambit_res = list(set([normalize_smiles(str(x)) for x in
FormatConverter.sanitize_smiles([str(s) for s in ambit_res])[0]]))
ambit_res = list(
set(
[
normalize_smiles(str(x))
for x in FormatConverter.sanitize_smiles([str(s) for s in ambit_res])[0]
]
)
)
products = FormatConverter.apply(SMILES, SMIRKS)
@ -46,22 +55,39 @@ def run_both_engines(SMILES, SMIRKS):
all_rdkit_prods = list(set(all_rdkit_prods))
# all_rdkit_res, rdkit_errors = FormatConverter.sanitize_smiles(all_rdkit_prods)
all_rdkit_res = list(set([normalize_smiles(str(x)) for x in
FormatConverter.sanitize_smiles([str(s) for s in all_rdkit_prods])[0]]))
all_rdkit_res = list(
set(
[
normalize_smiles(str(x))
for x in FormatConverter.sanitize_smiles(
[str(s) for s in all_rdkit_prods]
)[0]
]
)
)
# return ambit_res, ambit_errors, all_rdkit_res, rdkit_errors
return ambit_res, 0, all_rdkit_res, 0
def migration(request):
if request.method == 'GET':
if request.method == "GET":
context = get_base_context(request)
if os.path.exists(s.BASE_DIR / 'fixtures' / 'migration_status_per_rule.json') and request.GET.get(
"force") is None:
migration_status = json.load(open(s.BASE_DIR / 'fixtures' / 'migration_status_per_rule.json'))
if (
os.path.exists(s.BASE_DIR / "fixtures" / "migration_status_per_rule.json")
and request.GET.get("force") is None
):
migration_status = json.load(
open(s.BASE_DIR / "fixtures" / "migration_status_per_rule.json")
)
else:
BBD = Package.objects.get(url='http://localhost:8000/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1')
ALL_SMILES = [cs.smiles for cs in CompoundStructure.objects.filter(compound__package=BBD)]
BBD = Package.objects.get(
url="http://localhost:8000/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1"
)
ALL_SMILES = [
cs.smiles
for cs in CompoundStructure.objects.filter(compound__package=BBD)
]
RULES = SimpleAmbitRule.objects.filter(package=BBD)
results = list()
@ -71,7 +97,7 @@ def migration(request):
total = 0
for i, r in enumerate(RULES):
logger.debug(f'\r{i + 1:03d}/{num_rules}')
logger.debug(f"\r{i + 1:03d}/{num_rules}")
res = True
for smiles in ALL_SMILES:
try:
@ -81,13 +107,19 @@ def migration(request):
except Exception as e:
logger.error(e)
results.append({
'name': r.name,
'detail_url': s.SERVER_URL + '/migration/' + r.url.replace('https://envipath.org/', '').replace('http://localhost:8000/', ''),
'id': str(r.uuid),
'url': r.url,
'status': res,
})
results.append(
{
"name": r.name,
"detail_url": s.SERVER_URL
+ "/migration/"
+ r.url.replace("https://envipath.org/", "").replace(
"http://localhost:8000/", ""
),
"id": str(r.uuid),
"url": r.url,
"status": res,
}
)
if res:
success += 1
@ -95,32 +127,37 @@ def migration(request):
error += 1
total += 1
results = sorted(results, key=lambda x: (x['status'], x['name']))
results = sorted(results, key=lambda x: (x["status"], x["name"]))
migration_status = {
'results': results,
'success': success,
'error': error,
'total': total
"results": results,
"success": success,
"error": error,
"total": total,
}
json.dump(migration_status, open(s.BASE_DIR / 'fixtures' / 'migration_status_per_rule.json', 'w'))
json.dump(
migration_status,
open(s.BASE_DIR / "fixtures" / "migration_status_per_rule.json", "w"),
)
for r in migration_status['results']:
r['detail_url'] = r['detail_url'].replace('http://localhost:8000', s.SERVER_URL)
for r in migration_status["results"]:
r["detail_url"] = r["detail_url"].replace(
"http://localhost:8000", s.SERVER_URL
)
context.update(**migration_status)
return render(request, 'migration.html', context)
return render(request, "migration.html", context)
def migration_detail(request, package_uuid, rule_uuid):
current_user = _anonymous_or_real(request)
if request.method == 'GET':
if request.method == "GET":
context = get_base_context(request)
BBD = Package.objects.get(name='EAWAG-BBD')
BBD = Package.objects.get(name="EAWAG-BBD")
STRUCTURES = CompoundStructure.objects.filter(compound__package=BBD)
rule = Rule.objects.get(package=BBD, uuid=rule_uuid)
@ -132,8 +169,9 @@ def migration_detail(request, package_uuid, rule_uuid):
all_prods = set()
for structure in STRUCTURES:
ambit_smiles, ambit_errors, rdkit_smiles, rdkit_errors = run_both_engines(structure.smiles, smirks)
ambit_smiles, ambit_errors, rdkit_smiles, rdkit_errors = run_both_engines(
structure.smiles, smirks
)
for x in ambit_smiles:
all_prods.add(x)
@ -152,13 +190,13 @@ def migration_detail(request, package_uuid, rule_uuid):
if len(ambit_smiles) or len(rdkit_smiles):
temp = {
'url': structure.url,
'id': str(structure.uuid),
'name': structure.name,
'initial_smiles': structure.smiles,
'ambit_smiles': sorted(list(ambit_smiles)),
'rdkit_smiles': sorted(list(rdkit_smiles)),
'status': set(ambit_smiles) == set(rdkit_smiles),
"url": structure.url,
"id": str(structure.uuid),
"name": structure.name,
"initial_smiles": structure.smiles,
"ambit_smiles": sorted(list(ambit_smiles)),
"rdkit_smiles": sorted(list(rdkit_smiles)),
"status": set(ambit_smiles) == set(rdkit_smiles),
}
detail = f"""
BT: {bt_rule_name}
@ -177,29 +215,32 @@ def migration_detail(request, package_uuid, rule_uuid):
rdkit_errors: {rdkit_errors}
"""
temp['detail'] = '\n'.join([x.strip() for x in detail.split('\n')])
temp["detail"] = "\n".join([x.strip() for x in detail.split("\n")])
results.append(temp)
res &= partial_res
results = sorted(results, key=lambda x: x['status'])
context['results'] = results
context['res'] = res
context['bt_rule_name'] = bt_rule_name
return render(request, 'migration_detail.html', context)
results = sorted(results, key=lambda x: x["status"])
context["results"] = results
context["res"] = res
context["bt_rule_name"] = bt_rule_name
return render(request, "migration_detail.html", context)
def compare(request):
context = get_base_context(request)
if request.method == 'GET':
context[
"smirks"] = "[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([#1,#6:7])[H:3].[#6;A:2]=O"
context["smiles"] = "C(CC(=O)N[C@@H](CS[Se-])C(=O)NCC(=O)[O-])[C@@H](C(=O)[O-])N"
return render(request, 'compare.html', context)
if request.method == "GET":
context["smirks"] = (
"[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([#1,#6:7])[H:3].[#6;A:2]=O"
)
context["smiles"] = (
"C(CC(=O)N[C@@H](CS[Se-])C(=O)NCC(=O)[O-])[C@@H](C(=O)[O-])N"
)
return render(request, "compare.html", context)
elif request.method == 'POST':
elif request.method == "POST":
smiles = request.POST.get("smiles")
smirks = request.POST.get("smirks")
@ -219,9 +260,9 @@ def compare(request):
rdkit_res, _ = FormatConverter.sanitize_smiles(all_rdkit_prods)
context["result"] = True
context['ambit_res'] = sorted(set(ambit_res))
context['rdkit_res'] = sorted(set(rdkit_res))
context['diff'] = sorted(set(ambit_res).difference(set(rdkit_res)))
context["ambit_res"] = sorted(set(ambit_res))
context["rdkit_res"] = sorted(set(rdkit_res))
context["diff"] = sorted(set(ambit_res).difference(set(rdkit_res)))
context["smirks"] = smirks
context["smiles"] = smiles
@ -230,7 +271,7 @@ def compare(request):
if r.exists():
context["rule"] = r.first()
return render(request, 'compare.html', context)
return render(request, "compare.html", context)
else:
return HttpResponseNotAllowed(['GET', 'POST'])
return HttpResponseNotAllowed(["GET", "POST"])