18 Commits

Author SHA1 Message Date
93390b349b Initial 2026-02-12 14:03:10 +01:00
58ab5b33e3 [Fix] Filter Active Users (#314) (#329)
Adding users to a group or setting permissions on a package now filter for active users. Also any inactive members of group/package get marked as such.

<img width="490" alt="{3B906C71-F3AE-41E4-A61C-B8377D79F685}.png" src="attachments/09cf149a-9d7a-4560-8ce7-9f3487527ee2">

Reviewed-on: enviPath/enviPy#329
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2026-02-12 20:20:16 +13:00
73f0202267 [Fix] Filter Scenarios with Parent (#311) (#323)
The scenarios lists both in /scenarios and /package/<id>/scenario no longer show related scenarios (children).
All related scenarios are shown on the scenario page under Related Scenarios if there are any.
<img width="500" alt="{C2D38DED-A402-4A27-A241-BC2302C62A50}.png" src="attachments/1371c177-220c-42d5-94ff-56f9fbab761f">

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#323
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2026-02-11 23:19:20 +13:00
27c5bad9c5 [Fix] Upgraded ai-lib, temporarily ignore additional validation errors (#328)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#328
2026-02-11 03:49:20 +13:00
5789f20e7f [Feature] Create API Key Authenticaton for v1 API (#327)
Add API key authentication to v1 API
Also includes:
- management command to create keys for users
- Improvements to API tests

Minor:
- more robust way to start docker dev container.

Reviewed-on: enviPath/enviPy#327
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-02-11 02:29:54 +13:00
c0cfdb9255 [Style] Adds custom name display for timeseries (#320)
<img width="1336" alt="image.png" src="attachments/58e49257-976e-469f-a19e-069c8915c437">

Co-authored-by: jebus <lorsbach@envipath.com>
Reviewed-on: enviPath/enviPy#320
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-02-04 08:15:16 +13:00
5da8dbc191 [Feature] Timeseries Pathway view (#319)
**Warning depends on Timeseries feature to be merged**

Implements a way to display OECD 301F data on the pathway view.
This is mostly a PoC and needs to be improved once the pathway rendering is updated.

![image.png](/attachments/053965d7-78f7-487a-b5d0-898612708fa3)

Co-authored-by: jebus <lorsbach@envipath.com>
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#319
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-02-04 05:19:25 +13:00
dc18b73e08 [Feature] Adds timeseries display (#313)
Adds a way to input/display timeseries data to the additional information

Reviewed-on: enviPath/enviPy#313
Reviewed-by: jebus <lorsbach@envipath.com>
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-02-04 01:01:06 +13:00
d80dfb5ee3 [Feature] Dynamic additional information rendering in frontend (#282)
This implements a version of #274, relying on Pydantics built in JSON schema and JSON rendering.
Requires additional UI tagging in the ai model repo but will remove HTML tags.

Example scenario with filled information: 5882df9c-dae1-4d80-a40e-db4724271456/scenario/3a4d395a-6a6d-4154-8ce3-ced667fceec0

Reviewed-on: enviPath/enviPy#282
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-01-31 00:44:03 +13:00
9f63a9d4de [Fix] Fixed ObjectDoesNotExist for send_registration_mail, fixed duplicate mail sending (#312)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#312
2026-01-29 20:24:11 +13:00
5565b9cb9e [Fix] UI bugs, Registrations Mail, BTRules Popup, Legacy API fixes (#309)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#309
2026-01-29 11:13:34 +13:00
ab0b5a5186 [Feature] Leftovers after Release (#303)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#303
2026-01-22 10:26:38 +13:00
f905bf21cf [Feature] Update ToS to be more legally safe and sensible (#301)
- Improved ToS content
- Add ToS pointer and academic use note at signup
- Remove legal collection page (unnecessary)

Reviewed-on: enviPath/enviPy#301
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-01-20 03:18:40 +13:00
1fd993927c [Feature] Check os.environ for ENV_PATH (#300)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#300
2026-01-19 23:41:43 +13:00
2a2fe4f147 Static pages update (#299)
I have updated the static pages following @wicker comments on #275

Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Reviewed-on: enviPath/enviPy#299
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2026-01-19 23:37:08 +13:00
5f5ae76182 [Fix] Fix Prediction Spinner, ensure proper pathway status is set
Fixes Spinner and status message display on pathway page

Reviewed-on: enviPath/enviPy#291
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-01-15 23:09:12 +13:00
1c2f70b3b9 [Feature] Add batch-predict to site-map (#285)
Adds batch predict to the site-map but does not give it prominence.
This is to avoid non-experts "accidentally" flooding the system.

Happy to move it to the main menu if better, @jebus?

Reviewed-on: enviPath/enviPy#285
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-01-15 22:30:31 +13:00
54f8302104 [FIX] Fixed Search Output, Legacy API Model Endpoint, Handle ObjectsDoesNotExists in views (#297)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#297
2026-01-15 20:39:54 +13:00
126 changed files with 8243 additions and 3566 deletions

View File

@ -48,11 +48,6 @@ runs:
shell: bash shell: bash
run: | run: |
uv run python scripts/pnpm_wrapper.py install uv run python scripts/pnpm_wrapper.py install
cat << 'EOF' > pnpm-workspace.yaml
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
EOF
uv run python scripts/pnpm_wrapper.py run build uv run python scripts/pnpm_wrapper.py run build
- name: Wait for Postgres - name: Wait for Postgres

View File

@ -8,13 +8,12 @@ These instructions will guide you through setting up the project for local devel
- Python 3.11 or later - Python 3.11 or later
- [uv](https://github.com/astral-sh/uv) - Python package manager - [uv](https://github.com/astral-sh/uv) - Python package manager
- **Docker and Docker Compose** - Required for running PostgreSQL database - **Docker and Docker Compose** - Required for running PostgreSQL database and Redis (for async Celery tasks)
- Git - Git
- Make - Make
> **Note:** This application requires PostgreSQL (uses `ArrayField`). Docker is the easiest way to run PostgreSQL locally. > **Note:** This application requires PostgreSQL (uses `ArrayField`). Docker is the easiest way to run PostgreSQL locally.
### 1. Install Dependencies ### 1. Install Dependencies
This project uses `uv` to manage dependencies and `poe-the-poet` for task running. First, [install `uv` if you don't have it yet](https://docs.astral.sh/uv/guides/install-python/). This project uses `uv` to manage dependencies and `poe-the-poet` for task running. First, [install `uv` if you don't have it yet](https://docs.astral.sh/uv/guides/install-python/).
@ -79,21 +78,44 @@ uv run poe bootstrap # Bootstrap data only
uv run poe shell # Open the Django shell uv run poe shell # Open the Django shell
uv run poe build # Build frontend assets and collect static files uv run poe build # Build frontend assets and collect static files
uv run poe clean # Remove database volumes (WARNING: destroys all data) uv run poe clean # Remove database volumes (WARNING: destroys all data)
uv run poe celery # Start Celery worker for async task processing
uv run poe celery-dev # Start database and Celery worker
```
### 4. Async Celery Setup (Optional)
By default, Celery tasks run synchronously (`CELERY_TASK_ALWAYS_EAGER = True`), which means prediction tasks block the HTTP request until completion. To enable asynchronous task processing with live status updates on pathway pages:
1. **Set the Celery flag in your `.env` file:**
```bash
FLAG_CELERY_PRESENT=True
```
2. **Start Redis and Celery worker:**
```bash
uv run poe celery-dev
```
3. **Start the development server** (in another terminal):
```bash
uv run poe dev
``` ```
### Troubleshooting ### Troubleshooting
* **Docker Connection Error:** If you see an error like `open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified` (on Windows), it likely means your Docker Desktop application is not running. Please start Docker Desktop and try the command again. - **Docker Connection Error:** If you see an error like `open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified` (on Windows), it likely means your Docker Desktop application is not running. Please start Docker Desktop and try the command again.
* **SSH Keys for Git Dependencies:** Some dependencies are installed from private git repositories and require SSH authentication. Ensure your SSH keys are configured correctly for Git.
* For a general guide, see [GitHub's official documentation](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent).
* **Windows Users:** If `uv sync` hangs while fetching git dependencies, you may need to explicitly configure Git to use the Windows OpenSSH client and use the `ssh-agent` to manage your key's passphrase.
- **SSH Keys for Git Dependencies:** Some dependencies are installed from private git repositories and require SSH authentication. Ensure your SSH keys are configured correctly for Git.
- For a general guide, see [GitHub's official documentation](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent).
- **Windows Users:** If `uv sync` hangs while fetching git dependencies, you may need to explicitly configure Git to use the Windows OpenSSH client and use the `ssh-agent` to manage your key's passphrase.
1. **Point Git to the correct SSH executable:** 1. **Point Git to the correct SSH executable:**
```powershell ```powershell
git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe" git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"
``` ```
2. **Enable and use the SSH agent:** 2. **Enable and use the SSH agent:**
```powershell ```powershell
# Run these commands in an administrator PowerShell # Run these commands in an administrator PowerShell
Get-Service ssh-agent | Set-Service -StartupType Automatic -PassThru | Start-Service Get-Service ssh-agent | Set-Service -StartupType Automatic -PassThru | Start-Service

0
bayer/__init__.py Normal file
View File

3
bayer/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
bayer/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BayerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'bayer'

View File

@ -0,0 +1,35 @@
# Generated by Django 5.2.7 on 2026-02-12 12:36
import django.utils.timezone
import model_utils.fields
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Package',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('reviewed', models.BooleanField(default=False, verbose_name='Reviewstatus')),
('classification_level', models.IntegerField(choices=[(0, 'Internal'), (10, 'Restricted'), (20, 'Secret')], default=10)),
],
options={
'db_table': 'epdb_package',
},
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2026-02-12 12:36
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('bayer', '0001_initial'),
('epdb', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='package',
name='license',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.license', verbose_name='License'),
),
]

View File

95
bayer/models.py Normal file
View File

@ -0,0 +1,95 @@
from typing import List
from django.conf import settings as s
from django.db import models
from django.db.models import QuerySet
from epdb.models import (
EnviPathModel,
ParallelRule,
SequentialRule,
SimpleAmbitRule,
SimpleRDKitRule,
)
class Package(EnviPathModel):
reviewed = models.BooleanField(verbose_name="Reviewstatus", default=False)
license = models.ForeignKey(
"epdb.License", on_delete=models.SET_NULL, blank=True, null=True, verbose_name="License"
)
class Classification(models.IntegerChoices):
INTERNAL = 0, "Internal"
RESTRICTED = 10 , "Restricted"
SECRET = 20, "Secret"
classification_level = models.IntegerField(
choices=Classification,
default=Classification.RESTRICTED,
)
def delete(self, *args, **kwargs):
# explicitly handle related Rules
for r in self.rules.all():
r.delete()
super().delete(*args, **kwargs)
def __str__(self):
return f"{self.name} (pk={self.pk})"
@property
def compounds(self) -> QuerySet:
return self.compound_set.all()
@property
def rules(self) -> QuerySet:
return self.rule_set.all()
@property
def reactions(self) -> QuerySet:
return self.reaction_set.all()
@property
def pathways(self) -> QuerySet:
return self.pathway_set.all()
@property
def scenarios(self) -> QuerySet:
return self.scenario_set.all()
@property
def models(self) -> QuerySet:
return self.epmodel_set.all()
def _url(self):
return "{}/package/{}".format(s.SERVER_URL, self.uuid)
def get_applicable_rules(self) -> List["Rule"]:
"""
Returns a ordered set of rules where the following applies:
1. All Composite will be added to result
2. All SimpleRules will be added if theres no CompositeRule present using the SimpleRule
Ordering is based on "url" field.
"""
rules = []
rule_qs = self.rules
reflected_simple_rules = set()
for r in rule_qs:
if isinstance(r, ParallelRule) or isinstance(r, SequentialRule):
rules.append(r)
for sr in r.simple_rules.all():
reflected_simple_rules.add(sr)
for r in rule_qs:
if isinstance(r, SimpleAmbitRule) or isinstance(r, SimpleRDKitRule):
if r not in reflected_simple_rules:
rules.append(r)
rules = sorted(rules, key=lambda x: x.url)
return rules
class Meta:
db_table = "epdb_package"

View File

@ -0,0 +1,97 @@
{% extends "framework_modern.html" %}
{% block content %}
{% block action_modals %}
{% include "modals/objects/edit_package_modal.html" %}
{% include "modals/objects/edit_package_permissions_modal.html" %}
{% include "modals/objects/publish_package_modal.html" %}
{% include "modals/objects/set_license_modal.html" %}
{% include "modals/objects/export_package_modal.html" %}
{% include "modals/objects/generic_delete_modal.html" %}
{% endblock action_modals %}
<div class="space-y-2 p-4">
<!-- Header Section -->
<div class="card bg-base-100">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">{{ package.name }} - <i>{{ package.get_classification_level_display }}</i></h2>
<div id="actionsButton" class="dropdown dropdown-e nd hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-wrench"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
Actions
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
>
{% block actions %}
{% include "actions/objects/package.html" %}
{% endblock %}
</ul>
</div>
</div>
<p class="mt-2">{{ package.description|safe }}</p>
<ul class="menu bg-base-200 rounded-box mt-4 w-full">
<li>
<a href="{{ package.url }}/pathway" class="hover:bg-base-300"
>Pathways ({{ package.pathways.count }})</a
>
</li>
<li>
<a href="{{ package.url }}/rule" class="hover:bg-base-300"
>Rules ({{ package.rules.count }})</a
>
</li>
<li>
<a href="{{ package.url }}/compound" class="hover:bg-base-300"
>Compounds ({{ package.compounds.count }})</a
>
</li>
<li>
<a href="{{ package.url }}/reaction" class="hover:bg-base-300"
>Reactions ({{ package.reactions.count }})</a
>
</li>
<li>
<a href="{{ package.url }}/model" class="hover:bg-base-300"
>Models ({{ package.models.count }})</a
>
</li>
<li>
<a href="{{ package.url }}/scenario" class="hover:bg-base-300"
>Scenarios ({{ package.scenarios.count }})</a
>
</li>
</ul>
</div>
</div>
</div>
<script>
// Show actions button if there are actions
document.addEventListener("DOMContentLoaded", function () {
const actionsButton = document.getElementById("actionsButton");
const actionsList = actionsButton?.querySelector("ul");
if (actionsList && actionsList.children.length > 0) {
actionsButton?.classList.remove("hidden");
}
});
</script>
{% endblock content %}

3
bayer/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
bayer/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@ -1,6 +1,6 @@
services: services:
db: db:
image: postgres:15 image: postgres:18
container_name: envipath-postgres container_name: envipath-postgres
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
@ -9,12 +9,18 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
redis:
image: redis:7-alpine
container_name: envipath-redis
ports:
- "6379:6379"
volumes: volumes:
postgres_data: postgres_data:

View File

@ -21,7 +21,9 @@ from sklearn.tree import DecisionTreeClassifier
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env", override=False) ENV_PATH = os.environ.get("ENV_PATH", BASE_DIR / ".env")
print(f"Loading env from {ENV_PATH}")
load_dotenv(ENV_PATH, override=False)
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
@ -50,7 +52,8 @@ INSTALLED_APPS = [
# Custom # Custom
"epapi", # API endpoints (v1, etc.) "epapi", # API endpoints (v1, etc.)
"epdb", "epdb",
# "migration", "migration",
"bayer",
] ]
TENANT = os.environ.get("TENANT", "public") TENANT = os.environ.get("TENANT", "public")
@ -91,10 +94,15 @@ if os.environ.get("REGISTRATION_MANDATORY", False) == "True":
ROOT_URLCONF = "envipath.urls" ROOT_URLCONF = "envipath.urls"
TEMPLATE_DIRS = [
os.path.join(BASE_DIR, "bayer", "templates"),
os.path.join(BASE_DIR, "templates"),
]
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": (os.path.join(BASE_DIR, "templates"),), "DIRS": TEMPLATE_DIRS,
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
"context_processors": [ "context_processors": [

View File

@ -0,0 +1 @@
"""Tests for epapi utility modules."""

View File

@ -0,0 +1,218 @@
"""
Tests for validation error utilities.
Tests the format_validation_error() and handle_validation_error() functions
that transform Pydantic validation errors into user-friendly messages.
"""
from django.test import TestCase, tag
import json
from pydantic import BaseModel, ValidationError, field_validator
from typing import Literal
from ninja.errors import HttpError
from epapi.utils.validation_errors import format_validation_error, handle_validation_error
@tag("api", "utils")
class ValidationErrorUtilityTests(TestCase):
"""Test validation error utility functions."""
def test_format_missing_field_error(self):
"""Test formatting of missing required field error."""
# Create a model with required field
class TestModel(BaseModel):
required_field: str
# Trigger validation error
try:
TestModel()
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, "This field is required")
def test_format_enum_error(self):
"""Test formatting of enum validation error."""
class TestModel(BaseModel):
status: Literal["active", "inactive"]
try:
TestModel(status="invalid")
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
# Literal errors get formatted as "Please enter ..." with the valid options
self.assertIn("Please enter", formatted)
self.assertIn("active", formatted)
self.assertIn("inactive", formatted)
def test_format_type_errors(self):
"""Test formatting of type validation errors (string, int, float)."""
test_cases = [
# (field_type, invalid_value, expected_message)
# Note: We don't check exact error_type as Pydantic may use different types
# (e.g., int_type vs int_parsing) but we verify the formatted message is correct
(str, 123, "Please enter a valid string"),
(int, "not_a_number", "Please enter a valid int"),
(float, "not_a_float", "Please enter a valid float"),
]
for field_type, invalid_value, expected_message in test_cases:
with self.subTest(field_type=field_type.__name__):
class TestModel(BaseModel):
field: field_type
try:
TestModel(field=invalid_value)
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, expected_message)
def test_format_value_error(self):
"""Test formatting of value error from custom validator."""
class TestModel(BaseModel):
age: int
@field_validator("age")
@classmethod
def validate_age(cls, v):
if v < 0:
raise ValueError("Age must be positive")
return v
try:
TestModel(age=-5)
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, "Age must be positive")
def test_format_unknown_error_type_fallback(self):
"""Test that unknown error types fall back to default formatting."""
# Mock an error with an unknown type
mock_error = {
"type": "unknown_custom_type",
"msg": "Input should be a valid email address",
"ctx": {},
}
formatted = format_validation_error(mock_error)
# Should use the else branch which does replacements on the message
self.assertEqual(formatted, "Please enter a valid email address")
def test_handle_validation_error_structure(self):
"""Test that handle_validation_error raises HttpError with correct structure."""
class TestModel(BaseModel):
name: str
count: int
try:
TestModel(name=123, count="invalid")
except ValidationError as e:
# handle_validation_error should raise HttpError
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
self.assertEqual(http_error.status_code, 400)
# Parse the JSON from the error message
error_data = json.loads(http_error.message)
# Check structure
self.assertEqual(error_data["type"], "validation_error")
self.assertIn("field_errors", error_data)
self.assertIn("message", error_data)
self.assertEqual(error_data["message"], "Please correct the errors below")
# Check that both fields have errors
self.assertIn("name", error_data["field_errors"])
self.assertIn("count", error_data["field_errors"])
def test_handle_validation_error_no_pydantic_internals(self):
"""Test that handle_validation_error doesn't expose Pydantic internals."""
class TestModel(BaseModel):
email: str
try:
TestModel(email=123)
except ValidationError as e:
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
error_data = json.loads(http_error.message)
error_str = json.dumps(error_data)
# Ensure no Pydantic internals are exposed
self.assertNotIn("pydantic", error_str.lower())
self.assertNotIn("https://errors.pydantic.dev", error_str)
self.assertNotIn("loc", error_str)
def test_handle_validation_error_user_friendly_messages(self):
"""Test that all error messages are user-friendly."""
class TestModel(BaseModel):
name: str
age: int
status: Literal["active", "inactive"]
try:
TestModel(name=123, status="invalid") # Multiple errors
except ValidationError as e:
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
error_data = json.loads(http_error.message)
# All messages should be user-friendly (contain "Please" or "This field")
for field, messages in error_data["field_errors"].items():
for message in messages:
# User-friendly messages start with "Please" or "This field"
self.assertTrue(
message.startswith("Please") or message.startswith("This field"),
f"Message '{message}' is not user-friendly",
)
def test_handle_validation_error_multiple_errors_same_field(self):
"""Test handling multiple validation errors for the same field."""
class TestModel(BaseModel):
value: int
@field_validator("value")
@classmethod
def validate_range(cls, v):
if v < 0:
raise ValueError("Must be non-negative")
if v > 100:
raise ValueError("Must be at most 100")
return v
# Test with string (type error) - this will fail before the validator runs
try:
TestModel(value="invalid")
except ValidationError as e:
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
error_data = json.loads(http_error.message)
# Should have error for 'value' field
self.assertIn("value", error_data["field_errors"])
self.assertIsInstance(error_data["field_errors"]["value"], list)
self.assertGreater(len(error_data["field_errors"]["value"]), 0)

View File

@ -0,0 +1,448 @@
"""
Tests for Additional Information API endpoints.
Tests CRUD operations on scenario additional information including the new PATCH endpoint.
"""
from django.test import TestCase, tag
import json
from uuid import uuid4
from epdb.logic import PackageManager, UserManager
from epdb.models import Scenario
@tag("api", "additional_information")
class AdditionalInformationAPITests(TestCase):
"""Test additional information API endpoints."""
@classmethod
def setUpTestData(cls):
"""Set up test data: user, package, and scenario."""
cls.user = UserManager.create_user(
"ai-test-user",
"ai-test@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
cls.other_user = UserManager.create_user(
"ai-other-user",
"ai-other@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
cls.package = PackageManager.create_package(
cls.user, "AI Test Package", "Test package for additional information"
)
# Package owned by other_user (no access for cls.user)
cls.other_package = PackageManager.create_package(
cls.other_user, "Other Package", "Package without access"
)
# Create a scenario for testing
cls.scenario = Scenario.objects.create(
package=cls.package,
name="Test Scenario",
description="Test scenario for additional information tests",
scenario_type="biodegradation",
scenario_date="2024-01-01",
additional_information={}, # Initialize with empty dict
)
cls.other_scenario = Scenario.objects.create(
package=cls.other_package,
name="Other Scenario",
description="Scenario in package without access",
scenario_type="biodegradation",
scenario_date="2024-01-01",
additional_information={},
)
def test_list_all_schemas(self):
"""Test GET /api/v1/information/schema/ returns all schemas."""
self.client.force_login(self.user)
response = self.client.get("/api/v1/information/schema/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIsInstance(data, dict)
# Should have multiple schemas
self.assertGreater(len(data), 0)
# Each schema should have RJSF format
for name, schema in data.items():
self.assertIn("schema", schema)
self.assertIn("uiSchema", schema)
self.assertIn("formData", schema)
self.assertIn("groups", schema)
def test_get_specific_schema(self):
"""Test GET /api/v1/information/schema/{model_name}/ returns specific schema."""
self.client.force_login(self.user)
# Assuming 'temperature' is a valid model
response = self.client.get("/api/v1/information/schema/temperature/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("schema", data)
self.assertIn("uiSchema", data)
def test_get_nonexistent_schema_returns_404(self):
"""Test GET for non-existent schema returns 404."""
self.client.force_login(self.user)
response = self.client.get("/api/v1/information/schema/nonexistent/")
self.assertEqual(response.status_code, 404)
def test_list_scenario_information_empty(self):
"""Test GET /api/v1/scenario/{uuid}/information/ returns empty list initially."""
self.client.force_login(self.user)
response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIsInstance(data, list)
self.assertEqual(len(data), 0)
def test_create_additional_information(self):
"""Test POST creates additional information."""
self.client.force_login(self.user)
# Create temperature information (assuming temperature model exists)
payload = {"interval": {"start": 20, "end": 25}}
response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["status"], "created")
self.assertIn("uuid", data)
self.assertIsNotNone(data["uuid"])
def test_create_with_invalid_data_returns_400(self):
"""Test POST with invalid data returns 400 with validation errors."""
self.client.force_login(self.user)
# Invalid data (missing required fields or wrong types)
payload = {"invalid_field": "value"}
response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
data = response.json()
# Should have validation error details in 'detail' field
self.assertIn("detail", data)
def test_validation_errors_are_user_friendly(self):
"""Test that validation errors are user-friendly and field-specific."""
self.client.force_login(self.user)
# Invalid data - wrong type (string instead of number in interval)
payload = {"interval": {"start": "not_a_number", "end": 25}}
response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
data = response.json()
# Parse the error response - Django Ninja wraps errors in 'detail'
error_str = data.get("detail") or data.get("error")
self.assertIsNotNone(error_str, "Response should contain error details")
# Parse the JSON error string
error_data = json.loads(error_str)
# Check structure
self.assertEqual(error_data.get("type"), "validation_error")
self.assertIn("field_errors", error_data)
self.assertIn("message", error_data)
# Ensure error messages are user-friendly (no Pydantic URLs or technical jargon)
error_str = json.dumps(error_data)
self.assertNotIn("pydantic", error_str.lower())
self.assertNotIn("https://errors.pydantic.dev", error_str)
self.assertNotIn("loc", error_str) # No technical field like 'loc'
# Check that error message is helpful
self.assertIn("Please", error_data["message"]) # User-friendly language
def test_patch_additional_information(self):
"""Test PATCH updates existing additional information."""
self.client.force_login(self.user)
# First create an item
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Then update it with PATCH
update_payload = {"interval": {"start": 30, "end": 35}}
patch_response = self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
data=json.dumps(update_payload),
content_type="application/json",
)
self.assertEqual(patch_response.status_code, 200)
data = patch_response.json()
self.assertEqual(data["status"], "updated")
self.assertEqual(data["uuid"], item_uuid) # UUID preserved
# Verify the data was updated
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
items = list_response.json()
self.assertEqual(len(items), 1)
updated_item = items[0]
self.assertEqual(updated_item["uuid"], item_uuid)
self.assertEqual(updated_item["data"]["interval"]["start"], 30)
self.assertEqual(updated_item["data"]["interval"]["end"], 35)
def test_patch_nonexistent_item_returns_404(self):
"""Test PATCH on non-existent item returns 404."""
self.client.force_login(self.user)
fake_uuid = str(uuid4())
payload = {"interval": {"start": 30, "end": 35}}
response = self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{fake_uuid}/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
def test_patch_with_invalid_data_returns_400(self):
"""Test PATCH with invalid data returns 400."""
self.client.force_login(self.user)
# First create an item
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Try to update with invalid data
invalid_payload = {"invalid_field": "value"}
patch_response = self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
data=json.dumps(invalid_payload),
content_type="application/json",
)
self.assertEqual(patch_response.status_code, 400)
def test_patch_validation_errors_are_user_friendly(self):
"""Test that PATCH validation errors are user-friendly and field-specific."""
self.client.force_login(self.user)
# First create an item
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Update with invalid data - wrong type (string instead of number in interval)
invalid_payload = {"interval": {"start": "not_a_number", "end": 25}}
patch_response = self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
data=json.dumps(invalid_payload),
content_type="application/json",
)
self.assertEqual(patch_response.status_code, 400)
data = patch_response.json()
# Parse the error response - Django Ninja wraps errors in 'detail'
error_str = data.get("detail") or data.get("error")
self.assertIsNotNone(error_str, "Response should contain error details")
# Parse the JSON error string
error_data = json.loads(error_str)
# Check structure
self.assertEqual(error_data.get("type"), "validation_error")
self.assertIn("field_errors", error_data)
self.assertIn("message", error_data)
# Ensure error messages are user-friendly (no Pydantic URLs or technical jargon)
error_str = json.dumps(error_data)
self.assertNotIn("pydantic", error_str.lower())
self.assertNotIn("https://errors.pydantic.dev", error_str)
self.assertNotIn("loc", error_str) # No technical field like 'loc'
# Check that error message is helpful
self.assertIn("Please", error_data["message"]) # User-friendly language
def test_delete_additional_information(self):
"""Test DELETE removes additional information."""
self.client.force_login(self.user)
# Create an item
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Delete it
delete_response = self.client.delete(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/"
)
self.assertEqual(delete_response.status_code, 200)
data = delete_response.json()
self.assertEqual(data["status"], "deleted")
# Verify deletion
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
items = list_response.json()
self.assertEqual(len(items), 0)
def test_delete_nonexistent_item_returns_404(self):
"""Test DELETE on non-existent item returns 404."""
self.client.force_login(self.user)
fake_uuid = str(uuid4())
response = self.client.delete(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{fake_uuid}/"
)
self.assertEqual(response.status_code, 404)
def test_multiple_items_crud(self):
"""Test creating, updating, and deleting multiple items."""
self.client.force_login(self.user)
# Create first item
item1_payload = {"interval": {"start": 20, "end": 25}}
response1 = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(item1_payload),
content_type="application/json",
)
item1_uuid = response1.json()["uuid"]
# Create second item (different type if available, or same type)
item2_payload = {"interval": {"start": 30, "end": 35}}
response2 = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(item2_payload),
content_type="application/json",
)
item2_uuid = response2.json()["uuid"]
# Verify both exist
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
items = list_response.json()
self.assertEqual(len(items), 2)
# Update first item
update_payload = {"interval": {"start": 15, "end": 20}}
self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item1_uuid}/",
data=json.dumps(update_payload),
content_type="application/json",
)
# Delete second item
self.client.delete(f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item2_uuid}/")
# Verify final state: one item with updated data
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
items = list_response.json()
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["uuid"], item1_uuid)
self.assertEqual(items[0]["data"]["interval"]["start"], 15)
def test_list_info_denied_without_permission(self):
"""User cannot list info for scenario in package they don't have access to"""
self.client.force_login(self.user)
response = self.client.get(f"/api/v1/scenario/{self.other_scenario.uuid}/information/")
self.assertEqual(response.status_code, 403)
def test_add_info_denied_without_permission(self):
"""User cannot add info to scenario in package they don't have access to"""
self.client.force_login(self.user)
payload = {"interval": {"start": 25, "end": 30}}
response = self.client.post(
f"/api/v1/scenario/{self.other_scenario.uuid}/information/temperature/",
json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
def test_update_info_denied_without_permission(self):
"""User cannot update info in scenario they don't have access to"""
self.client.force_login(self.other_user)
# First create an item as other_user
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.other_scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Try to update as user (who doesn't have access)
self.client.force_login(self.user)
update_payload = {"interval": {"start": 30, "end": 35}}
response = self.client.patch(
f"/api/v1/scenario/{self.other_scenario.uuid}/information/item/{item_uuid}/",
data=json.dumps(update_payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
def test_delete_info_denied_without_permission(self):
"""User cannot delete info from scenario they don't have access to"""
self.client.force_login(self.other_user)
# First create an item as other_user
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.other_scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Try to delete as user (who doesn't have access)
self.client.force_login(self.user)
response = self.client.delete(
f"/api/v1/scenario/{self.other_scenario.uuid}/information/item/{item_uuid}/"
)
self.assertEqual(response.status_code, 403)
def test_nonexistent_scenario_returns_404(self):
"""Test operations on non-existent scenario return 404."""
self.client.force_login(self.user)
fake_uuid = uuid4()
response = self.client.get(f"/api/v1/scenario/{fake_uuid}/information/")
self.assertEqual(response.status_code, 404)

View File

@ -261,13 +261,6 @@ class GlobalCompoundListPermissionTest(APIPermissionTestBase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
payload = response.json() payload = response.json()
# user2 should see compounds from:
# - reviewed_package (public)
# - unreviewed_package_read (READ permission)
# - unreviewed_package_write (WRITE permission)
# - unreviewed_package_all (ALL permission)
# - group_package (via group membership)
# Total: 5 compounds
self.assertEqual(payload["total_items"], 5) self.assertEqual(payload["total_items"], 5)
visible_uuids = {item["uuid"] for item in payload["items"]} visible_uuids = {item["uuid"] for item in payload["items"]}
@ -303,54 +296,6 @@ class GlobalCompoundListPermissionTest(APIPermissionTestBase):
# user1 owns all packages, so sees all compounds # user1 owns all packages, so sees all compounds
self.assertEqual(payload["total_items"], 7) self.assertEqual(payload["total_items"], 7)
def test_read_permission_allows_viewing(self):
"""READ permission allows viewing compounds."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that read_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.read_compound.uuid), uuids)
def test_write_permission_allows_viewing(self):
"""WRITE permission also allows viewing compounds."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that write_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.write_compound.uuid), uuids)
def test_all_permission_allows_viewing(self):
"""ALL permission allows viewing compounds."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that all_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.all_compound.uuid), uuids)
def test_group_permission_allows_viewing(self):
"""Group membership grants access to group-permitted packages."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that group_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.group_compound.uuid), uuids)
@tag("api", "end2end") @tag("api", "end2end")
class PackageScopedCompoundListPermissionTest(APIPermissionTestBase): class PackageScopedCompoundListPermissionTest(APIPermissionTestBase):

View File

@ -134,7 +134,7 @@ class BaseTestAPIGetPaginated:
f"({self.total_reviewed} <= {self.default_page_size})" f"({self.total_reviewed} <= {self.default_page_size})"
) )
response = self.client.get(self.global_endpoint, {"page": 2}) response = self.client.get(self.global_endpoint, {"page": 2, "review_status": True})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
payload = response.json() payload = response.json()

View File

@ -0,0 +1,301 @@
"""
Tests for Scenario Creation Endpoint Error Handling.
Tests comprehensive error handling for POST /api/v1/package/{uuid}/scenario/
including package not found, permission denied, validation errors, and database errors.
"""
from django.test import TestCase, tag
import json
from uuid import uuid4
from epdb.logic import PackageManager, UserManager
from epdb.models import Scenario
@tag("api", "scenario_creation")
class ScenarioCreationAPITests(TestCase):
"""Test scenario creation endpoint error handling."""
@classmethod
def setUpTestData(cls):
"""Set up test data: users and packages."""
cls.user = UserManager.create_user(
"scenario-test-user",
"scenario-test@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
cls.other_user = UserManager.create_user(
"other-user",
"other@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
cls.package = PackageManager.create_package(
cls.user, "Test Package", "Test package for scenario creation"
)
def test_create_scenario_package_not_found(self):
"""Test that non-existent package UUID returns 404."""
self.client.force_login(self.user)
fake_uuid = uuid4()
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{fake_uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
self.assertIn("Package not found", response.json()["detail"])
def test_create_scenario_insufficient_permissions(self):
"""Test that unauthorized access returns 403."""
self.client.force_login(self.other_user)
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
self.assertIn("permission", response.json()["detail"].lower())
def test_create_scenario_invalid_ai_type(self):
"""Test that unknown additional information type returns 400."""
self.client.force_login(self.user)
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [
{"type": "invalid_type_that_does_not_exist", "data": {"some_field": "some_value"}}
],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
response_data = response.json()
self.assertIn("Validation errors", response_data["detail"])
def test_create_scenario_validation_error(self):
"""Test that invalid additional information data returns 400."""
self.client.force_login(self.user)
# Use malformed data structure for an actual AI type
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [
{
"type": "invalid_type_name",
"data": None, # This should cause a validation error
}
],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
# Should return 422 for validation errors
self.assertEqual(response.status_code, 422)
def test_create_scenario_success(self):
"""Test that valid scenario creation returns 200."""
self.client.force_login(self.user)
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["name"], "Test Scenario")
self.assertEqual(data["description"], "Test description")
# Verify scenario was actually created
scenario = Scenario.objects.get(name="Test Scenario")
self.assertEqual(scenario.package, self.package)
self.assertEqual(scenario.scenario_type, "biodegradation")
def test_create_scenario_auto_name(self):
"""Test that empty name triggers auto-generation."""
self.client.force_login(self.user)
payload = {
"name": "", # Empty name should be auto-generated
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
# Auto-generated name should follow pattern "Scenario N"
self.assertTrue(data["name"].startswith("Scenario "))
def test_create_scenario_xss_protection(self):
"""Test that XSS attempts are sanitized."""
self.client.force_login(self.user)
payload = {
"name": "<script>alert('xss')</script>Clean Name",
"description": "<img src=x onerror=alert('xss')>Description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
# XSS should be cleaned out
self.assertNotIn("<script>", data["name"])
self.assertNotIn("onerror", data["description"])
def test_create_scenario_missing_required_field(self):
"""Test that missing required fields returns validation error."""
self.client.force_login(self.user)
# Missing 'name' field entirely
payload = {
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
# Should return 422 for schema validation errors
self.assertEqual(response.status_code, 422)
def test_create_scenario_type_error_in_ai(self):
"""Test that TypeError in AI instantiation returns 400."""
self.client.force_login(self.user)
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [
{
"type": "invalid_type_name",
"data": "string instead of dict", # Wrong type
}
],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
# Should return 422 for validation errors
self.assertEqual(response.status_code, 422)
def test_create_scenario_default_values(self):
"""Test that default values are applied correctly."""
self.client.force_login(self.user)
# Minimal payload with only name
payload = {"name": "Minimal Scenario"}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["name"], "Minimal Scenario")
# Check defaults are applied
scenario = Scenario.objects.get(name="Minimal Scenario")
# Default description from model is "no description"
self.assertIn(scenario.description.lower(), ["", "no description"])
def test_create_scenario_unicode_characters(self):
"""Test that unicode characters are handled properly."""
self.client.force_login(self.user)
payload = {
"name": "Test Scenario 测试 🧪",
"description": "Description with émojis and spëcial çhars",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("测试", data["name"])
self.assertIn("émojis", data["description"])

View File

@ -0,0 +1,114 @@
"""
Property-based tests for schema generation.
Tests that verify schema generation works correctly for all models,
regardless of their structure.
"""
import pytest
from typing import Type
from pydantic import BaseModel
from envipy_additional_information import registry, EnviPyModel
from epapi.utils.schema_transformers import build_rjsf_output
class TestSchemaGeneration:
"""Test that all models can generate valid RJSF schemas."""
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_all_models_generate_rjsf(self, model_name: str, model_cls: Type[BaseModel]):
"""Every model in the registry should generate valid RJSF format."""
# Skip non-EnviPyModel classes (parsers, etc.)
if not issubclass(model_cls, EnviPyModel):
pytest.skip(f"{model_name} is not an EnviPyModel")
# Should not raise exception
result = build_rjsf_output(model_cls)
# Verify structure
assert isinstance(result, dict), f"{model_name}: Result should be a dict"
assert "schema" in result, f"{model_name}: Missing 'schema' key"
assert "uiSchema" in result, f"{model_name}: Missing 'uiSchema' key"
assert "formData" in result, f"{model_name}: Missing 'formData' key"
assert "groups" in result, f"{model_name}: Missing 'groups' key"
# Verify types
assert isinstance(result["schema"], dict), f"{model_name}: schema should be dict"
assert isinstance(result["uiSchema"], dict), f"{model_name}: uiSchema should be dict"
assert isinstance(result["formData"], dict), f"{model_name}: formData should be dict"
assert isinstance(result["groups"], list), f"{model_name}: groups should be list"
# Verify schema has properties
assert "properties" in result["schema"], f"{model_name}: schema should have 'properties'"
assert isinstance(result["schema"]["properties"], dict), (
f"{model_name}: properties should be dict"
)
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_ui_schema_matches_schema_fields(self, model_name: str, model_cls: Type[BaseModel]):
"""uiSchema keys should match schema properties (or be nested for intervals)."""
if not issubclass(model_cls, EnviPyModel):
pytest.skip(f"{model_name} is not an EnviPyModel")
result = build_rjsf_output(model_cls)
schema_props = set(result["schema"]["properties"].keys())
ui_schema_keys = set(result["uiSchema"].keys())
# uiSchema should have entries for all top-level properties
# (intervals may have nested start/end, but the main field should be present)
assert ui_schema_keys.issubset(schema_props), (
f"{model_name}: uiSchema has keys not in schema: {ui_schema_keys - schema_props}"
)
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_groups_is_list_of_strings(self, model_name: str, model_cls: Type[BaseModel]):
"""Groups should be a list of strings."""
if not issubclass(model_cls, EnviPyModel):
pytest.skip(f"{model_name} is not an EnviPyModel")
result = build_rjsf_output(model_cls)
groups = result["groups"]
assert isinstance(groups, list), f"{model_name}: groups should be list"
assert all(isinstance(g, str) for g in groups), (
f"{model_name}: all groups should be strings, got {groups}"
)
assert len(groups) > 0, f"{model_name}: should have at least one group"
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_form_data_matches_schema(self, model_name: str, model_cls: Type[BaseModel]):
"""formData keys should match schema properties."""
if not issubclass(model_cls, EnviPyModel):
pytest.skip(f"{model_name} is not an EnviPyModel")
result = build_rjsf_output(model_cls)
schema_props = set(result["schema"]["properties"].keys())
form_data_keys = set(result["formData"].keys())
# formData should only contain keys that are in schema
assert form_data_keys.issubset(schema_props), (
f"{model_name}: formData has keys not in schema: {form_data_keys - schema_props}"
)
class TestWidgetTypes:
"""Test that widget types are valid."""
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_widget_types_are_valid(self, model_name: str, model_cls: Type[BaseModel]):
"""All widget types in uiSchema should be valid WidgetType values."""
from envipy_additional_information.ui_config import WidgetType
if not issubclass(model_cls, EnviPyModel):
pytest.skip(f"{model_name} is not an EnviPyModel")
result = build_rjsf_output(model_cls)
valid_widgets = {wt.value for wt in WidgetType}
for field_name, ui_config in result["uiSchema"].items():
widget = ui_config.get("ui:widget")
if widget:
assert widget in valid_widgets, (
f"{model_name}.{field_name}: Invalid widget '{widget}'. Valid: {valid_widgets}"
)

View File

@ -0,0 +1,94 @@
from datetime import timedelta
from django.test import TestCase, tag
from django.utils import timezone
from epdb.logic import PackageManager, UserManager
from epdb.models import APIToken
@tag("api", "auth")
class BearerTokenAuthTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = UserManager.create_user(
"token-user",
"token-user@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
default_pkg = cls.user.default_package
cls.user.default_package = None
cls.user.save()
if default_pkg:
default_pkg.delete()
cls.unreviewed_package = PackageManager.create_package(
cls.user, "Token Auth Package", "Package for token auth tests"
)
def _auth_header(self, raw_token):
return {"HTTP_AUTHORIZATION": f"Bearer {raw_token}"}
def test_valid_token_allows_access(self):
_, raw_token = APIToken.create_token(self.user, name="Valid Token", expires_days=1)
response = self.client.get("/api/v1/compounds/", **self._auth_header(raw_token))
self.assertEqual(response.status_code, 200)
def test_expired_token_rejected(self):
token, raw_token = APIToken.create_token(self.user, name="Expired Token", expires_days=1)
token.expires_at = timezone.now() - timedelta(days=1)
token.save(update_fields=["expires_at"])
response = self.client.get("/api/v1/compounds/", **self._auth_header(raw_token))
self.assertEqual(response.status_code, 401)
def test_inactive_token_rejected(self):
token, raw_token = APIToken.create_token(self.user, name="Inactive Token", expires_days=1)
token.is_active = False
token.save(update_fields=["is_active"])
response = self.client.get("/api/v1/compounds/", **self._auth_header(raw_token))
self.assertEqual(response.status_code, 401)
def test_invalid_token_rejected(self):
response = self.client.get("/api/v1/compounds/", HTTP_AUTHORIZATION="Bearer invalid-token")
self.assertEqual(response.status_code, 401)
def test_no_token_rejected(self):
self.client.logout()
response = self.client.get("/api/v1/compounds/")
self.assertEqual(response.status_code, 401)
def test_bearer_populates_request_user_for_packages(self):
response = self.client.get("/api/v1/packages/")
self.assertEqual(response.status_code, 200)
payload = response.json()
uuids = {item["uuid"] for item in payload["items"]}
self.assertNotIn(str(self.unreviewed_package.uuid), uuids)
_, raw_token = APIToken.create_token(self.user, name="Package Token", expires_days=1)
response = self.client.get("/api/v1/packages/", **self._auth_header(raw_token))
self.assertEqual(response.status_code, 200)
payload = response.json()
uuids = {item["uuid"] for item in payload["items"]}
self.assertIn(str(self.unreviewed_package.uuid), uuids)
def test_session_auth_still_works_without_bearer(self):
self.client.force_login(self.user)
response = self.client.get("/api/v1/packages/")
self.assertEqual(response.status_code, 200)
payload = response.json()
uuids = {item["uuid"] for item in payload["items"]}
self.assertIn(str(self.unreviewed_package.uuid), uuids)

0
epapi/utils/__init__.py Normal file
View File

View File

@ -0,0 +1,181 @@
"""
Schema transformation utilities for converting Pydantic models to RJSF format.
This module provides functions to extract UI configuration from Pydantic models
and transform them into React JSON Schema Form (RJSF) compatible format.
"""
from typing import Type, Optional, Any
import jsonref
from pydantic import BaseModel
from envipy_additional_information.ui_config import UIConfig
from envipy_additional_information import registry
def extract_groups(model_cls: Type[BaseModel]) -> list[str]:
"""
Extract groups from registry-stored group information.
Args:
model_cls: The model class
Returns:
List of group names the model belongs to
"""
return registry.get_groups(model_cls)
def extract_ui_metadata(model_cls: Type[BaseModel]) -> dict[str, Any]:
"""
Extract model-level UI metadata from UI class.
Returns metadata attributes that are NOT UIConfig instances.
Common metadata includes: unit, description, title.
"""
metadata: dict[str, Any] = {}
if not hasattr(model_cls, "UI"):
return metadata
ui_class = getattr(model_cls, "UI")
# Iterate over all attributes in the UI class
for attr_name in dir(ui_class):
# Skip private attributes
if attr_name.startswith("_"):
continue
# Get the attribute value
try:
attr_value = getattr(ui_class, attr_name)
except AttributeError:
continue
# Skip callables but keep types/classes
if callable(attr_value) and not isinstance(attr_value, type):
continue
# Skip UIConfig instances (these are field-level configs, not metadata)
# This includes both UIConfig and IntervalConfig
if isinstance(attr_value, UIConfig):
continue
metadata[attr_name] = attr_value
return metadata
def extract_ui_config_from_model(model_cls: Type[BaseModel]) -> dict[str, Any]:
"""
Extract UI configuration from model's UI class.
Returns a dictionary mapping field names to their UI schema configurations.
Trusts the config classes to handle their own transformation logic.
"""
ui_configs: dict[str, Any] = {}
if not hasattr(model_cls, "UI"):
return ui_configs
ui_class = getattr(model_cls, "UI")
schema = model_cls.model_json_schema()
field_names = schema.get("properties", {}).keys()
# Extract config for each field
for field_name in field_names:
# Skip if UI config doesn't exist for this field (field may be hidden from UI)
if not hasattr(ui_class, field_name):
continue
ui_config = getattr(ui_class, field_name)
if isinstance(ui_config, UIConfig):
ui_configs[field_name] = ui_config.to_ui_schema_field()
return ui_configs
def build_ui_schema(model_cls: Type[BaseModel]) -> dict:
"""Generate RJSF uiSchema from model's UI class."""
ui_schema = {}
# Extract field-level UI configs
field_configs = extract_ui_config_from_model(model_cls)
for field_name, config in field_configs.items():
ui_schema[field_name] = config
return ui_schema
def build_schema(model_cls: Type[BaseModel]) -> dict[str, Any]:
"""
Build JSON schema from Pydantic model, applying UI metadata.
Dereferences all $ref pointers to produce fully inlined schema.
This ensures the frontend receives schemas with enum values and nested
properties fully resolved, without needing client-side ref resolution.
Extracts model-level metadata from UI class (title, unit, etc.) and applies
it to the generated schema. This ensures UI metadata is the single source of truth.
"""
schema = model_cls.model_json_schema()
# Dereference $ref pointers (inlines $defs) using jsonref
# This ensures the frontend receives schemas with enum values and nested
# properties fully resolved, currently necessary for client-side rendering.
# FIXME: This is a hack to get the schema to work with alpine schema-form.js replace once we migrate to client-side framework.
schema = jsonref.replace_refs(schema, proxies=False)
# Remove $defs section as all refs are now inlined
if "$defs" in schema:
del schema["$defs"]
# Extract and apply UI metadata (title, unit, description, etc.)
ui_metadata = extract_ui_metadata(model_cls)
# Apply all metadata consistently as custom properties with x- prefix
# This ensures consistency and avoids conflicts with standard JSON Schema properties
for key, value in ui_metadata.items():
if value is not None:
schema[f"x-{key}"] = value
# Set standard title property from UI metadata for JSON Schema compliance
if "title" in ui_metadata:
schema["title"] = ui_metadata["title"]
elif "label" in ui_metadata:
schema["title"] = ui_metadata["label"]
return schema
def build_rjsf_output(model_cls: Type[BaseModel], initial_data: Optional[dict] = None) -> dict:
"""
Main function that returns complete RJSF format.
Trusts the config classes to handle their own transformation logic.
No special-case handling - if a config knows how to transform itself, it will.
Returns:
dict with keys: schema, uiSchema, formData, groups
"""
# Build schema with UI metadata applied
schema = build_schema(model_cls)
# Build UI schema - config classes handle their own transformation
ui_schema = build_ui_schema(model_cls)
# Extract groups from marker interfaces
groups = extract_groups(model_cls)
# Use provided initial_data or empty dict
form_data = initial_data if initial_data is not None else {}
return {
"schema": schema,
"uiSchema": ui_schema,
"formData": form_data,
"groups": groups,
}

View File

@ -0,0 +1,82 @@
"""Shared utilities for handling Pydantic validation errors."""
import json
from pydantic import ValidationError
from pydantic_core import ErrorDetails
from ninja.errors import HttpError
def format_validation_error(error: ErrorDetails) -> str:
"""Format a Pydantic validation error into a user-friendly message.
Args:
error: A Pydantic error details dictionary containing 'msg', 'type', 'ctx', etc.
Returns:
A user-friendly error message string.
"""
msg = error.get("msg") or "Invalid value"
error_type = error.get("type") or ""
# Handle common validation types with friendly messages
if error_type == "enum":
ctx = error.get("ctx", {})
expected = ctx.get("expected", "") if ctx else ""
return f"Please select a valid option{': ' + expected if expected else ''}"
elif error_type == "literal_error":
# Literal errors (like Literal["active", "inactive"])
return msg.replace("Input should be ", "Please enter ")
elif error_type == "missing":
return "This field is required"
elif error_type == "string_type":
return "Please enter a valid string"
elif error_type == "int_type":
return "Please enter a valid int"
elif error_type == "int_parsing":
return "Please enter a valid int"
elif error_type == "float_type":
return "Please enter a valid float"
elif error_type == "float_parsing":
return "Please enter a valid float"
elif error_type == "value_error":
# Strip "Value error, " prefix from custom validator messages
return msg.replace("Value error, ", "")
else:
# Default: use the message from Pydantic but clean it up
return msg.replace("Input should be ", "Please enter ").replace("Value error, ", "")
def handle_validation_error(e: ValidationError) -> None:
"""Convert a Pydantic ValidationError into a structured HttpError.
This function transforms Pydantic validation errors into a JSON structure
that the frontend expects for displaying field-level errors.
Args:
e: The Pydantic ValidationError to handle.
Raises:
HttpError: Always raises a 400 error with structured JSON containing
type, field_errors, and message fields.
"""
# Transform Pydantic validation errors into user-friendly format
field_errors: dict[str, list[str]] = {}
for error in e.errors():
# Get the field name from location tuple
loc = error.get("loc", ())
field = str(loc[-1]) if loc else "root"
# Format the error message
friendly_msg = format_validation_error(error)
if field not in field_errors:
field_errors[field] = []
field_errors[field].append(friendly_msg)
# Return structured error for frontend parsing
error_response = {
"type": "validation_error",
"field_errors": field_errors,
"message": "Please correct the errors below",
}
raise HttpError(400, json.dumps(error_response))

View File

@ -1,8 +1,34 @@
import hashlib
from ninja.security import HttpBearer from ninja.security import HttpBearer
from ninja.errors import HttpError from ninja.errors import HttpError
from epdb.models import APIToken
class BearerTokenAuth(HttpBearer): class BearerTokenAuth(HttpBearer):
def authenticate(self, request, token): def authenticate(self, request, token):
# FIXME: placeholder; implement it in O(1) time if token is None:
return None
hashed_token = hashlib.sha256(token.encode()).hexdigest()
user = APIToken.authenticate(hashed_token, hashed=True)
if not user:
raise HttpError(401, "Invalid or expired token") raise HttpError(401, "Invalid or expired token")
request.user = user
return user
class OptionalBearerTokenAuth:
"""Bearer auth that allows unauthenticated access.
Validates the Bearer token if present (401 on invalid token),
otherwise lets the request through for anonymous/session access.
"""
def __init__(self):
self._bearer = BearerTokenAuth()
def __call__(self, request):
return self._bearer(request) or request.user

View File

@ -1,12 +1,12 @@
from django.db.models import Model from django.db.models import Model
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import CompoundStructure, User, Package, Compound from epdb.models import CompoundStructure, User, Package, Compound, Scenario
from uuid import UUID from uuid import UUID
from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError
def get_compound_or_error(user, compound_uuid: UUID): def get_compound_for_read(user, compound_uuid: UUID):
""" """
Get compound by UUID with permission check. Get compound by UUID with permission check.
""" """
@ -23,7 +23,7 @@ def get_compound_or_error(user, compound_uuid: UUID):
return compound return compound
def get_package_or_error(user, package_uuid: UUID): def get_package_for_read(user, package_uuid: UUID):
""" """
Get package by UUID with permission check. Get package by UUID with permission check.
""" """
@ -41,14 +41,40 @@ def get_package_or_error(user, package_uuid: UUID):
return package return package
def get_user_packages_qs(user: User | None): def get_scenario_for_read(user, scenario_uuid: UUID):
"""Get scenario by UUID with read permission check."""
try:
scenario = Scenario.objects.select_related("package").get(uuid=scenario_uuid)
except Scenario.DoesNotExist:
raise EPAPINotFoundError(f"Scenario with UUID {scenario_uuid} not found")
if not user or user.is_anonymous or not PackageManager.readable(user, scenario.package):
raise EPAPIPermissionDeniedError("Insufficient permissions to access this scenario.")
return scenario
def get_scenario_for_write(user, scenario_uuid: UUID):
"""Get scenario by UUID with write permission check."""
try:
scenario = Scenario.objects.select_related("package").get(uuid=scenario_uuid)
except Scenario.DoesNotExist:
raise EPAPINotFoundError(f"Scenario with UUID {scenario_uuid} not found")
if not user or user.is_anonymous or not PackageManager.writable(user, scenario.package):
raise EPAPIPermissionDeniedError("Insufficient permissions to modify this scenario.")
return scenario
def get_user_packages_for_read(user: User | None):
"""Get all packages readable by the user.""" """Get all packages readable by the user."""
if not user or user.is_anonymous: if not user or user.is_anonymous:
return PackageManager.get_reviewed_packages() return PackageManager.get_reviewed_packages()
return PackageManager.get_all_readable_packages(user, include_reviewed=True) return PackageManager.get_all_readable_packages(user, include_reviewed=True)
def get_user_entities_qs(model_class: Model, user: User | None): def get_user_entities_for_read(model_class: Model, user: User | None):
"""Build queryset for reviewed package entities.""" """Build queryset for reviewed package entities."""
if not user or user.is_anonymous: if not user or user.is_anonymous:
@ -60,16 +86,14 @@ def get_user_entities_qs(model_class: Model, user: User | None):
return qs return qs
def get_package_scoped_entities_qs( def get_package_entities_for_read(model_class: Model, package_uuid: UUID, user: User | None = None):
model_class: Model, package_uuid: UUID, user: User | None = None
):
"""Build queryset for specific package entities.""" """Build queryset for specific package entities."""
package = get_package_or_error(user, package_uuid) package = get_package_for_read(user, package_uuid)
qs = model_class.objects.filter(package=package).select_related("package") qs = model_class.objects.filter(package=package).select_related("package")
return qs return qs
def get_user_structures_qs(user: User | None): def get_user_structure_for_read(user: User | None):
"""Build queryset for structures accessible to the user (via compound->package).""" """Build queryset for structures accessible to the user (via compound->package)."""
if not user or user.is_anonymous: if not user or user.is_anonymous:
@ -83,13 +107,13 @@ def get_user_structures_qs(user: User | None):
return qs return qs
def get_package_compound_scoped_structure_qs( def get_package_compound_structure_for_read(
package_uuid: UUID, compound_uuid: UUID, user: User | None = None package_uuid: UUID, compound_uuid: UUID, user: User | None = None
): ):
"""Build queryset for specific package compound structures.""" """Build queryset for specific package compound structures."""
get_package_or_error(user, package_uuid) get_package_for_read(user, package_uuid)
compound = get_compound_or_error(user, compound_uuid) compound = get_compound_for_read(user, compound_uuid)
qs = CompoundStructure.objects.filter(compound=compound).select_related("compound__package") qs = CompoundStructure.objects.filter(compound=compound).select_related("compound__package")
return qs return qs

View File

@ -0,0 +1,174 @@
from ninja import Router, Body
from ninja.errors import HttpError
from uuid import UUID
from pydantic import ValidationError
from typing import Dict, Any
import logging
from envipy_additional_information import registry
from envipy_additional_information.groups import GroupEnum
from epapi.utils.schema_transformers import build_rjsf_output
from epapi.utils.validation_errors import handle_validation_error
from ..dal import get_scenario_for_read, get_scenario_for_write
logger = logging.getLogger(__name__)
router = Router(tags=["Additional Information"])
@router.get("/information/schema/")
def list_all_schemas(request):
"""Return all schemas in RJSF format with lowercase class names as keys."""
result = {}
for name, cls in registry.list_models().items():
try:
result[name] = build_rjsf_output(cls)
except Exception as e:
logger.warning(f"Failed to generate schema for {name}: {e}")
continue
return result
@router.get("/information/schema/{model_name}/")
def get_model_schema(request, model_name: str):
"""Return RJSF schema for specific model."""
cls = registry.get_model(model_name.lower())
if not cls:
raise HttpError(404, f"Unknown model: {model_name}")
return build_rjsf_output(cls)
@router.get("/scenario/{uuid:scenario_uuid}/information/")
def list_scenario_info(request, scenario_uuid: UUID):
"""List all additional information for a scenario"""
scenario = get_scenario_for_read(request.user, scenario_uuid)
result = []
for ai in scenario.get_additional_information():
result.append(
{
"type": ai.__class__.__name__,
"uuid": getattr(ai, "uuid", None),
"data": ai.model_dump(mode="json"),
}
)
return result
@router.post("/scenario/{uuid:scenario_uuid}/information/{model_name}/")
def add_scenario_info(
request, scenario_uuid: UUID, model_name: str, payload: Dict[str, Any] = Body(...)
):
"""Add new additional information to scenario"""
cls = registry.get_model(model_name.lower())
if not cls:
raise HttpError(404, f"Unknown model: {model_name}")
try:
instance = cls(**payload) # Pydantic validates
except ValidationError as e:
handle_validation_error(e)
scenario = get_scenario_for_write(request.user, scenario_uuid)
# Model method now returns the UUID
created_uuid = scenario.add_additional_information(instance)
return {"status": "created", "uuid": created_uuid}
@router.patch("/scenario/{uuid:scenario_uuid}/information/item/{uuid:ai_uuid}/")
def update_scenario_info(
request, scenario_uuid: UUID, ai_uuid: UUID, payload: Dict[str, Any] = Body(...)
):
"""Update existing additional information for a scenario"""
scenario = get_scenario_for_write(request.user, scenario_uuid)
ai_uuid_str = str(ai_uuid)
# Find item to determine type for validation
found_type = None
for type_name, items in scenario.additional_information.items():
if any(item.get("uuid") == ai_uuid_str for item in items):
found_type = type_name
break
if found_type is None:
raise HttpError(404, f"Additional information not found: {ai_uuid}")
# Get the model class for validation
cls = registry.get_model(found_type.lower())
if not cls:
raise HttpError(500, f"Unknown model type in data: {found_type}")
# Validate the payload against the model
try:
instance = cls(**payload)
except ValidationError as e:
handle_validation_error(e)
# Use model method for update
try:
scenario.update_additional_information(ai_uuid_str, instance)
except ValueError as e:
raise HttpError(404, str(e))
return {"status": "updated", "uuid": ai_uuid_str}
@router.delete("/scenario/{uuid:scenario_uuid}/information/item/{uuid:ai_uuid}/")
def delete_scenario_info(request, scenario_uuid: UUID, ai_uuid: UUID):
"""Delete additional information from scenario"""
scenario = get_scenario_for_write(request.user, scenario_uuid)
try:
scenario.remove_additional_information(str(ai_uuid))
except ValueError as e:
raise HttpError(404, str(e))
return {"status": "deleted"}
@router.get("/information/groups/")
def list_groups(request):
"""Return list of available group names."""
return {"groups": GroupEnum.values()}
@router.get("/information/groups/{group_name}/")
def get_group_models(request, group_name: str):
"""
Return models for a specific group organized by subcategory.
Args:
group_name: One of "sludge", "soil", or "sediment" (string)
Returns:
Dictionary with subcategories (exp, spike, comp, misc, or group name)
as keys and lists of model info as values
"""
# Convert string to enum (raises ValueError if invalid)
try:
group_enum = GroupEnum(group_name)
except ValueError:
valid = ", ".join(GroupEnum.values())
raise HttpError(400, f"Invalid group '{group_name}'. Valid: {valid}")
try:
group_data = registry.collect_group(group_enum)
except (ValueError, TypeError) as e:
raise HttpError(400, str(e))
result = {}
for subcategory, models in group_data.items():
result[subcategory] = [
{
"name": cls.__name__.lower(),
"class": cls.__name__,
"title": getattr(cls.UI, "title", cls.__name__)
if hasattr(cls, "UI")
else cls.__name__,
}
for cls in models
]
return result

View File

@ -6,7 +6,7 @@ from uuid import UUID
from epdb.models import Compound from epdb.models import Compound
from ..pagination import EnhancedPageNumberPagination from ..pagination import EnhancedPageNumberPagination
from ..schemas import CompoundOutSchema, ReviewStatusFilter from ..schemas import CompoundOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs from ..dal import get_user_entities_for_read, get_package_entities_for_read
router = Router() router = Router()
@ -21,7 +21,7 @@ def list_all_compounds(request):
""" """
List all compounds from reviewed packages. List all compounds from reviewed packages.
""" """
return get_user_entities_qs(Compound, request.user).order_by("name").all() return get_user_entities_for_read(Compound, request.user).order_by("name").all()
@router.get( @router.get(
@ -38,4 +38,4 @@ def list_package_compounds(request, package_uuid: UUID):
List all compounds for a specific package. List all compounds for a specific package.
""" """
user = request.user user = request.user
return get_package_scoped_entities_qs(Compound, package_uuid, user).order_by("name").all() return get_package_entities_for_read(Compound, package_uuid, user).order_by("name").all()

View File

@ -6,7 +6,7 @@ from uuid import UUID
from epdb.models import EPModel from epdb.models import EPModel
from ..pagination import EnhancedPageNumberPagination from ..pagination import EnhancedPageNumberPagination
from ..schemas import ModelOutSchema, ReviewStatusFilter from ..schemas import ModelOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs from ..dal import get_user_entities_for_read, get_package_entities_for_read
router = Router() router = Router()
@ -21,7 +21,7 @@ def list_all_models(request):
""" """
List all models from reviewed packages. List all models from reviewed packages.
""" """
return get_user_entities_qs(EPModel, request.user).order_by("name").all() return get_user_entities_for_read(EPModel, request.user).order_by("name").all()
@router.get( @router.get(
@ -38,4 +38,4 @@ def list_package_models(request, package_uuid: UUID):
List all models for a specific package. List all models for a specific package.
""" """
user = request.user user = request.user
return get_package_scoped_entities_qs(EPModel, package_uuid, user).order_by("name").all() return get_package_entities_for_read(EPModel, package_uuid, user).order_by("name").all()

View File

@ -3,7 +3,8 @@ from ninja import Router
from ninja_extra.pagination import paginate from ninja_extra.pagination import paginate
import logging import logging
from ..dal import get_user_packages_qs from ..auth import OptionalBearerTokenAuth
from ..dal import get_user_packages_for_read
from ..pagination import EnhancedPageNumberPagination from ..pagination import EnhancedPageNumberPagination
from ..schemas import PackageOutSchema, SelfReviewStatusFilter from ..schemas import PackageOutSchema, SelfReviewStatusFilter
@ -11,7 +12,11 @@ router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@router.get("/packages/", response=EnhancedPageNumberPagination.Output[PackageOutSchema], auth=None) @router.get(
"/packages/",
response=EnhancedPageNumberPagination.Output[PackageOutSchema],
auth=OptionalBearerTokenAuth(),
)
@paginate( @paginate(
EnhancedPageNumberPagination, EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
@ -23,5 +28,5 @@ def list_all_packages(request):
""" """
user = request.user user = request.user
qs = get_user_packages_qs(user) qs = get_user_packages_for_read(user)
return qs.order_by("name").all() return qs.order_by("name").all()

View File

@ -6,7 +6,7 @@ from uuid import UUID
from epdb.models import Pathway from epdb.models import Pathway
from ..pagination import EnhancedPageNumberPagination from ..pagination import EnhancedPageNumberPagination
from ..schemas import PathwayOutSchema, ReviewStatusFilter from ..schemas import PathwayOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs from ..dal import get_user_entities_for_read, get_package_entities_for_read
router = Router() router = Router()
@ -22,7 +22,7 @@ def list_all_pathways(request):
List all pathways from reviewed packages. List all pathways from reviewed packages.
""" """
user = request.user user = request.user
return get_user_entities_qs(Pathway, user).order_by("name").all() return get_user_entities_for_read(Pathway, user).order_by("name").all()
@router.get( @router.get(
@ -39,4 +39,4 @@ def list_package_pathways(request, package_uuid: UUID):
List all pathways for a specific package. List all pathways for a specific package.
""" """
user = request.user user = request.user
return get_package_scoped_entities_qs(Pathway, package_uuid, user).order_by("name").all() return get_package_entities_for_read(Pathway, package_uuid, user).order_by("name").all()

View File

@ -6,7 +6,7 @@ from uuid import UUID
from epdb.models import Reaction from epdb.models import Reaction
from ..pagination import EnhancedPageNumberPagination from ..pagination import EnhancedPageNumberPagination
from ..schemas import ReactionOutSchema, ReviewStatusFilter from ..schemas import ReactionOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs from ..dal import get_user_entities_for_read, get_package_entities_for_read
router = Router() router = Router()
@ -22,7 +22,7 @@ def list_all_reactions(request):
List all reactions from reviewed packages. List all reactions from reviewed packages.
""" """
user = request.user user = request.user
return get_user_entities_qs(Reaction, user).order_by("name").all() return get_user_entities_for_read(Reaction, user).order_by("name").all()
@router.get( @router.get(
@ -39,4 +39,4 @@ def list_package_reactions(request, package_uuid: UUID):
List all reactions for a specific package. List all reactions for a specific package.
""" """
user = request.user user = request.user
return get_package_scoped_entities_qs(Reaction, package_uuid, user).order_by("name").all() return get_package_entities_for_read(Reaction, package_uuid, user).order_by("name").all()

View File

@ -6,7 +6,7 @@ from uuid import UUID
from epdb.models import Rule from epdb.models import Rule
from ..pagination import EnhancedPageNumberPagination from ..pagination import EnhancedPageNumberPagination
from ..schemas import ReviewStatusFilter, RuleOutSchema from ..schemas import ReviewStatusFilter, RuleOutSchema
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs from ..dal import get_user_entities_for_read, get_package_entities_for_read
router = Router() router = Router()
@ -22,7 +22,7 @@ def list_all_rules(request):
List all rules from reviewed packages. List all rules from reviewed packages.
""" """
user = request.user user = request.user
return get_user_entities_qs(Rule, user).order_by("name").all() return get_user_entities_for_read(Rule, user).order_by("name").all()
@router.get( @router.get(
@ -39,4 +39,4 @@ def list_package_rules(request, package_uuid: UUID):
List all rules for a specific package. List all rules for a specific package.
""" """
user = request.user user = request.user
return get_package_scoped_entities_qs(Rule, package_uuid, user).order_by("name").all() return get_package_entities_for_read(Rule, package_uuid, user).order_by("name").all()

View File

@ -1,12 +1,26 @@
from django.conf import settings as s from django.conf import settings as s
from ninja import Router from django.db import IntegrityError, OperationalError, DatabaseError
from ninja import Router, Body
from ninja.errors import HttpError
from ninja_extra.pagination import paginate from ninja_extra.pagination import paginate
from uuid import UUID from uuid import UUID
from pydantic import ValidationError
import logging
import json
from epdb.models import Scenario from epdb.models import Scenario
from epdb.logic import PackageManager
from epdb.views import _anonymous_or_real
from ..pagination import EnhancedPageNumberPagination from ..pagination import EnhancedPageNumberPagination
from ..schemas import ReviewStatusFilter, ScenarioOutSchema from ..schemas import (
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs ScenarioOutSchema,
ScenarioCreateSchema,
ScenarioReviewStatusAndRelatedFilter,
)
from ..dal import get_user_entities_for_read, get_package_entities_for_read
from envipy_additional_information import registry
logger = logging.getLogger(__name__)
router = Router() router = Router()
@ -15,11 +29,12 @@ router = Router()
@paginate( @paginate(
EnhancedPageNumberPagination, EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter, filter_schema=ScenarioReviewStatusAndRelatedFilter,
) )
def list_all_scenarios(request): def list_all_scenarios(request):
user = request.user user = request.user
return get_user_entities_qs(Scenario, user).order_by("name").all() items = get_user_entities_for_read(Scenario, user)
return items.order_by("name").all()
@router.get( @router.get(
@ -29,8 +44,87 @@ def list_all_scenarios(request):
@paginate( @paginate(
EnhancedPageNumberPagination, EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter, filter_schema=ScenarioReviewStatusAndRelatedFilter,
) )
def list_package_scenarios(request, package_uuid: UUID): def list_package_scenarios(request, package_uuid: UUID):
user = request.user user = request.user
return get_package_scoped_entities_qs(Scenario, package_uuid, user).order_by("name").all() items = get_package_entities_for_read(Scenario, package_uuid, user)
return items.order_by("name").all()
@router.post("/package/{uuid:package_uuid}/scenario/", response=ScenarioOutSchema)
def create_scenario(request, package_uuid: UUID, payload: ScenarioCreateSchema = Body(...)):
"""Create a new scenario with optional additional information."""
user = _anonymous_or_real(request)
try:
current_package = PackageManager.get_package_by_id(user, package_uuid)
except ValueError as e:
error_msg = str(e)
if "does not exist" in error_msg:
raise HttpError(404, f"Package not found: {package_uuid}")
elif "Insufficient permissions" in error_msg:
raise HttpError(403, "You do not have permission to access this package")
else:
logger.error(f"Unexpected ValueError from get_package_by_id: {error_msg}")
raise HttpError(400, "Invalid package request")
# Build additional information models from payload
additional_information_models = []
validation_errors = []
for ai_item in payload.additional_information:
# Get model class from registry
model_cls = registry.get_model(ai_item.type.lower())
if not model_cls:
validation_errors.append(f"Unknown additional information type: {ai_item.type}")
continue
try:
# Validate and create model instance
instance = model_cls(**ai_item.data)
additional_information_models.append(instance)
except ValidationError as e:
# Collect validation errors to return to user
error_messages = [err.get("msg", "Validation error") for err in e.errors()]
validation_errors.append(f"{ai_item.type}: {', '.join(error_messages)}")
except (TypeError, AttributeError, KeyError) as e:
logger.warning(f"Failed to instantiate {ai_item.type} model: {str(e)}")
validation_errors.append(f"{ai_item.type}: Invalid data structure - {str(e)}")
except Exception as e:
logger.error(f"Unexpected error instantiating {ai_item.type}: {str(e)}")
validation_errors.append(f"{ai_item.type}: Failed to process - please check your data")
# If there are validation errors, return them
if validation_errors:
raise HttpError(
400,
json.dumps(
{
"error": "Validation errors in additional information",
"details": validation_errors,
}
),
)
# Create scenario using the existing Scenario.create method
try:
new_scenario = Scenario.create(
package=current_package,
name=payload.name,
description=payload.description,
scenario_date=payload.scenario_date,
scenario_type=payload.scenario_type,
additional_information=additional_information_models,
)
except IntegrityError as e:
logger.error(f"Database integrity error creating scenario: {str(e)}")
raise HttpError(400, "Scenario creation failed - data constraint violation")
except OperationalError as e:
logger.error(f"Database operational error creating scenario: {str(e)}")
raise HttpError(503, "Database temporarily unavailable - please try again")
except (DatabaseError, AttributeError) as e:
logger.error(f"Error creating scenario: {str(e)}")
raise HttpError(500, "Failed to create scenario due to database error")
return new_scenario

View File

@ -0,0 +1,23 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from epdb.logic import SettingManager
from ..pagination import EnhancedPageNumberPagination
from ..schemas import SettingOutSchema
router = Router()
@router.get("/settings/", response=EnhancedPageNumberPagination.Output[SettingOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
)
def list_all_pathways(request):
"""
List all pathways from reviewed packages.
"""
user = request.user
return SettingManager.get_all_settings(user)

View File

@ -6,8 +6,8 @@ from uuid import UUID
from ..pagination import EnhancedPageNumberPagination from ..pagination import EnhancedPageNumberPagination
from ..schemas import CompoundStructureOutSchema, StructureReviewStatusFilter from ..schemas import CompoundStructureOutSchema, StructureReviewStatusFilter
from ..dal import ( from ..dal import (
get_user_structures_qs, get_user_structure_for_read,
get_package_compound_scoped_structure_qs, get_package_compound_structure_for_read,
) )
router = Router() router = Router()
@ -26,7 +26,7 @@ def list_all_structures(request):
List all structures from all packages. List all structures from all packages.
""" """
user = request.user user = request.user
return get_user_structures_qs(user).order_by("name").all() return get_user_structure_for_read(user).order_by("name").all()
@router.get( @router.get(
@ -44,7 +44,7 @@ def list_package_structures(request, package_uuid: UUID, compound_uuid: UUID):
""" """
user = request.user user = request.user
return ( return (
get_package_compound_scoped_structure_qs(package_uuid, compound_uuid, user) get_package_compound_structure_for_read(package_uuid, compound_uuid, user)
.order_by("name") .order_by("name")
.all() .all()
) )

View File

@ -1,7 +1,19 @@
from ninja import Router from ninja import Router
from ninja.security import SessionAuth from ninja.security import SessionAuth
from .auth import BearerTokenAuth from .auth import BearerTokenAuth
from .endpoints import packages, scenarios, compounds, rules, reactions, pathways, models, structure from .endpoints import (
packages,
scenarios,
compounds,
rules,
reactions,
pathways,
models,
structure,
additional_information,
settings,
)
# Main router with authentication # Main router with authentication
router = Router( router = Router(
@ -20,3 +32,5 @@ router.add_router("", reactions.router)
router.add_router("", pathways.router) router.add_router("", pathways.router)
router.add_router("", models.router) router.add_router("", models.router)
router.add_router("", structure.router) router.add_router("", structure.router)
router.add_router("", additional_information.router)
router.add_router("", settings.router)

View File

@ -1,5 +1,5 @@
from ninja import FilterSchema, FilterLookup, Schema from ninja import FilterSchema, FilterLookup, Schema
from typing import Annotated, Optional from typing import Annotated, Optional, List, Dict, Any
from uuid import UUID from uuid import UUID
@ -22,6 +22,12 @@ class StructureReviewStatusFilter(FilterSchema):
review_status: Annotated[Optional[bool], FilterLookup("compound__package__reviewed")] = None review_status: Annotated[Optional[bool], FilterLookup("compound__package__reviewed")] = None
class ScenarioReviewStatusAndRelatedFilter(ReviewStatusFilter):
"""Filter schema for review_status and parent query parameter."""
exclude_related: Annotated[Optional[bool], FilterLookup("parent__isnull")] = None
# Base schema for all package-scoped entities # Base schema for all package-scoped entities
class PackageEntityOutSchema(Schema): class PackageEntityOutSchema(Schema):
"""Base schema for entities belonging to a package.""" """Base schema for entities belonging to a package."""
@ -51,6 +57,23 @@ class ScenarioOutSchema(PackageEntityOutSchema):
pass pass
class AdditionalInformationItemSchema(Schema):
"""Schema for additional information item in scenario creation."""
type: str
data: Dict[str, Any]
class ScenarioCreateSchema(Schema):
"""Schema for creating a new scenario."""
name: str
description: str = ""
scenario_date: str = "No date"
scenario_type: str = "Not specified"
additional_information: List[AdditionalInformationItemSchema] = []
class CompoundOutSchema(PackageEntityOutSchema): class CompoundOutSchema(PackageEntityOutSchema):
pass pass
@ -102,3 +125,10 @@ class PackageOutSchema(Schema):
@staticmethod @staticmethod
def resolve_review_status(obj): def resolve_review_status(obj):
return "reviewed" if obj.reviewed else "unreviewed" return "reviewed" if obj.reviewed else "unreviewed"
class SettingOutSchema(Schema):
uuid: UUID
url: str = ""
name: str
description: str

View File

@ -28,7 +28,7 @@ Package = s.GET_PACKAGE_MODEL()
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
list_display = ["username", "email", "is_active"] list_display = ["username", "email", "is_active", "is_staff", "is_superuser"]
class UserPackagePermissionAdmin(admin.ModelAdmin): class UserPackagePermissionAdmin(admin.ModelAdmin):
@ -48,7 +48,7 @@ class JobLogAdmin(admin.ModelAdmin):
class EPAdmin(admin.ModelAdmin): class EPAdmin(admin.ModelAdmin):
search_fields = ["name", "description"] search_fields = ["name", "description", "url", "uuid"]
list_display = ["name", "url", "created"] list_display = ["name", "url", "created"]
ordering = ["-created"] ordering = ["-created"]

View File

@ -2,20 +2,12 @@ from typing import List
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from ninja import Router, Schema, Field from ninja import Router, Schema, Field
from ninja.errors import HttpError
from ninja.pagination import paginate from ninja.pagination import paginate
from ninja.security import HttpBearer
from epapi.v1.auth import BearerTokenAuth
from .logic import PackageManager from .logic import PackageManager
from .models import User, Compound, APIToken from .models import User, Compound
class BearerTokenAuth(HttpBearer):
def authenticate(self, request, token):
for token_obj in APIToken.objects.select_related("user").all():
if token_obj.check_token(token) and token_obj.is_valid():
return token_obj.user
raise HttpError(401, "Invalid or expired token")
def _anonymous_or_real(request): def _anonymous_or_real(request):

View File

@ -5,27 +5,33 @@ from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from ninja import Field, Form, Router, Schema, Query from ninja import Field, Form, Query, Router, Schema
from ninja.security import SessionAuth from ninja.security import SessionAuth
from utilities.chem import FormatConverter from utilities.chem import FormatConverter
from utilities.misc import PackageExporter from utilities.misc import PackageExporter
from .logic import GroupManager, PackageManager, SettingManager, UserManager, SearchManager from .logic import GroupManager, PackageManager, SearchManager, SettingManager, UserManager
from .models import ( from .models import (
Compound, Compound,
CompoundStructure, CompoundStructure,
Edge, Edge,
EnviFormer,
EPModel, EPModel,
Group,
GroupPackagePermission,
MLRelativeReasoning,
Node, Node,
PackageBasedModel,
ParallelRule,
Pathway, Pathway,
Reaction, Reaction,
Rule, Rule,
RuleBasedRelativeReasoning,
Scenario, Scenario,
SimpleAmbitRule, SimpleAmbitRule,
User, User,
UserPackagePermission, UserPackagePermission,
ParallelRule,
) )
Package = s.GET_PACKAGE_MODEL() Package = s.GET_PACKAGE_MODEL()
@ -200,6 +206,82 @@ def get_user(request, user_uuid):
} }
########
# Group #
########
class GroupMember(Schema):
id: str
identifier: str
name: str
class GroupWrapper(Schema):
group: List[SimpleGroup]
class GroupSchema(Schema):
description: str
id: str = Field(None, alias="url")
identifier: str = "group"
members: List[GroupMember] = Field([], alias="members")
name: str = Field(None, alias="name")
ownerid: str = Field(None, alias="owner.url")
ownername: str = Field(None, alias="owner.get_name")
packages: List["SimplePackage"] = Field([], alias="packages")
readers: List[GroupMember] = Field([], alias="readers")
writers: List[GroupMember] = Field([], alias="writers")
@staticmethod
def resolve_members(obj: Group):
res = []
for member in obj.user_member.all():
res.append(GroupMember(id=member.url, identifier="usermember", name=member.get_name()))
for member in obj.group_member.all():
res.append(GroupMember(id=member.url, identifier="groupmember", name=member.get_name()))
return res
@staticmethod
def resolve_packages(obj: Group):
return Package.objects.filter(
id__in=[
GroupPackagePermission.objects.filter(group=obj).values_list(
"package_id", flat=True
)
]
)
@staticmethod
def resolve_readers(obj: Group):
return GroupSchema.resolve_members(obj)
@staticmethod
def resolve_writers(obj: Group):
return [GroupMember(id=obj.owner.url, identifier="usermember", name=obj.owner.username)]
@router.get("/group", response={200: GroupWrapper, 403: Error})
def get_groups(request):
return {"group": GroupManager.get_groups(request.user)}
@router.get("/group/{uuid:group_uuid}", response={200: GroupSchema, 403: Error})
def get_group(request, group_uuid):
try:
g = GroupManager.get_group_by_id(request.user, group_uuid)
return g
except ValueError:
return 403, {
"message": f"Getting Group with id {group_uuid} failed due to insufficient rights!"
}
##########
# Search #
##########
class Search(Schema): class Search(Schema):
packages: List[str] = Field(alias="packages[]") packages: List[str] = Field(alias="packages[]")
search: str search: str
@ -237,11 +319,11 @@ def search(request, search: Query[Search]):
if "Compound Structures" in search_res: if "Compound Structures" in search_res:
res["structure"] = search_res["Compound Structures"] res["structure"] = search_res["Compound Structures"]
if "Reaction" in search_res: if "Reactions" in search_res:
res["reaction"] = search_res["Reaction"] res["reaction"] = search_res["Reactions"]
if "Pathway" in search_res: if "Pathways" in search_res:
res["pathway"] = search_res["Pathway"] res["pathway"] = search_res["Pathways"]
if "Rules" in search_res: if "Rules" in search_res:
res["rule"] = search_res["Rules"] res["rule"] = search_res["Rules"]
@ -292,7 +374,7 @@ class PackageSchema(Schema):
).values_list("user", flat=True) ).values_list("user", flat=True)
).distinct() ).distinct()
return [{u.id: u.name} for u in users] return [{u.id: u.get_name()} for u in users]
@staticmethod @staticmethod
def resolve_writers(obj: Package): def resolve_writers(obj: Package):
@ -302,7 +384,7 @@ class PackageSchema(Schema):
).values_list("user", flat=True) ).values_list("user", flat=True)
).distinct() ).distinct()
return [{u.id: u.name} for u in users] return [{u.id: u.get_name()} for u in users]
@staticmethod @staticmethod
def resolve_review_comment(obj): def resolve_review_comment(obj):
@ -884,7 +966,12 @@ def create_package_simple_rule(
raise ValueError("Not yet implemented!") raise ValueError("Not yet implemented!")
else: else:
sr = SimpleAmbitRule.create( sr = SimpleAmbitRule.create(
p, r.name, r.description, r.smirks, r.reactantFilterSmarts, r.productFilterSmarts p,
r.name,
r.description,
r.smirks,
r.reactantFilterSmarts,
r.productFilterSmarts,
) )
return redirect(sr.url) return redirect(sr.url)
@ -1037,7 +1124,7 @@ class ReactionSchema(Schema):
name: str = Field(None, alias="name") name: str = Field(None, alias="name")
pathways: List["SimplePathway"] = Field([], alias="related_pathways") pathways: List["SimplePathway"] = Field([], alias="related_pathways")
products: List["ReactionCompoundStructure"] = Field([], alias="products") products: List["ReactionCompoundStructure"] = Field([], alias="products")
references: List[Dict[str, List[str]]] = Field([], alias="references") references: Dict[str, List[str]] = Field({}, alias="references")
reviewStatus: str = Field(None, alias="review_status") reviewStatus: str = Field(None, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios") scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
smirks: str = Field("", alias="smirks") smirks: str = Field("", alias="smirks")
@ -1053,8 +1140,12 @@ class ReactionSchema(Schema):
@staticmethod @staticmethod
def resolve_references(obj: Reaction): def resolve_references(obj: Reaction):
# TODO rhea_refs = []
return [] for rhea in obj.get_rhea_identifiers():
rhea_refs.append(f"{rhea.identifier_value}")
# TODO UniProt
return {"rheaReferences": rhea_refs, "uniprotCount": []}
@staticmethod @staticmethod
def resolve_medline_references(obj: Reaction): def resolve_medline_references(obj: Reaction):
@ -1447,6 +1538,7 @@ def create_pathway(
setting = SettingManager.get_setting_by_url(request.user, pw.selectedSetting) setting = SettingManager.get_setting_by_url(request.user, pw.selectedSetting)
new_pw.setting = setting new_pw.setting = setting
new_pw.kv.update({"status": "running"})
new_pw.save() new_pw.save()
from .tasks import dispatch, predict from .tasks import dispatch, predict
@ -1632,7 +1724,7 @@ class EdgeSchema(Schema):
id: str = Field(None, alias="url") id: str = Field(None, alias="url")
identifier: str = "edge" identifier: str = "edge"
name: str = Field(None, alias="name") name: str = Field(None, alias="name")
reactionName: str = Field(None, alias="edge_label.name") reactionName: str = Field(None, alias="edge_label.get_name")
reactionURI: str = Field(None, alias="edge_label.url") reactionURI: str = Field(None, alias="edge_label.url")
reviewStatus: str = Field(None, alias="review_status") reviewStatus: str = Field(None, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios") scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
@ -1681,7 +1773,7 @@ class CreateEdge(Schema):
@router.post( @router.post(
"/package/{uuid:package_uuid}/üathway/{uuid:pathway_uuid}/edge", "/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge",
response={200: str | Any, 403: Error}, response={200: str | Any, 403: Error},
) )
def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]): def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
@ -1700,10 +1792,26 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
if e.edgeAsSmirks: if e.edgeAsSmirks:
for ed in e.edgeAsSmirks.split(">>")[0].split("\\."): for ed in e.edgeAsSmirks.split(">>")[0].split("\\."):
educts.append(Node.objects.get(pathway=pw, default_node_label__smiles=ed)) stand_ed = FormatConverter.standardize(ed, remove_stereo=True)
educts.append(
Node.objects.get(
pathway=pw,
default_node_label=CompoundStructure.objects.get(
compound__package=p, smiles=stand_ed
).compound.default_structure,
)
)
for pr in e.edgeAsSmirks.split(">>")[1].split("\\."): for pr in e.edgeAsSmirks.split(">>")[1].split("\\."):
products.append(Node.objects.get(pathway=pw, default_node_label__smiles=pr)) stand_pr = FormatConverter.standardize(pr, remove_stereo=True)
products.append(
Node.objects.get(
pathway=pw,
default_node_label=CompoundStructure.objects.get(
compound__package=p, smiles=stand_pr
).compound.default_structure,
)
)
else: else:
for ed in e.educts.split(","): for ed in e.educts.split(","):
educts.append(Node.objects.get(pathway=pw, url=ed.strip())) educts.append(Node.objects.get(pathway=pw, url=ed.strip()))
@ -1716,7 +1824,7 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
start_nodes=educts, start_nodes=educts,
end_nodes=products, end_nodes=products,
rule=None, rule=None,
name=e.name, name=None,
description=e.edgeReason, description=e.edgeReason,
) )
@ -1753,26 +1861,46 @@ class ModelWrapper(Schema):
class ModelSchema(Schema): class ModelSchema(Schema):
aliases: List[str] = Field([], alias="aliases") aliases: List[str] = Field([], alias="aliases")
description: str = Field(None, alias="description") description: str = Field(None, alias="description")
evalPackages: List["SimplePackage"] = Field([]) evalPackages: List["SimplePackage"] = Field([], alias="eval_packages")
id: str = Field(None, alias="url") id: str = Field(None, alias="url")
identifier: str = "relative-reasoning" identifier: str = "relative-reasoning"
# "info" : { info: dict = Field({}, alias="info")
# "Accuracy (Single-Gen)" : "0.5932962678936605" ,
# "Area under PR-Curve (Single-Gen)" : "0.5654653182134282" ,
# "Area under ROC-Curve (Single-Gen)" : "0.8178302405034772" ,
# "Precision (Single-Gen)" : "0.6978730822873083" ,
# "Probability Threshold" : "0.5" ,
# "Recall/Sensitivity (Single-Gen)" : "0.4484149210261006"
# } ,
name: str = Field(None, alias="name") name: str = Field(None, alias="name")
pathwayPackages: List["SimplePackage"] = Field([]) pathwayPackages: List["SimplePackage"] = Field([], alias="pathway_packages")
reviewStatus: str = Field(None, alias="review_status") reviewStatus: str = Field(None, alias="review_status")
rulePackages: List["SimplePackage"] = Field([]) rulePackages: List["SimplePackage"] = Field([], alias="rule_packages")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios") scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
status: str status: str = Field(None, alias="model_status")
statusMessage: str statusMessage: str = Field(None, alias="status_message")
threshold: str threshold: str = Field(None, alias="threshold")
type: str type: str = Field(None, alias="model_type")
@staticmethod
def resolve_info(obj: EPModel):
return {}
@staticmethod
def resolve_status_message(obj: EPModel):
for k, v in PackageBasedModel.PROGRESS_STATUS_CHOICES.items():
if k == obj.model_status:
return v
return None
@staticmethod
def resolve_threshold(obj: EPModel):
return f"{obj.threshold:.2f}"
@staticmethod
def resolve_model_type(obj: EPModel):
if isinstance(obj, RuleBasedRelativeReasoning):
return "RULEBASED"
elif isinstance(obj, MLRelativeReasoning):
return "ECC"
elif isinstance(obj, EnviFormer):
return "ENVIFORMER"
else:
return None
@router.get("/model", response={200: ModelWrapper, 403: Error}) @router.get("/model", response={200: ModelWrapper, 403: Error})
@ -1833,7 +1961,7 @@ def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
if pr.rule: if pr.rule:
res["id"] = pr.rule.url res["id"] = pr.rule.url
res["identifier"] = pr.rule.get_rule_identifier() res["identifier"] = pr.rule.get_rule_identifier()
res["name"] = pr.rule.name res["name"] = pr.rule.get_name()
res["reviewStatus"] = ( res["reviewStatus"] = (
"reviewed" if pr.rule.package.reviewed else "unreviewed" "reviewed" if pr.rule.package.reviewed else "unreviewed"
) )

View File

@ -194,8 +194,6 @@ class UserManager(object):
if clean_username != username or clean_email != email: if clean_username != username or clean_email != email:
# This will be caught by the try in view.py/register # This will be caught by the try in view.py/register
raise ValueError("Invalid username or password") raise ValueError("Invalid username or password")
# avoid circular import :S
from .tasks import send_registration_mail
extra_fields = {"is_active": not s.ADMIN_APPROVAL_REQUIRED} extra_fields = {"is_active": not s.ADMIN_APPROVAL_REQUIRED}
@ -214,10 +212,6 @@ class UserManager(object):
u.default_package = p u.default_package = p
u.save() u.save()
if not u.is_active:
# send email for verification
send_registration_mail.delay(u.pk)
if set_setting: if set_setting:
u.default_setting = Setting.objects.get(global_default=True) u.default_setting = Setting.objects.get(global_default=True)
u.save() u.save()
@ -685,7 +679,7 @@ class PackageManager(object):
ai_data = json.loads(res.model_dump_json()) ai_data = json.loads(res.model_dump_json())
ai_data["uuid"] = f"{uuid4()}" ai_data["uuid"] = f"{uuid4()}"
new_add_inf[res_cls_name].append(ai_data) new_add_inf[res_cls_name].append(ai_data)
except ValidationError: except (ValidationError, ValueError):
logger.error(f"Failed to convert {name} with {addinf_data}") logger.error(f"Failed to convert {name} with {addinf_data}")
scen.additional_information = new_add_inf scen.additional_information = new_add_inf

View File

@ -8,7 +8,6 @@ from epdb.logic import UserManager, GroupManager, PackageManager, SettingManager
from epdb.models import ( from epdb.models import (
UserSettingPermission, UserSettingPermission,
MLRelativeReasoning, MLRelativeReasoning,
EnviFormer,
Permission, Permission,
User, User,
ExternalDatabase, ExternalDatabase,
@ -231,7 +230,6 @@ class Command(BaseCommand):
package=pack, package=pack,
rule_packages=[mapping["EAWAG-BBD"]], rule_packages=[mapping["EAWAG-BBD"]],
data_packages=[mapping["EAWAG-BBD"]], data_packages=[mapping["EAWAG-BBD"]],
eval_packages=[],
threshold=0.5, threshold=0.5,
name="ECC - BBD - T0.5", name="ECC - BBD - T0.5",
description="ML Relative Reasoning", description="ML Relative Reasoning",
@ -239,7 +237,3 @@ class Command(BaseCommand):
ml_model.build_dataset() ml_model.build_dataset()
ml_model.build_model() ml_model.build_model()
# If available, create EnviFormerModel
if s.ENVIFORMER_PRESENT:
EnviFormer.create(pack, "EnviFormer - T0.5", "EnviFormer Model with Threshold 0.5", 0.5)

View File

@ -0,0 +1,92 @@
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from epdb.models import APIToken
class Command(BaseCommand):
help = "Create an API token for a user"
def add_arguments(self, parser):
parser.add_argument(
"--username",
required=True,
help="Username of the user who will own the token",
)
parser.add_argument(
"--name",
required=True,
help="Descriptive name for the token",
)
parser.add_argument(
"--expires-days",
type=int,
default=90,
help="Days until expiration (0 for no expiration)",
)
parser.add_argument(
"--inactive",
action="store_true",
help="Create the token as inactive",
)
parser.add_argument(
"--curl",
action="store_true",
help="Print a curl example using the token",
)
parser.add_argument(
"--base-url",
default=None,
help="Base URL for curl example (default SERVER_URL or http://localhost:8000)",
)
parser.add_argument(
"--endpoint",
default="/api/v1/compounds/",
help="Endpoint path for curl example",
)
def handle(self, *args, **options):
username = options["username"]
name = options["name"]
expires_days = options["expires_days"]
if expires_days < 0:
raise CommandError("--expires-days must be >= 0")
if expires_days == 0:
expires_days = None
user_model = get_user_model()
try:
user = user_model.objects.get(username=username)
except user_model.DoesNotExist as exc:
raise CommandError(f"User not found for username '{username}'") from exc
token, raw_token = APIToken.create_token(user, name=name, expires_days=expires_days)
if options["inactive"]:
token.is_active = False
token.save(update_fields=["is_active"])
self.stdout.write(f"User: {user.username} ({user.email})")
self.stdout.write(f"Token name: {token.name}")
self.stdout.write(f"Token id: {token.id}")
if token.expires_at:
self.stdout.write(f"Expires at: {token.expires_at.isoformat()}")
else:
self.stdout.write("Expires at: never")
self.stdout.write(f"Active: {token.is_active}")
self.stdout.write("Raw token:")
self.stdout.write(raw_token)
if options["curl"]:
base_url = (
options["base_url"] or getattr(s, "SERVER_URL", None) or "http://localhost:8000"
)
endpoint = options["endpoint"]
endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}"
url = f"{base_url.rstrip('/')}{endpoint}"
curl_cmd = f'curl -H "Authorization: Bearer {raw_token}" "{url}"'
self.stdout.write("Curl:")
self.stdout.write(curl_cmd)

View File

@ -47,7 +47,7 @@ class Command(BaseCommand):
"description": model.description, "description": model.description,
"kv": model.kv, "kv": model.kv,
"data_packages_uuids": [str(p.uuid) for p in model.data_packages.all()], "data_packages_uuids": [str(p.uuid) for p in model.data_packages.all()],
"eval_packages_uuids": [str(p.uuid) for p in model.data_packages.all()], "eval_packages_uuids": [str(p.uuid) for p in model.eval_packages.all()],
"threshold": model.threshold, "threshold": model.threshold,
"eval_results": model.eval_results, "eval_results": model.eval_results,
"multigen_eval": model.multigen_eval, "multigen_eval": model.multigen_eval,

View File

@ -1,6 +1,5 @@
# Generated by Django 5.2.1 on 2025-07-22 20:58 # Generated by Django 5.2.7 on 2026-02-12 12:36
import datetime
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
import django.contrib.postgres.fields import django.contrib.postgres.fields
@ -19,11 +18,12 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('auth', '0012_alter_user_first_name_max_length'), ('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'), ('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.EPDB_PACKAGE_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Compound', name='ApplicabilityDomain',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
@ -31,9 +31,33 @@ class Migration(migrations.Migration):
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')), ('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('num_neighbours', models.IntegerField(default=5)),
('reliability_threshold', models.FloatField(default=0.5)),
('local_compatibilty_threshold', models.FloatField(default=0.5)),
('functional_groups', models.JSONField(blank=True, default=dict, null=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Edge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)), ('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')), ('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
], ],
options={
'abstract': False,
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='EPModel', name='EPModel',
@ -44,7 +68,9 @@ class Migration(migrations.Migration):
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')), ('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)), ('kv', models.JSONField(blank=True, default=dict, null=True)),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')), ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
], ],
options={ options={
@ -52,6 +78,27 @@ class Migration(migrations.Migration):
'base_manager_name': 'objects', 'base_manager_name': 'objects',
}, },
), ),
migrations.CreateModel(
name='ExternalDatabase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=100, unique=True, verbose_name='Database Name')),
('full_name', models.CharField(blank=True, max_length=255, verbose_name='Full Database Name')),
('description', models.TextField(blank=True, verbose_name='Description')),
('base_url', models.URLField(blank=True, null=True, verbose_name='Base URL')),
('url_pattern', models.CharField(blank=True, help_text="URL pattern with {id} placeholder, e.g., 'https://pubchem.ncbi.nlm.nih.gov/compound/{id}'", max_length=500, verbose_name='URL Pattern')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
],
options={
'verbose_name': 'External Database',
'verbose_name_plural': 'External Databases',
'db_table': 'epdb_external_database',
'ordering': ['name'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='Permission', name='Permission',
fields=[ fields=[
@ -65,6 +112,7 @@ class Migration(migrations.Migration):
name='License', name='License',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cc_string', models.TextField(verbose_name='CC string')),
('link', models.URLField(verbose_name='link')), ('link', models.URLField(verbose_name='link')),
('image_link', models.URLField(verbose_name='Image link')), ('image_link', models.URLField(verbose_name='Image link')),
], ],
@ -78,8 +126,11 @@ class Migration(migrations.Migration):
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')), ('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)), ('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')), ('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
], ],
options={ options={
'abstract': False, 'abstract': False,
@ -101,6 +152,9 @@ class Migration(migrations.Migration):
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True)), ('email', models.EmailField(max_length=254, unique=True)),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('is_reviewer', models.BooleanField(default=False)),
('default_package', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Default Package')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
], ],
@ -117,11 +171,34 @@ class Migration(migrations.Migration):
name='APIToken', name='APIToken',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('hashed_key', models.CharField(max_length=128, unique=True)), ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('created', models.DateTimeField(auto_now_add=True)), ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('expires_at', models.DateTimeField(blank=True, default=datetime.datetime(2025, 10, 20, 20, 58, 48, 351675, tzinfo=datetime.timezone.utc), null=True)), ('hashed_key', models.CharField(help_text='SHA-256 hash of the token key', max_length=128, unique=True)),
('name', models.CharField(blank=True, help_text='Optional name for the token', max_length=100)), ('expires_at', models.DateTimeField(blank=True, help_text='Token expiration time (null for no expiration)', null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('name', models.CharField(help_text='Descriptive name for this token', max_length=100)),
('is_active', models.BooleanField(default=True, help_text='Whether this token is active')),
('user', models.ForeignKey(help_text='User who owns this token', on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'API Token',
'verbose_name_plural': 'API Tokens',
'db_table': 'epdb_api_token',
'ordering': ['-created'],
},
),
migrations.CreateModel(
name='Compound',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -133,6 +210,7 @@ class Migration(migrations.Migration):
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')), ('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)), ('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')), ('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('smiles', models.TextField(verbose_name='SMILES')), ('smiles', models.TextField(verbose_name='SMILES')),
@ -150,36 +228,6 @@ class Migration(migrations.Migration):
name='default_structure', name='default_structure',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='compound_default_structure', to='epdb.compoundstructure', verbose_name='Default Structure'), field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='compound_default_structure', to='epdb.compoundstructure', verbose_name='Default Structure'),
), ),
migrations.CreateModel(
name='Edge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='EnviFormer',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.epmodel',),
),
migrations.CreateModel( migrations.CreateModel(
name='PluginModel', name='PluginModel',
fields=[ fields=[
@ -191,17 +239,6 @@ class Migration(migrations.Migration):
}, },
bases=('epdb.epmodel',), bases=('epdb.epmodel',),
), ),
migrations.CreateModel(
name='RuleBaseRelativeReasoning',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.epmodel',),
),
migrations.CreateModel( migrations.CreateModel(
name='Group', name='Group',
fields=[ fields=[
@ -209,10 +246,11 @@ class Migration(migrations.Migration):
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('name', models.TextField(verbose_name='Group name')), ('name', models.TextField(verbose_name='Group name')),
('public', models.BooleanField(default=False, verbose_name='Public Group')), ('public', models.BooleanField(default=False, verbose_name='Public Group')),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('description', models.TextField(default='no description', verbose_name='Descriptions')),
('group_member', models.ManyToManyField(related_name='groups_in_group', to='epdb.group', verbose_name='Group member')), ('group_member', models.ManyToManyField(blank=True, related_name='groups_in_group', to='epdb.group', verbose_name='Group member')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Group Owner')), ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Group Owner')),
('user_member', models.ManyToManyField(related_name='users_in_group', to=settings.AUTH_USER_MODEL, verbose_name='User members')), ('user_member', models.ManyToManyField(related_name='users_in_group', to=settings.AUTH_USER_MODEL, verbose_name='User members')),
], ],
@ -225,6 +263,41 @@ class Migration(migrations.Migration):
name='default_group', name='default_group',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_group', to='epdb.group', verbose_name='Default Group'), field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_group', to='epdb.group', verbose_name='Default Group'),
), ),
migrations.CreateModel(
name='JobLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('task_id', models.UUIDField(unique=True)),
('job_name', models.TextField()),
('status', models.CharField(choices=[('INITIAL', 'Initial'), ('SUCCESS', 'Success'), ('FAILURE', 'Failure'), ('REVOKED', 'Revoked'), ('IGNORED', 'Ignored')], default='INITIAL', max_length=20)),
('done_at', models.DateTimeField(blank=True, default=None, null=True)),
('task_result', models.TextField(blank=True, default=None, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Package',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('reviewed', models.BooleanField(default=False, verbose_name='Reviewstatus')),
('license', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.license', verbose_name='License')),
],
options={
'swappable': 'EPDB_PACKAGE_MODEL',
},
),
migrations.CreateModel( migrations.CreateModel(
name='Node', name='Node',
fields=[ fields=[
@ -234,9 +307,11 @@ class Migration(migrations.Migration):
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')), ('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)), ('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')), ('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('depth', models.IntegerField(verbose_name='Node depth')), ('depth', models.IntegerField(verbose_name='Node depth')),
('stereo_removed', models.BooleanField(default=False)),
('default_node_label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='default_node_structure', to='epdb.compoundstructure', verbose_name='Default Node Label')), ('default_node_label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='default_node_structure', to='epdb.compoundstructure', verbose_name='Default Node Label')),
('node_labels', models.ManyToManyField(related_name='node_structures', to='epdb.compoundstructure', verbose_name='All Node Labels')), ('node_labels', models.ManyToManyField(related_name='node_structures', to='epdb.compoundstructure', verbose_name='All Node Labels')),
('out_edges', models.ManyToManyField(to='epdb.edge', verbose_name='Outgoing Edges')), ('out_edges', models.ManyToManyField(to='epdb.edge', verbose_name='Outgoing Edges')),
@ -255,38 +330,6 @@ class Migration(migrations.Migration):
name='start_nodes', name='start_nodes',
field=models.ManyToManyField(related_name='edge_educts', to='epdb.node', verbose_name='Start Nodes'), field=models.ManyToManyField(related_name='edge_educts', to='epdb.node', verbose_name='Start Nodes'),
), ),
migrations.CreateModel(
name='Package',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('reviewed', models.BooleanField(default=False, verbose_name='Reviewstatus')),
('license', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.license', verbose_name='License')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='epmodel',
name='package',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package'),
),
migrations.AddField(
model_name='compound',
name='package',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package'),
),
migrations.AddField(
model_name='user',
name='default_package',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.package', verbose_name='Default Package'),
),
migrations.CreateModel( migrations.CreateModel(
name='SequentialRule', name='SequentialRule',
fields=[ fields=[
@ -309,16 +352,6 @@ class Migration(migrations.Migration):
}, },
bases=('epdb.rule',), bases=('epdb.rule',),
), ),
migrations.AddField(
model_name='rule',
name='package',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package'),
),
migrations.AddField(
model_name='rule',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
migrations.CreateModel( migrations.CreateModel(
name='Pathway', name='Pathway',
fields=[ fields=[
@ -328,9 +361,11 @@ class Migration(migrations.Migration):
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')), ('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)), ('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')), ('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package')), ('predicted', models.BooleanField(default=False)),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
], ],
options={ options={
'abstract': False, 'abstract': False,
@ -355,12 +390,13 @@ class Migration(migrations.Migration):
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')), ('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)), ('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')), ('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('multi_step', models.BooleanField(verbose_name='Multistep Reaction')), ('multi_step', models.BooleanField(verbose_name='Multistep Reaction')),
('medline_references', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), null=True, size=None, verbose_name='Medline References')), ('medline_references', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), null=True, size=None, verbose_name='Medline References')),
('educts', models.ManyToManyField(related_name='reaction_educts', to='epdb.compoundstructure', verbose_name='Educts')), ('educts', models.ManyToManyField(related_name='reaction_educts', to='epdb.compoundstructure', verbose_name='Educts')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package')), ('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
('products', models.ManyToManyField(related_name='reaction_products', to='epdb.compoundstructure', verbose_name='Products')), ('products', models.ManyToManyField(related_name='reaction_products', to='epdb.compoundstructure', verbose_name='Products')),
('rules', models.ManyToManyField(related_name='reaction_rule', to='epdb.rule', verbose_name='Rule')), ('rules', models.ManyToManyField(related_name='reaction_rule', to='epdb.rule', verbose_name='Rule')),
], ],
@ -368,6 +404,28 @@ class Migration(migrations.Migration):
'abstract': False, 'abstract': False,
}, },
), ),
migrations.CreateModel(
name='EnzymeLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('ec_number', models.TextField(verbose_name='EC Number')),
('classification_level', models.IntegerField(verbose_name='Classification Level')),
('linking_method', models.TextField(verbose_name='Linking Method')),
('edge_evidence', models.ManyToManyField(to='epdb.edge')),
('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.rule')),
('reaction_evidence', models.ManyToManyField(to='epdb.reaction')),
],
options={
'abstract': False,
},
),
migrations.AddField( migrations.AddField(
model_name='edge', model_name='edge',
name='edge_label', name='edge_label',
@ -382,11 +440,12 @@ class Migration(migrations.Migration):
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')), ('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)), ('kv', models.JSONField(blank=True, default=dict, null=True)),
('scenario_date', models.CharField(default='No date', max_length=256)), ('scenario_date', models.CharField(default='No date', max_length=256)),
('scenario_type', models.CharField(default='Not specified', max_length=256)), ('scenario_type', models.CharField(default='Not specified', max_length=256)),
('additional_information', models.JSONField(verbose_name='Additional Information')), ('additional_information', models.JSONField(verbose_name='Additional Information')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package')), ('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
('parent', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='epdb.scenario')), ('parent', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='epdb.scenario')),
], ],
options={ options={
@ -437,14 +496,16 @@ class Migration(migrations.Migration):
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')), ('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)), ('kv', models.JSONField(blank=True, default=dict, null=True)),
('public', models.BooleanField(default=False)), ('public', models.BooleanField(default=False)),
('global_default', models.BooleanField(default=False)), ('global_default', models.BooleanField(default=False)),
('max_depth', models.IntegerField(default=5, verbose_name='Setting Max Depth')), ('max_depth', models.IntegerField(default=5, verbose_name='Setting Max Depth')),
('max_nodes', models.IntegerField(default=30, verbose_name='Setting Max Number of Nodes')), ('max_nodes', models.IntegerField(default=30, verbose_name='Setting Max Number of Nodes')),
('model_threshold', models.FloatField(blank=True, default=0.25, null=True, verbose_name='Setting Model Threshold')), ('model_threshold', models.FloatField(blank=True, default=0.25, null=True, verbose_name='Setting Model Threshold')),
('expansion_scheme', models.CharField(choices=[('BFS', 'Breadth First Search'), ('DFS', 'Depth First Search'), ('GREEDY', 'Greedy')], default='BFS', max_length=20)),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.epmodel', verbose_name='Setting EPModel')), ('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.epmodel', verbose_name='Setting EPModel')),
('rule_packages', models.ManyToManyField(blank=True, related_name='setting_rule_packages', to='epdb.package', verbose_name='Setting Rule Packages')), ('rule_packages', models.ManyToManyField(blank=True, related_name='setting_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Setting Rule Packages')),
], ],
options={ options={
'abstract': False, 'abstract': False,
@ -461,39 +522,86 @@ class Migration(migrations.Migration):
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.setting', verbose_name='The users default settings'), field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.setting', verbose_name='The users default settings'),
), ),
migrations.CreateModel( migrations.CreateModel(
name='MLRelativeReasoning', name='EnviFormer',
fields=[ fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')), ('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)), ('threshold', models.FloatField(default=0.5)),
('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
('eval_results', models.JSONField(blank=True, default=dict, null=True)), ('eval_results', models.JSONField(blank=True, default=dict, null=True)),
('data_packages', models.ManyToManyField(related_name='data_packages', to='epdb.package', verbose_name='Data Packages')), ('multigen_eval', models.BooleanField(default=False)),
('eval_packages', models.ManyToManyField(related_name='eval_packages', to='epdb.package', verbose_name='Evaluation Packages')), ('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
('rule_packages', models.ManyToManyField(related_name='rule_packages', to='epdb.package', verbose_name='Rule Packages')), ('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages')),
('eval_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages')),
('rule_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages')),
], ],
options={ options={
'abstract': False, 'abstract': False,
'base_manager_name': 'objects',
}, },
bases=('epdb.epmodel',), bases=('epdb.epmodel',),
), ),
migrations.CreateModel( migrations.CreateModel(
name='ApplicabilityDomain', name='MLRelativeReasoning',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)),
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
('multigen_eval', models.BooleanField(default=False)),
('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages')),
('eval_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages')),
('rule_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages')),
],
options={
'abstract': False,
},
bases=('epdb.epmodel',),
),
migrations.AddField(
model_name='applicabilitydomain',
name='model',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.mlrelativereasoning'),
),
migrations.CreateModel(
name='RuleBasedRelativeReasoning',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)),
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
('multigen_eval', models.BooleanField(default=False)),
('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
('min_count', models.IntegerField(default=10)),
('max_count', models.IntegerField(default=0)),
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages')),
('eval_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages')),
('rule_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages')),
],
options={
'abstract': False,
},
bases=('epdb.epmodel',),
),
migrations.CreateModel(
name='ExternalIdentifier',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.TextField(default='no name', verbose_name='Name')), ('object_id', models.IntegerField()),
('description', models.TextField(default='no description', verbose_name='Descriptions')), ('identifier_value', models.CharField(max_length=255, verbose_name='Identifier Value')),
('kv', models.JSONField(blank=True, default=dict, null=True)), ('url', models.URLField(blank=True, null=True, verbose_name='Direct URL')),
('num_neighbours', models.FloatField(default=5)), ('is_primary', models.BooleanField(default=False, help_text='Mark this as the primary identifier for this database', verbose_name='Is Primary')),
('reliability_threshold', models.FloatField(default=0.5)), ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('local_compatibilty_threshold', models.FloatField(default=0.5)), ('database', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.externaldatabase', verbose_name='External Database')),
('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.mlrelativereasoning')),
], ],
options={ options={
'abstract': False, 'verbose_name': 'External Identifier',
'verbose_name_plural': 'External Identifiers',
'db_table': 'epdb_external_identifier',
'indexes': [models.Index(fields=['content_type', 'object_id'], name='epdb_extern_content_b76813_idx'), models.Index(fields=['database', 'identifier_value'], name='epdb_extern_databas_486422_idx')],
'unique_together': {('content_type', 'object_id', 'database', 'identifier_value')},
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@ -558,7 +666,7 @@ class Migration(migrations.Migration):
('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')), ('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')),
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.group', verbose_name='Permission to')), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.group', verbose_name='Permission to')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Permission on')), ('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Permission on')),
], ],
options={ options={
'unique_together': {('package', 'group')}, 'unique_together': {('package', 'group')},
@ -570,7 +678,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')), ('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')),
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')), ('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Permission on')), ('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Permission on')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Permission to')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Permission to')),
], ],
options={ options={

View File

@ -40,7 +40,7 @@ def set_cc(apps, schema_editor):
row["cc_string"]: (row["lowest_pk"], row["all_pks"]) for row in license_lookup_qs row["cc_string"]: (row["lowest_pk"], row["all_pks"]) for row in license_lookup_qs
} }
Packages = apps.get_model("epdb", "Package") Packages = apps.get_model("bayer", "Package")
for k, v in license_lookup.items(): for k, v in license_lookup.items():
# Set min pk to all packages pointing to any of the duplicates # Set min pk to all packages pointing to any of the duplicates
@ -53,7 +53,7 @@ def set_cc(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("epdb", "0010_license_cc_string"), ("epdb", "0001_initial"),
] ]
operations = [migrations.RunPython(set_cc)] operations = [migrations.RunPython(set_cc)]

View File

@ -1,128 +0,0 @@
# Generated by Django 5.2.1 on 2025-08-25 18:07
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('epdb', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ExternalDatabase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=100, unique=True, verbose_name='Database Name')),
('full_name', models.CharField(blank=True, max_length=255, verbose_name='Full Database Name')),
('description', models.TextField(blank=True, verbose_name='Description')),
('base_url', models.URLField(blank=True, null=True, verbose_name='Base URL')),
('url_pattern', models.CharField(blank=True, help_text="URL pattern with {id} placeholder, e.g., 'https://pubchem.ncbi.nlm.nih.gov/compound/{id}'", max_length=500, verbose_name='URL Pattern')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
],
options={
'verbose_name': 'External Database',
'verbose_name_plural': 'External Databases',
'db_table': 'epdb_external_database',
'ordering': ['name'],
},
),
migrations.AlterModelOptions(
name='apitoken',
options={'ordering': ['-created'], 'verbose_name': 'API Token', 'verbose_name_plural': 'API Tokens'},
),
migrations.AlterModelOptions(
name='edge',
options={},
),
migrations.RemoveField(
model_name='edge',
name='polymorphic_ctype',
),
migrations.AddField(
model_name='apitoken',
name='is_active',
field=models.BooleanField(default=True, help_text='Whether this token is active'),
),
migrations.AddField(
model_name='apitoken',
name='modified',
field=model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified'),
),
migrations.AddField(
model_name='applicabilitydomain',
name='functional_groups',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AddField(
model_name='mlrelativereasoning',
name='app_domain',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain'),
),
migrations.AlterField(
model_name='apitoken',
name='created',
field=model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created'),
),
migrations.AlterField(
model_name='apitoken',
name='expires_at',
field=models.DateTimeField(blank=True, help_text='Token expiration time (null for no expiration)', null=True),
),
migrations.AlterField(
model_name='apitoken',
name='hashed_key',
field=models.CharField(help_text='SHA-256 hash of the token key', max_length=128, unique=True),
),
migrations.AlterField(
model_name='apitoken',
name='name',
field=models.CharField(help_text='Descriptive name for this token', max_length=100),
),
migrations.AlterField(
model_name='apitoken',
name='user',
field=models.ForeignKey(help_text='User who owns this token', on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='applicabilitydomain',
name='num_neighbours',
field=models.IntegerField(default=5),
),
migrations.AlterModelTable(
name='apitoken',
table='epdb_api_token',
),
migrations.CreateModel(
name='ExternalIdentifier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('object_id', models.IntegerField()),
('identifier_value', models.CharField(max_length=255, verbose_name='Identifier Value')),
('url', models.URLField(blank=True, null=True, verbose_name='Direct URL')),
('is_primary', models.BooleanField(default=False, help_text='Mark this as the primary identifier for this database', verbose_name='Is Primary')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('database', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.externaldatabase', verbose_name='External Database')),
],
options={
'verbose_name': 'External Identifier',
'verbose_name_plural': 'External Identifiers',
'db_table': 'epdb_external_identifier',
'indexes': [models.Index(fields=['content_type', 'object_id'], name='epdb_extern_content_b76813_idx'), models.Index(fields=['database', 'identifier_value'], name='epdb_extern_databas_486422_idx')],
'unique_together': {('content_type', 'object_id', 'database', 'identifier_value')},
},
),
]

View File

@ -1,228 +0,0 @@
# Generated by Django 5.2.1 on 2025-08-26 17:05
from django.db import migrations, models
def populate_url(apps, schema_editor):
MODELS = [
'User',
'Group',
'Package',
'Compound',
'CompoundStructure',
'Pathway',
'Edge',
'Node',
'Reaction',
'SimpleAmbitRule',
'SimpleRDKitRule',
'ParallelRule',
'SequentialRule',
'Scenario',
'Setting',
'MLRelativeReasoning',
'EnviFormer',
'ApplicabilityDomain',
]
for model in MODELS:
obj_cls = apps.get_model("epdb", model)
for obj in obj_cls.objects.all():
obj.url = assemble_url(obj)
if obj.url is None:
raise ValueError(f"Could not assemble url for {obj}")
obj.save()
def assemble_url(obj):
from django.conf import settings as s
match obj.__class__.__name__:
case 'User':
return '{}/user/{}'.format(s.SERVER_URL, obj.uuid)
case 'Group':
return '{}/group/{}'.format(s.SERVER_URL, obj.uuid)
case 'Package':
return '{}/package/{}'.format(s.SERVER_URL, obj.uuid)
case 'Compound':
return '{}/compound/{}'.format(obj.package.url, obj.uuid)
case 'CompoundStructure':
return '{}/structure/{}'.format(obj.compound.url, obj.uuid)
case 'SimpleAmbitRule':
return '{}/simple-ambit-rule/{}'.format(obj.package.url, obj.uuid)
case 'SimpleRDKitRule':
return '{}/simple-rdkit-rule/{}'.format(obj.package.url, obj.uuid)
case 'ParallelRule':
return '{}/parallel-rule/{}'.format(obj.package.url, obj.uuid)
case 'SequentialRule':
return '{}/sequential-rule/{}'.format(obj.compound.url, obj.uuid)
case 'Reaction':
return '{}/reaction/{}'.format(obj.package.url, obj.uuid)
case 'Pathway':
return '{}/pathway/{}'.format(obj.package.url, obj.uuid)
case 'Node':
return '{}/node/{}'.format(obj.pathway.url, obj.uuid)
case 'Edge':
return '{}/edge/{}'.format(obj.pathway.url, obj.uuid)
case 'MLRelativeReasoning':
return '{}/model/{}'.format(obj.package.url, obj.uuid)
case 'EnviFormer':
return '{}/model/{}'.format(obj.package.url, obj.uuid)
case 'ApplicabilityDomain':
return '{}/model/{}/applicability-domain/{}'.format(obj.model.package.url, obj.model.uuid, obj.uuid)
case 'Scenario':
return '{}/scenario/{}'.format(obj.package.url, obj.uuid)
case 'Setting':
return '{}/setting/{}'.format(s.SERVER_URL, obj.uuid)
case _:
raise ValueError(f"Unknown model {obj.__class__.__name__}")
class Migration(migrations.Migration):
dependencies = [
('epdb', '0002_externaldatabase_alter_apitoken_options_and_more'),
]
operations = [
migrations.AddField(
model_name='applicabilitydomain',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='compound',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='compoundstructure',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='edge',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='epmodel',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='group',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='node',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='package',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='pathway',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='reaction',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='rule',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='scenario',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='setting',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='user',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.RunPython(populate_url, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='applicabilitydomain',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='compound',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='compoundstructure',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='edge',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='epmodel',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='group',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='node',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='package',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='pathway',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='reaction',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='rule',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='scenario',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='setting',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='user',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
]

View File

@ -1,55 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-09 09:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epdb', '0001_squashed_0003_applicabilitydomain_url_compound_url_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='mlrelativereasoning',
options={},
),
migrations.AlterField(
model_name='mlrelativereasoning',
name='data_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to='epdb.package', verbose_name='Data Packages'),
),
migrations.AlterField(
model_name='mlrelativereasoning',
name='eval_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to='epdb.package', verbose_name='Evaluation Packages'),
),
migrations.AlterField(
model_name='mlrelativereasoning',
name='rule_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to='epdb.package', verbose_name='Rule Packages'),
),
migrations.CreateModel(
name='RuleBasedRelativeReasoning',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)),
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
('min_count', models.IntegerField(default=10)),
('max_count', models.IntegerField(default=0)),
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to='epdb.package', verbose_name='Data Packages')),
('eval_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to='epdb.package', verbose_name='Evaluation Packages')),
('rule_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to='epdb.package', verbose_name='Rule Packages')),
],
options={
'abstract': False,
},
bases=('epdb.epmodel',),
),
migrations.DeleteModel(
name='RuleBaseRelativeReasoning',
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-11 06:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epdb', '0004_alter_mlrelativereasoning_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='group',
name='group_member',
field=models.ManyToManyField(blank=True, related_name='groups_in_group', to='epdb.group', verbose_name='Group member'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-18 06:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epdb', '0005_alter_group_group_member'),
]
operations = [
migrations.AddField(
model_name='mlrelativereasoning',
name='multigen_eval',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='rulebasedrelativereasoning',
name='multigen_eval',
field=models.BooleanField(default=False),
),
]

View File

@ -1,53 +0,0 @@
# Generated by Django 5.2.1 on 2025-10-07 08:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epdb', '0006_mlrelativereasoning_multigen_eval_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='enviformer',
options={},
),
migrations.AddField(
model_name='enviformer',
name='app_domain',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain'),
),
migrations.AddField(
model_name='enviformer',
name='data_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to='epdb.package', verbose_name='Data Packages'),
),
migrations.AddField(
model_name='enviformer',
name='eval_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to='epdb.package', verbose_name='Evaluation Packages'),
),
migrations.AddField(
model_name='enviformer',
name='eval_results',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AddField(
model_name='enviformer',
name='model_status',
field=models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL'),
),
migrations.AddField(
model_name='enviformer',
name='multigen_eval',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='enviformer',
name='rule_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to='epdb.package', verbose_name='Rule Packages'),
),
]

View File

@ -1,64 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-10 06:58
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0007_alter_enviformer_options_enviformer_app_domain_and_more"),
]
operations = [
migrations.CreateModel(
name="EnzymeLink",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now, editable=False, verbose_name="created"
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now, editable=False, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
default=uuid.uuid4, unique=True, verbose_name="UUID of this object"
),
),
("name", models.TextField(default="no name", verbose_name="Name")),
(
"description",
models.TextField(default="no description", verbose_name="Descriptions"),
),
("url", models.TextField(null=True, unique=True, verbose_name="URL")),
("kv", models.JSONField(blank=True, default=dict, null=True)),
("ec_number", models.TextField(verbose_name="EC Number")),
("classification_level", models.IntegerField(verbose_name="Classification Level")),
("linking_method", models.TextField(verbose_name="Linking Method")),
("edge_evidence", models.ManyToManyField(to="epdb.edge")),
("reaction_evidence", models.ManyToManyField(to="epdb.reaction")),
(
"rule",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="epdb.rule"),
),
],
options={
"abstract": False,
},
),
]

View File

@ -1,66 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-27 09:39
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0008_enzymelink"),
]
operations = [
migrations.CreateModel(
name="JobLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now, editable=False, verbose_name="created"
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now, editable=False, verbose_name="modified"
),
),
("task_id", models.UUIDField(unique=True)),
("job_name", models.TextField()),
(
"status",
models.CharField(
choices=[
("INITIAL", "Initial"),
("SUCCESS", "Success"),
("FAILURE", "Failure"),
("REVOKED", "Revoked"),
("IGNORED", "Ignored"),
],
default="INITIAL",
max_length=20,
),
),
("done_at", models.DateTimeField(blank=True, default=None, null=True)),
("task_result", models.TextField(blank=True, default=None, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-11 14:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0009_joblog"),
]
operations = [
migrations.AddField(
model_name="license",
name="cc_string",
field=models.TextField(default="by-nc-sa", verbose_name="CC string"),
preserve_default=False,
),
]

View File

@ -1,22 +0,0 @@
# Generated by Django 5.2.7 on 2025-12-02 13:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0011_auto_20251111_1413"),
]
operations = [
migrations.AddField(
model_name="node",
name="stereo_removed",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="pathway",
name="predicted",
field=models.BooleanField(default=False),
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 5.2.7 on 2025-12-14 11:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0012_node_stereo_removed_pathway_predicted"),
]
operations = [
migrations.AddField(
model_name="setting",
name="expansion_schema",
field=models.CharField(
choices=[
("BFS", "Breadth First Search"),
("DFS", "Depth First Search"),
("GREEDY", "Greedy"),
],
default="BFS",
max_length=20,
),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 5.2.7 on 2025-12-14 16:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("epdb", "0013_setting_expansion_schema"),
]
operations = [
migrations.RenameField(
model_name="setting",
old_name="expansion_schema",
new_name="expansion_scheme",
),
]

View File

@ -72,10 +72,14 @@ class User(AbstractUser):
null=True, null=True,
blank=False, blank=False,
) )
is_reviewer = models.BooleanField(default=False)
USERNAME_FIELD = "email" USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"] REQUIRED_FIELDS = ["username"]
def get_name(self):
return self.username
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.url: if not self.url:
self.url = self._url() self.url = self._url()
@ -166,17 +170,18 @@ class APIToken(TimeStampedModel):
return token, raw_key return token, raw_key
@classmethod @classmethod
def authenticate(cls, raw_key: str) -> Optional[User]: def authenticate(cls, token: str, *, hashed: bool = False) -> Optional[User]:
""" """
Authenticate a user using an API token. Authenticate a user using an API token.
Args: Args:
raw_key: Raw token key token: Raw token key or SHA-256 hash (when hashed=True)
hashed: Whether the token is already hashed
Returns: Returns:
User if token is valid, None otherwise User if token is valid, None otherwise
""" """
hashed_key = hashlib.sha256(raw_key.encode()).hexdigest() hashed_key = token if hashed else hashlib.sha256(token.encode()).hexdigest()
try: try:
token = cls.objects.select_related("user").get(hashed_key=hashed_key) token = cls.objects.select_related("user").get(hashed_key=hashed_key)
@ -207,7 +212,10 @@ class Group(TimeStampedModel):
) )
def __str__(self): def __str__(self):
return f"{self.name} (pk={self.pk})" return f"{self.get_name()} (pk={self.pk})"
def get_name(self):
return self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.url: if not self.url:
@ -595,7 +603,7 @@ class EnviPathModel(TimeStampedModel):
res = { res = {
"url": self.url, "url": self.url,
"uuid": str(self.uuid), "uuid": str(self.uuid),
"name": self.name, "name": self.get_name(),
} }
if include_description: if include_description:
@ -608,11 +616,14 @@ class EnviPathModel(TimeStampedModel):
return self.kv.get(k, default) return self.kv.get(k, default)
return default return default
def get_name(self):
return self.name
class Meta: class Meta:
abstract = True abstract = True
def __str__(self): def __str__(self):
return f"{self.name} (pk={self.pk})" return f"{self.get_name()} (pk={self.pk})"
class AliasMixin(models.Model): class AliasMixin(models.Model):
@ -623,7 +634,7 @@ class AliasMixin(models.Model):
@transaction.atomic @transaction.atomic
def add_alias(self, new_alias, set_as_default=False): def add_alias(self, new_alias, set_as_default=False):
if set_as_default: if set_as_default:
self.aliases.append(self.name) self.aliases.append(self.get_name())
self.name = new_alias self.name = new_alias
if new_alias in self.aliases: if new_alias in self.aliases:
@ -764,15 +775,17 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
logger.debug( logger.debug(
f"#Structures: {num_structs} - #Standardized SMILES: {len(stand_smiles)}" f"#Structures: {num_structs} - #Standardized SMILES: {len(stand_smiles)}"
) )
logger.debug(f"Couldn't infer normalized structure for {self.name} - {self.url}") logger.debug(
f"Couldn't infer normalized structure for {self.get_name()} - {self.url}"
)
raise ValueError( raise ValueError(
f"Couldn't find nor infer normalized structure for {self.name} ({self.url})" f"Couldn't find nor infer normalized structure for {self.get_name()} ({self.url})"
) )
else: else:
cs = CompoundStructure.create( cs = CompoundStructure.create(
self, self,
stand_smiles.pop(), stand_smiles.pop(),
name="Normalized structure of {}".format(self.name), name="Normalized structure of {}".format(self.get_name()),
description="{} (in its normalized form)".format(self.description), description="{} (in its normalized form)".format(self.description),
normalized_structure=True, normalized_structure=True,
) )
@ -847,8 +860,10 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
if name is not None: if name is not None:
# Clean for potential XSS # Clean for potential XSS
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "": if name is None or name == "":
name = f"Compound {Compound.objects.filter(package=package).count() + 1}" name = f"Compound {Compound.objects.filter(package=package).count() + 1}"
c.name = name c.name = name
# We have a default here only set the value if it carries some payload # We have a default here only set the value if it carries some payload
@ -977,7 +992,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
cs = CompoundStructure.create( cs = CompoundStructure.create(
existing_normalized_compound, existing_normalized_compound,
structure.smiles, structure.smiles,
name=structure.name, name=structure.get_name(),
description=structure.description, description=structure.description,
normalized_structure=structure.normalized_structure, normalized_structure=structure.normalized_structure,
) )
@ -988,13 +1003,13 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
else: else:
raise ValueError( raise ValueError(
f"Found a CompoundStructure for {default_structure_smiles} but not for {normalized_structure_smiles} in target package {target.name}" f"Found a CompoundStructure for {default_structure_smiles} but not for {normalized_structure_smiles} in target package {target.get_name()}"
) )
else: else:
# Here we can safely use Compound.objects.create as we won't end up in a duplicate # Here we can safely use Compound.objects.create as we won't end up in a duplicate
new_compound = Compound.objects.create( new_compound = Compound.objects.create(
package=target, package=target,
name=self.name, name=self.get_name(),
description=self.description, description=self.description,
kv=self.kv.copy() if self.kv else {}, kv=self.kv.copy() if self.kv else {},
) )
@ -1010,7 +1025,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
canonical_smiles=structure.canonical_smiles, canonical_smiles=structure.canonical_smiles,
inchikey=structure.inchikey, inchikey=structure.inchikey,
normalized_structure=structure.normalized_structure, normalized_structure=structure.normalized_structure,
name=structure.name, name=structure.get_name(),
description=structure.description, description=structure.description,
kv=structure.kv.copy() if structure.kv else {}, kv=structure.kv.copy() if structure.kv else {},
) )
@ -1049,11 +1064,8 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
def half_lifes(self): def half_lifes(self):
hls: Dict[Scenario, List[HalfLife]] = defaultdict(list) hls: Dict[Scenario, List[HalfLife]] = defaultdict(list)
for n in self.related_nodes: for cs in self.structures:
for scen in n.scenarios.all().order_by("name"): hls.update(cs.half_lifes())
for ai in scen.get_additional_information():
if isinstance(ai, HalfLife):
hls[scen].append(ai)
return dict(hls) return dict(hls)
@ -1103,6 +1115,7 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdenti
# Clean for potential XSS # Clean for potential XSS
if name is not None: if name is not None:
cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if description is not None: if description is not None:
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
@ -1146,6 +1159,21 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdenti
def is_default_structure(self): def is_default_structure(self):
return self.compound.default_structure == self return self.compound.default_structure == self
@property
def related_nodes(self):
return Node.objects.filter(node_labels__in=[self], pathway__package=self.compound.package)
def half_lifes(self):
hls: Dict[Scenario, List[HalfLife]] = defaultdict(list)
for n in self.related_nodes:
for scen in n.scenarios.all().order_by("name"):
for ai in scen.get_additional_information():
if isinstance(ai, HalfLife):
hls[scen].append(ai)
return dict(hls)
class EnzymeLink(EnviPathModel, KEGGIdentifierMixin): class EnzymeLink(EnviPathModel, KEGGIdentifierMixin):
rule = models.ForeignKey("Rule", on_delete=models.CASCADE, db_index=True) rule = models.ForeignKey("Rule", on_delete=models.CASCADE, db_index=True)
@ -1217,7 +1245,7 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
if rule_type == SimpleAmbitRule: if rule_type == SimpleAmbitRule:
new_rule = SimpleAmbitRule.create( new_rule = SimpleAmbitRule.create(
package=target, package=target,
name=self.name, name=self.get_name(),
description=self.description, description=self.description,
smirks=self.smirks, smirks=self.smirks,
reactant_filter_smarts=self.reactant_filter_smarts, reactant_filter_smarts=self.reactant_filter_smarts,
@ -1231,7 +1259,7 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
elif rule_type == SimpleRDKitRule: elif rule_type == SimpleRDKitRule:
new_rule = SimpleRDKitRule.create( new_rule = SimpleRDKitRule.create(
package=target, package=target,
name=self.name, name=self.get_name(),
description=self.description, description=self.description,
reaction_smarts=self.reaction_smarts, reaction_smarts=self.reaction_smarts,
) )
@ -1249,7 +1277,7 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
new_rule = ParallelRule.create( new_rule = ParallelRule.create(
package=target, package=target,
simple_rules=new_srs, simple_rules=new_srs,
name=self.name, name=self.get_name(),
description=self.description, description=self.description,
) )
@ -1353,12 +1381,13 @@ class SimpleAmbitRule(SimpleRule):
def get_rule_identifier(self) -> str: def get_rule_identifier(self) -> str:
return "simple-rule" return "simple-rule"
def apply(self, smiles): def apply(self, smiles, *args, **kwargs):
return FormatConverter.apply( return FormatConverter.apply(
smiles, smiles,
self.smirks, self.smirks,
reactant_filter_smarts=self.reactant_filter_smarts, reactant_filter_smarts=self.reactant_filter_smarts,
product_filter_smarts=self.product_filter_smarts, product_filter_smarts=self.product_filter_smarts,
**kwargs,
) )
@property @property
@ -1388,8 +1417,8 @@ class SimpleAmbitRule(SimpleRule):
class SimpleRDKitRule(SimpleRule): class SimpleRDKitRule(SimpleRule):
reaction_smarts = models.TextField(blank=False, null=False, verbose_name="SMIRKS") reaction_smarts = models.TextField(blank=False, null=False, verbose_name="SMIRKS")
def apply(self, smiles): def apply(self, smiles, *args, **kwargs):
return FormatConverter.apply(smiles, self.reaction_smarts) return FormatConverter.apply(smiles, self.reaction_smarts, **kwargs)
def _url(self): def _url(self):
return "{}/simple-rdkit-rule/{}".format(self.package.url, self.uuid) return "{}/simple-rdkit-rule/{}".format(self.package.url, self.uuid)
@ -1410,10 +1439,10 @@ class ParallelRule(Rule):
def srs(self) -> QuerySet: def srs(self) -> QuerySet:
return self.simple_rules.all() return self.simple_rules.all()
def apply(self, structure): def apply(self, structure, *args, **kwargs):
res = list() res = list()
for simple_rule in self.srs: for simple_rule in self.srs:
res.extend(simple_rule.apply(structure)) res.extend(simple_rule.apply(structure, **kwargs))
return list(set(res)) return list(set(res))
@ -1518,11 +1547,11 @@ class SequentialRule(Rule):
def srs(self): def srs(self):
return self.simple_rules.all() return self.simple_rules.all()
def apply(self, structure): def apply(self, structure, *args, **kwargs):
# TODO determine levels or see java implementation # TODO determine levels or see java implementation
res = set() res = set()
for simple_rule in self.srs: for simple_rule in self.srs:
res.union(set(simple_rule.apply(structure))) res.union(set(simple_rule.apply(structure, **kwargs)))
return res return res
@ -1622,9 +1651,11 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
r = Reaction() r = Reaction()
r.package = package r.package = package
# Clean for potential XSS # Clean for potential XSS
if name is not None and name.strip() != "": if name is not None and name.strip() != "":
r.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() r.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if description is not None and name.strip() != "": if description is not None and name.strip() != "":
r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
@ -1672,7 +1703,7 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
new_reaction = Reaction.create( new_reaction = Reaction.create(
package=target, package=target,
name=self.name, name=self.get_name(),
description=self.description, description=self.description,
educts=copied_reaction_educts, educts=copied_reaction_educts,
products=copied_reaction_products, products=copied_reaction_products,
@ -1827,8 +1858,10 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
queue.append(prod) queue.append(prod)
# We shouldn't lose or make up nodes... # We shouldn't lose or make up nodes...
assert len(nodes) == len(self.nodes) if len(nodes) != len(self.nodes):
logger.debug(f"{self.name}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}") logger.debug(
f"{self.get_name()}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}"
)
links = [e.d3_json() for e in self.edges] links = [e.d3_json() for e in self.edges]
@ -1879,6 +1912,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
"source": pseudo_idx, "source": pseudo_idx,
"target": node_url_to_idx[target], "target": node_url_to_idx[target],
"app_domain": link.get("app_domain", None), "app_domain": link.get("app_domain", None),
"multi_step": link["multi_step"],
} }
adjusted_links.append(new_link) adjusted_links.append(new_link)
@ -1895,7 +1929,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
"isIncremental": self.kv.get("mode") == "incremental", "isIncremental": self.kv.get("mode") == "incremental",
"isPredicted": self.kv.get("mode") == "predicted", "isPredicted": self.kv.get("mode") == "predicted",
"lastModified": self.modified.strftime("%Y-%m-%d %H:%M:%S"), "lastModified": self.modified.strftime("%Y-%m-%d %H:%M:%S"),
"pathwayName": self.name, "pathwayName": self.get_name(),
"reviewStatus": "reviewed" if self.package.reviewed else "unreviewed", "reviewStatus": "reviewed" if self.package.reviewed else "unreviewed",
"scenarios": [], "scenarios": [],
"upToDate": True, "upToDate": True,
@ -1938,14 +1972,14 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
if include_pathway_url: if include_pathway_url:
row.append(n.pathway.url) row.append(n.pathway.url)
row += [cs.smiles, cs.name, n.depth] row += [cs.smiles, cs.get_name(), n.depth]
edges = self.edges.filter(end_nodes__in=[n]) edges = self.edges.filter(end_nodes__in=[n])
if len(edges): if len(edges):
for e in edges: for e in edges:
_row = row.copy() _row = row.copy()
_row.append(e.kv.get("probability")) _row.append(e.kv.get("probability"))
_row.append(",".join([r.name for r in e.edge_label.rules.all()])) _row.append(",".join([r.get_name() for r in e.edge_label.rules.all()]))
_row.append(",".join([r.url for r in e.edge_label.rules.all()])) _row.append(",".join([r.url for r in e.edge_label.rules.all()]))
_row.append(e.start_nodes.all()[0].default_node_label.smiles) _row.append(e.start_nodes.all()[0].default_node_label.smiles)
rows.append(_row) rows.append(_row)
@ -1976,12 +2010,14 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
if name is not None: if name is not None:
# Clean for potential XSS # Clean for potential XSS
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "": if name is None or name == "":
name = f"Pathway {Pathway.objects.filter(package=package).count() + 1}" name = f"Pathway {Pathway.objects.filter(package=package).count() + 1}"
pw.name = name pw.name = name
if description is not None and description.strip() != "": if description is not None and description.strip() != "":
pw.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() pw.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
pw.predicted = predicted pw.predicted = predicted
pw.save() pw.save()
@ -2006,7 +2042,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
# deduplicated # deduplicated
new_pathway = Pathway.objects.create( new_pathway = Pathway.objects.create(
package=target, package=target,
name=self.name, name=self.get_name(),
description=self.description, description=self.description,
setting=self.setting, # TODO copy settings? setting=self.setting, # TODO copy settings?
kv=self.kv.copy() if self.kv else {}, kv=self.kv.copy() if self.kv else {},
@ -2036,7 +2072,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
pathway=new_pathway, pathway=new_pathway,
default_node_label=copied_structure, default_node_label=copied_structure,
depth=node.depth, depth=node.depth,
name=node.name, name=node.get_name(),
description=node.description, description=node.description,
kv=node.kv.copy() if node.kv else {}, kv=node.kv.copy() if node.kv else {},
) )
@ -2060,7 +2096,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
new_edge = Edge.objects.create( new_edge = Edge.objects.create(
pathway=new_pathway, pathway=new_pathway,
edge_label=copied_reaction, edge_label=copied_reaction,
name=edge.name, name=edge.get_name(),
description=edge.description, description=edge.description,
kv=edge.kv.copy() if edge.kv else {}, kv=edge.kv.copy() if edge.kv else {},
) )
@ -2120,6 +2156,18 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
def _url(self): def _url(self):
return "{}/node/{}".format(self.pathway.url, self.uuid) return "{}/node/{}".format(self.pathway.url, self.uuid)
def get_name(self):
non_generic_name = True
if self.name == "no name":
non_generic_name = False
return (
self.name
if non_generic_name
else f"{self.default_node_label.name} (taken from underlying structure)"
)
def d3_json(self): def d3_json(self):
app_domain_data = self.get_app_domain_assessment_data() app_domain_data = self.get_app_domain_assessment_data()
@ -2132,9 +2180,9 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
"image_svg": IndigoUtils.mol_to_svg( "image_svg": IndigoUtils.mol_to_svg(
self.default_node_label.smiles, width=40, height=40 self.default_node_label.smiles, width=40, height=40
), ),
"name": self.default_node_label.name, "name": self.get_name(),
"smiles": self.default_node_label.smiles, "smiles": self.default_node_label.smiles,
"scenarios": [{"name": s.name, "url": s.url} for s in self.scenarios.all()], "scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
"app_domain": { "app_domain": {
"inside_app_domain": app_domain_data["assessment"]["inside_app_domain"] "inside_app_domain": app_domain_data["assessment"]["inside_app_domain"]
if app_domain_data if app_domain_data
@ -2142,9 +2190,11 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
"uncovered_functional_groups": False, "uncovered_functional_groups": False,
}, },
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False), "is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
"timeseries": self.get_timeseries_data(),
} }
@staticmethod @staticmethod
@transaction.atomic
def create( def create(
pathway: "Pathway", pathway: "Pathway",
smiles: str, smiles: str,
@ -2178,12 +2228,22 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
def as_svg(self): def as_svg(self):
return IndigoUtils.mol_to_svg(self.default_node_label.smiles) return IndigoUtils.mol_to_svg(self.default_node_label.smiles)
def get_timeseries_data(self):
for scenario in self.scenarios.all():
for ai in scenario.get_additional_information():
if ai.__class__.__name__ == "OECD301FTimeSeries":
return ai.model_dump(mode="json")
return None
def get_app_domain_assessment_data(self): def get_app_domain_assessment_data(self):
data = self.kv.get("app_domain_assessment", None) data = self.kv.get("app_domain_assessment", None)
if data: if data:
rule_ids = defaultdict(list) rule_ids = defaultdict(list)
for e in Edge.objects.filter(start_nodes__in=[self]): for e in Edge.objects.filter(start_nodes__in=[self]):
# TODO While the Pathway is being predicted we sometimes
# TODO receive 'NoneType' object has no attribute 'rules'
if e.edge_label:
for r in e.edge_label.rules.all(): for r in e.edge_label.rules.all():
rule_ids[str(r.uuid)].append(e.simple_json()) rule_ids[str(r.uuid)].append(e.simple_json())
@ -2198,7 +2258,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
res = super().simple_json() res = super().simple_json()
name = res.get("name", None) name = res.get("name", None)
if name == "no name": if name == "no name":
res["name"] = self.default_node_label.name res["name"] = self.default_node_label.get_name()
return res return res
@ -2222,17 +2282,24 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
def d3_json(self): def d3_json(self):
edge_json = { edge_json = {
"name": self.name, "name": self.get_name(),
"id": self.url, "id": self.url,
"url": self.url, "url": self.url,
"image": self.url + "?image=svg", "image": self.url + "?image=svg",
"reaction": {"name": self.edge_label.name, "url": self.edge_label.url} "reaction": {
"name": self.edge_label.get_name(),
"url": self.edge_label.url,
"rules": [
{"name": r.get_name(), "url": r.url} for r in self.edge_label.rules.all()
],
}
if self.edge_label if self.edge_label
else None, else None,
"multi_step": self.edge_label.multi_step if self.edge_label else False,
"reaction_probability": self.kv.get("probability"), "reaction_probability": self.kv.get("probability"),
"start_node_urls": [x.url for x in self.start_nodes.all()], "start_node_urls": [x.url for x in self.start_nodes.all()],
"end_node_urls": [x.url for x in self.end_nodes.all()], "end_node_urls": [x.url for x in self.end_nodes.all()],
"scenarios": [{"name": s.name, "url": s.url} for s in self.scenarios.all()], "scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
} }
for n in self.start_nodes.all(): for n in self.start_nodes.all():
@ -2269,6 +2336,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
return edge_json return edge_json
@staticmethod @staticmethod
@transaction.atomic
def create( def create(
pathway, pathway,
start_nodes: List[Node], start_nodes: List[Node],
@ -2320,10 +2388,22 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
res = super().simple_json() res = super().simple_json()
name = res.get("name", None) name = res.get("name", None)
if name == "no name": if name == "no name":
res["name"] = self.edge_label.name res["name"] = self.edge_label.get_name()
return res return res
def get_name(self):
non_generic_name = True
if self.name == "no name":
non_generic_name = False
return (
self.name
if non_generic_name
else f"{self.edge_label.name} (taken from underlying reaction)"
)
class EPModel(PolymorphicModel, EnviPathModel): class EPModel(PolymorphicModel, EnviPathModel):
package = models.ForeignKey( package = models.ForeignKey(
@ -2604,7 +2684,7 @@ class PackageBasedModel(EPModel):
root_compounds.append(pw.root_nodes[0].default_node_label) root_compounds.append(pw.root_nodes[0].default_node_label)
else: else:
logger.info( logger.info(
f"Skipping MG Eval of Pathway {pw.name} ({pw.uuid}) as it has no root compounds!" f"Skipping MG Eval of Pathway {pw.get_name()} ({pw.uuid}) as it has no root compounds!"
) )
# As we need a Model Instance in our setting, get a fresh copy from db, overwrite the serialized mode and # As we need a Model Instance in our setting, get a fresh copy from db, overwrite the serialized mode and
@ -2730,7 +2810,7 @@ class PackageBasedModel(EPModel):
pathways.append(pathway) pathways.append(pathway)
else: else:
logging.warning( logging.warning(
f"No root compound in pathway {pathway.name}, excluding from multigen evaluation" f"No root compound in pathway {pathway.get_name()}, excluding from multigen evaluation"
) )
# build lookup reaction -> {uuid1, uuid2} for overlap check # build lookup reaction -> {uuid1, uuid2} for overlap check
@ -3062,7 +3142,7 @@ class ApplicabilityDomain(EnviPathModel):
ad = ApplicabilityDomain() ad = ApplicabilityDomain()
ad.model = mlrr ad.model = mlrr
# ad.uuid = mlrr.uuid # ad.uuid = mlrr.uuid
ad.name = f"AD for {mlrr.name}" ad.name = f"AD for {mlrr.get_name()}"
ad.num_neighbours = num_neighbours ad.num_neighbours = num_neighbours
ad.reliability_threshold = reliability_threshold ad.reliability_threshold = reliability_threshold
ad.local_compatibilty_threshold = local_compatibility_threshold ad.local_compatibilty_threshold = local_compatibility_threshold
@ -3346,7 +3426,7 @@ class EnviFormer(PackageBasedModel):
) )
for smiles in smiles_list for smiles in smiles_list
] ]
logger.info(f"Submitting {canon_smiles} to {self.name}") logger.info(f"Submitting {canon_smiles} to {self.get_name()}")
start = datetime.now() start = datetime.now()
products_list = self.model.predict_batch(canon_smiles) products_list = self.model.predict_batch(canon_smiles)
end = datetime.now() end = datetime.now()
@ -3503,7 +3583,7 @@ class EnviFormer(PackageBasedModel):
root_node = p.root_nodes root_node = p.root_nodes
if len(root_node) > 1: if len(root_node) > 1:
logging.warning( logging.warning(
f"Pathway {p.name} has more than one root compound, only {root_node[0]} will be used" f"Pathway {p.get_name()} has more than one root compound, only {root_node[0]} will be used"
) )
root_node = ".".join( root_node = ".".join(
[ [
@ -3623,7 +3703,7 @@ class EnviFormer(PackageBasedModel):
pathways.append(pathway) pathways.append(pathway)
else: else:
logging.warning( logging.warning(
f"No root compound in pathway {pathway.name}, excluding from multigen evaluation" f"No root compound in pathway {pathway.get_name()}, excluding from multigen evaluation"
) )
# build lookup reaction -> {uuid1, uuid2} for overlap check # build lookup reaction -> {uuid1, uuid2} for overlap check
@ -3751,11 +3831,21 @@ class Scenario(EnviPathModel):
return new_s return new_s
@transaction.atomic @transaction.atomic
def add_additional_information(self, data: "EnviPyModel"): def add_additional_information(self, data: "EnviPyModel") -> str:
"""
Add additional information to this scenario.
Args:
data: EnviPyModel instance to add
Returns:
str: UUID of the created item
"""
cls_name = data.__class__.__name__ cls_name = data.__class__.__name__
# Clean for potential XSS hidden in the additional information fields. # Clean for potential XSS hidden in the additional information fields.
ai_data = json.loads(nh3.clean(data.model_dump_json()).strip()) ai_data = json.loads(nh3.clean(data.model_dump_json()).strip())
ai_data["uuid"] = f"{uuid4()}" generated_uuid = str(uuid4())
ai_data["uuid"] = generated_uuid
if cls_name not in self.additional_information: if cls_name not in self.additional_information:
self.additional_information[cls_name] = [] self.additional_information[cls_name] = []
@ -3763,6 +3853,51 @@ class Scenario(EnviPathModel):
self.additional_information[cls_name].append(ai_data) self.additional_information[cls_name].append(ai_data)
self.save() self.save()
return generated_uuid
@transaction.atomic
def update_additional_information(self, ai_uuid: str, data: "EnviPyModel") -> None:
"""
Update existing additional information by UUID.
Args:
ai_uuid: UUID of the item to update
data: EnviPyModel instance with new data
Raises:
ValueError: If item with given UUID not found or type mismatch
"""
found_type = None
found_idx = -1
# Find the item by UUID
for type_name, items in self.additional_information.items():
for idx, item_data in enumerate(items):
if item_data.get("uuid") == ai_uuid:
found_type = type_name
found_idx = idx
break
if found_type:
break
if found_type is None:
raise ValueError(f"Additional information with UUID {ai_uuid} not found")
# Verify the model type matches (prevent type changes)
new_type = data.__class__.__name__
if new_type != found_type:
raise ValueError(
f"Cannot change type from {found_type} to {new_type}. "
f"Delete and create a new item instead."
)
# Update the item data, preserving UUID
ai_data = json.loads(nh3.clean(data.model_dump_json()).strip())
ai_data["uuid"] = ai_uuid
self.additional_information[found_type][found_idx] = ai_data
self.save()
@transaction.atomic @transaction.atomic
def remove_additional_information(self, ai_uuid): def remove_additional_information(self, ai_uuid):
found_type = None found_type = None
@ -3777,9 +3912,9 @@ class Scenario(EnviPathModel):
if found_type is not None and found_idx >= 0: if found_type is not None and found_idx >= 0:
if len(self.additional_information[found_type]) == 1: if len(self.additional_information[found_type]) == 1:
del self.additional_information[k] del self.additional_information[found_type]
else: else:
self.additional_information[k].pop(found_idx) self.additional_information[found_type].pop(found_idx)
self.save() self.save()
else: else:
raise ValueError(f"Could not find additional information with uuid {ai_uuid}") raise ValueError(f"Could not find additional information with uuid {ai_uuid}")
@ -3802,7 +3937,7 @@ class Scenario(EnviPathModel):
self.save() self.save()
def get_additional_information(self): def get_additional_information(self):
from envipy_additional_information import NAME_MAPPING from envipy_additional_information import registry
for k, vals in self.additional_information.items(): for k, vals in self.additional_information.items():
if k == "enzyme": if k == "enzyme":
@ -3810,14 +3945,31 @@ class Scenario(EnviPathModel):
for v in vals: for v in vals:
# Per default additional fields are ignored # Per default additional fields are ignored
MAPPING = {c.__name__: c for c in NAME_MAPPING.values()} MAPPING = {c.__name__: c for c in registry.list_models().values()}
try:
inst = MAPPING[k](**v) inst = MAPPING[k](**v)
except Exception as e:
logger.error(f"Could not load additional information {k}: {e}")
if s.SENTRY_ENABLED:
from sentry_sdk import capture_exception
capture_exception(e)
# Add uuid to uniquely identify objects for manipulation # Add uuid to uniquely identify objects for manipulation
if "uuid" in v: if "uuid" in v:
inst.__dict__["uuid"] = v["uuid"] inst.__dict__["uuid"] = v["uuid"]
yield inst yield inst
def related_pathways(self):
scens = [self]
if self.parent is not None:
scens.append(self.parent)
return Pathway.objects.filter(
scenarios__in=scens, package__reviewed=True, package=self.package
).distinct()
class UserSettingPermission(Permission): class UserSettingPermission(Permission):
uuid = models.UUIDField( uuid = models.UUIDField(
@ -4024,6 +4176,6 @@ class JobLog(TimeStampedModel):
return self.task_result return self.task_result
def is_result_downloadable(self): def is_result_downloadable(self):
downloadable = ["batch_predict"] downloadable = ["batch_predict", "identify_missing_rules"]
return self.job_name in downloadable return self.job_name in downloadable

View File

@ -7,6 +7,7 @@ from uuid import uuid4
from celery import shared_task from celery import shared_task
from celery.utils.functional import LRUCache from celery.utils.functional import LRUCache
from django.conf import settings as s from django.conf import settings as s
from django.core.mail import EmailMultiAlternatives
from django.utils import timezone from django.utils import timezone
from epdb.logic import SPathway from epdb.logic import SPathway
@ -73,7 +74,31 @@ def predict_simple(model_pk: int, smiles: str):
@shared_task(queue="background") @shared_task(queue="background")
def send_registration_mail(user_pk: int): def send_registration_mail(user_pk: int):
pass u = User.objects.get(id=user_pk)
tpl = """Welcome {username}!,
Thank you for your interest in enviPath.
The public system is intended for non-commercial use only.
We will review your account details and usually activate your account within 24 hours.
Once activated, you will be notified by email.
If we have any questions, we will contact you at this email address.
Best regards,
enviPath team"""
msg = EmailMultiAlternatives(
"Your enviPath account",
tpl.format(username=u.username),
"admin@envipath.org",
[u.email],
bcc=["admin@envipath.org"],
)
msg.send(fail_silently=False)
@shared_task(bind=True, queue="model") @shared_task(bind=True, queue="model")

View File

@ -209,5 +209,4 @@ urlpatterns = [
re_path(r"^contact$", v.static_contact_support, name="contact_support"), re_path(r"^contact$", v.static_contact_support, name="contact_support"),
re_path(r"^careers$", v.static_careers, name="careers"), re_path(r"^careers$", v.static_careers, name="careers"),
re_path(r"^cite$", v.static_cite, name="cite"), re_path(r"^cite$", v.static_cite, name="cite"),
re_path(r"^legal$", v.static_legal, name="legal"),
] ]

View File

@ -1,22 +1,21 @@
import json import json
import logging import logging
from typing import Any, Dict, List
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List
import nh3 import nh3
from django.conf import settings as s from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import BadRequest, PermissionDenied from django.core.exceptions import BadRequest, PermissionDenied
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from envipy_additional_information import NAME_MAPPING
from oauth2_provider.decorators import protected_resource from oauth2_provider.decorators import protected_resource
from sentry_sdk import capture_exception
from utilities.chem import FormatConverter, IndigoUtils from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required from utilities.decorators import package_permission_required
from utilities.misc import HTMLGenerator
from .logic import ( from .logic import (
EPDBURLParser, EPDBURLParser,
@ -34,6 +33,7 @@ from .models import (
EnviFormer, EnviFormer,
EnzymeLink, EnzymeLink,
EPModel, EPModel,
ExpansionSchemeChoice,
ExternalDatabase, ExternalDatabase,
ExternalIdentifier, ExternalIdentifier,
Group, Group,
@ -51,7 +51,6 @@ from .models import (
SimpleAmbitRule, SimpleAmbitRule,
User, User,
UserPackagePermission, UserPackagePermission,
ExpansionSchemeChoice,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -238,6 +237,15 @@ def register(request):
try: try:
u = UserManager.create_user(username, email, password) u = UserManager.create_user(username, email, password)
logger.info(f"Created user {u.username} ({u.pk})") logger.info(f"Created user {u.username} ({u.pk})")
try:
from .tasks import send_registration_mail
send_registration_mail.delay(u.pk)
except Exception as e:
logger.error(f"Failed to send registration mail to {u.email}: {e}")
capture_exception(e)
except Exception: except Exception:
context["message"] = "Registration failed! Couldn't create User Account." context["message"] = "Registration failed! Couldn't create User Account."
return render(request, "static/login.html", context) return render(request, "static/login.html", context)
@ -339,7 +347,7 @@ def breadcrumbs(
{"Package": s.SERVER_URL + "/package"}, {"Package": s.SERVER_URL + "/package"},
] ]
if first_level_object is not None: if first_level_object is not None:
bread.append({first_level_object.name: first_level_object.url}) bread.append({first_level_object.get_name(): first_level_object.url})
if second_level_namespace is not None: if second_level_namespace is not None:
bread.append( bread.append(
@ -350,7 +358,7 @@ def breadcrumbs(
) )
if second_level_object is not None: if second_level_object is not None:
bread.append({second_level_object.name: second_level_object.url}) bread.append({second_level_object.get_name(): second_level_object.url})
if third_level_namespace is not None: if third_level_namespace is not None:
bread.append( bread.append(
@ -361,7 +369,7 @@ def breadcrumbs(
) )
if third_level_object is not None: if third_level_object is not None:
bread.append({third_level_object.name: third_level_object.url}) bread.append({third_level_object.get_name(): third_level_object.url})
return bread return bread
@ -462,7 +470,7 @@ def package_predict_pathway(request, package_uuid):
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Predict Pathway" context["title"] = f"enviPath - {current_package.get_name()} - Predict Pathway"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
return render(request, "predict_pathway.html", context) return render(request, "predict_pathway.html", context)
@ -475,6 +483,10 @@ def packages(request):
context = get_base_context(request) context = get_base_context(request)
context["title"] = "enviPath - Packages" context["title"] = "enviPath - Packages"
context["meta"]["current_package"] = context["meta"]["user"].default_package context["meta"]["current_package"] = context["meta"]["user"].default_package
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Package": s.SERVER_URL + "/package"},
]
# Context for paginated template # Context for paginated template
context["entity_type"] = "package" context["entity_type"] = "package"
@ -529,6 +541,10 @@ def compounds(request):
context = get_base_context(request) context = get_base_context(request)
context["title"] = "enviPath - Compounds" context["title"] = "enviPath - Compounds"
context["meta"]["current_package"] = context["meta"]["user"].default_package context["meta"]["current_package"] = context["meta"]["user"].default_package
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Compound": s.SERVER_URL + "/compound"},
]
# Context for paginated template # Context for paginated template
context["entity_type"] = "compound" context["entity_type"] = "compound"
@ -759,7 +775,7 @@ def package_models(request, package_uuid):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Models" context["title"] = f"enviPath - {current_package.get_name()} - Models"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "model" context["object_type"] = "model"
@ -781,7 +797,7 @@ def package_models(request, package_uuid):
return JsonResponse( return JsonResponse(
{ {
"objects": [ "objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in ( for pw in (
reviewed_model_qs if current_package.reviewed else unreviewed_model_qs reviewed_model_qs if current_package.reviewed else unreviewed_model_qs
) )
@ -880,7 +896,7 @@ def package_models(request, package_uuid):
def package_model(request, package_uuid, model_uuid): def package_model(request, package_uuid, model_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_model = EPModel.objects.get(package=current_package, uuid=model_uuid) current_model = get_object_or_404(EPModel, package=current_package, uuid=model_uuid)
if request.method == "GET": if request.method == "GET":
classify = request.GET.get("classify", False) classify = request.GET.get("classify", False)
@ -931,7 +947,7 @@ def package_model(request, package_uuid, model_uuid):
return JsonResponse(app_domain_assessment, safe=False) return JsonResponse(app_domain_assessment, safe=False)
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_model.name}" context["title"] = f"enviPath - {current_package.get_name()} - {current_model.get_name()}"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "model" context["object_type"] = "model"
@ -1009,7 +1025,7 @@ def package(request, package_uuid):
if request.method == "GET": if request.method == "GET":
if request.GET.get("export", False) == "true": if request.GET.get("export", False) == "true":
filename = f"{current_package.name.replace(' ', '_')}_{current_package.uuid}.json" filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json"
pack_json = PackageManager.export_package( pack_json = PackageManager.export_package(
current_package, include_models=False, include_external_identifiers=False current_package, include_models=False, include_external_identifiers=False
) )
@ -1019,7 +1035,7 @@ def package(request, package_uuid):
return response return response
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name}" context["title"] = f"enviPath - {current_package.get_name()}"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "package" context["object_type"] = "package"
@ -1033,6 +1049,7 @@ def package(request, package_uuid):
"user_id", flat=True "user_id", flat=True
) )
) )
users = users.filter(is_active=True)
group_perms = GroupPackagePermission.objects.filter(package=current_package) group_perms = GroupPackagePermission.objects.filter(package=current_package)
groups = Group.objects.exclude( groups = Group.objects.exclude(
@ -1056,7 +1073,7 @@ def package(request, package_uuid):
if current_user.default_package == current_package: if current_user.default_package == current_package:
return error( return error(
request, request,
f'Package "{current_package.name}" is the default and cannot be deleted!', f'Package "{current_package.get_name()}" is the default and cannot be deleted!',
"You cannot delete the default package. If you want to delete this package you have to set another default package first.", "You cannot delete the default package. If you want to delete this package you have to set another default package first.",
) )
@ -1154,7 +1171,7 @@ def package_compounds(request, package_uuid):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Compounds" context["title"] = f"enviPath - {current_package.get_name()} - Compounds"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "compound" context["object_type"] = "compound"
@ -1179,7 +1196,7 @@ def package_compounds(request, package_uuid):
return JsonResponse( return JsonResponse(
{ {
"objects": [ "objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in ( for pw in (
reviewed_compound_qs reviewed_compound_qs
if current_package.reviewed if current_package.reviewed
@ -1212,11 +1229,13 @@ def package_compounds(request, package_uuid):
def package_compound(request, package_uuid, compound_uuid): def package_compound(request, package_uuid, compound_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_compound = Compound.objects.get(package=current_package, uuid=compound_uuid) current_compound = get_object_or_404(Compound, package=current_package, uuid=compound_uuid)
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_compound.name}" context["title"] = (
f"enviPath - {current_package.get_name()} - {current_compound.get_name()}"
)
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "compound" context["object_type"] = "compound"
@ -1300,7 +1319,7 @@ def package_compound_structures(request, package_uuid, compound_uuid):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = ( context["title"] = (
f"enviPath - {current_package.name} - {current_compound.name} - Structures" f"enviPath - {current_package.get_name()} - {current_compound.get_name()} - Structures"
) )
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
@ -1309,7 +1328,7 @@ def package_compound_structures(request, package_uuid, compound_uuid):
current_package, "compound", current_compound, "structure" current_package, "compound", current_compound, "structure"
) )
context["entity_type"] = "structure" context["entity_type"] = "structure"
context["page_title"] = f"{current_compound.name} - Structures" context["page_title"] = f"{current_compound.get_name()} - Structures"
context["api_endpoint"] = ( context["api_endpoint"] = (
f"/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/" f"/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/"
) )
@ -1346,15 +1365,23 @@ def package_compound_structures(request, package_uuid, compound_uuid):
def package_compound_structure(request, package_uuid, compound_uuid, structure_uuid): def package_compound_structure(request, package_uuid, compound_uuid, structure_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_compound = Compound.objects.get(package=current_package, uuid=compound_uuid) current_compound = get_object_or_404(Compound, package=current_package, uuid=compound_uuid)
current_structure = CompoundStructure.objects.get( current_structure = get_object_or_404(
compound=current_compound, uuid=structure_uuid CompoundStructure, compound=current_compound, uuid=structure_uuid
) )
if request.method == "GET": if request.method == "GET":
is_image_request = request.GET.get("image")
if is_image_request:
if is_image_request == "svg":
return HttpResponse(current_structure.as_svg, content_type="image/svg+xml")
else:
return HttpResponseBadRequest("Currently only SVG as image formate supported!")
context = get_base_context(request) context = get_base_context(request)
context["title"] = ( context["title"] = (
f"enviPath - {current_package.name} - {current_compound.name} - {current_structure.name}" f"enviPath - {current_package.get_name()} - {current_compound.get_name()} - {current_structure.get_name()}"
) )
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
@ -1460,7 +1487,7 @@ def package_rules(request, package_uuid):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Rules" context["title"] = f"enviPath - {current_package.get_name()} - Rules"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "rule" context["object_type"] = "rule"
@ -1482,7 +1509,7 @@ def package_rules(request, package_uuid):
return JsonResponse( return JsonResponse(
{ {
"objects": [ "objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in ( for pw in (
reviewed_rule_qs if current_package.reviewed else unreviewed_rule_qs reviewed_rule_qs if current_package.reviewed else unreviewed_rule_qs
) )
@ -1534,7 +1561,7 @@ def package_rules(request, package_uuid):
def package_rule(request, package_uuid, rule_uuid): def package_rule(request, package_uuid, rule_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_rule = Rule.objects.get(package=current_package, uuid=rule_uuid) current_rule = get_object_or_404(Rule, package=current_package, uuid=rule_uuid)
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
@ -1572,7 +1599,7 @@ def package_rule(request, package_uuid, rule_uuid):
content_type="image/svg+xml", content_type="image/svg+xml",
) )
context["title"] = f"enviPath - {current_package.name} - {current_rule.name}" context["title"] = f"enviPath - {current_package.get_name()} - {current_rule.get_name()}"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "rule" context["object_type"] = "rule"
@ -1645,7 +1672,7 @@ def package_rule_enzymelink(request, package_uuid, rule_uuid, enzymelink_uuid):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_rule.name}" context["title"] = f"enviPath - {current_package.get_name()} - {current_rule.get_name()}"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "enzyme" context["object_type"] = "enzyme"
@ -1668,7 +1695,7 @@ def package_reactions(request, package_uuid):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Reactions" context["title"] = f"enviPath - {current_package.get_name()} - Reactions"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "reaction" context["object_type"] = "reaction"
@ -1692,7 +1719,7 @@ def package_reactions(request, package_uuid):
return JsonResponse( return JsonResponse(
{ {
"objects": [ "objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in ( for pw in (
reviewed_reaction_qs reviewed_reaction_qs
if current_package.reviewed if current_package.reviewed
@ -1729,11 +1756,13 @@ def package_reactions(request, package_uuid):
def package_reaction(request, package_uuid, reaction_uuid): def package_reaction(request, package_uuid, reaction_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_reaction = Reaction.objects.get(package=current_package, uuid=reaction_uuid) current_reaction = get_object_or_404(Reaction, package=current_package, uuid=reaction_uuid)
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_reaction.name}" context["title"] = (
f"enviPath - {current_package.get_name()} - {current_reaction.get_name()}"
)
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "reaction" context["object_type"] = "reaction"
@ -1816,7 +1845,7 @@ def package_pathways(request, package_uuid):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Pathways" context["title"] = f"enviPath - {current_package.get_name()} - Pathways"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "pathway" context["object_type"] = "pathway"
@ -1838,7 +1867,7 @@ def package_pathways(request, package_uuid):
return JsonResponse( return JsonResponse(
{ {
"objects": [ "objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in ( for pw in (
reviewed_pathway_qs reviewed_pathway_qs
if current_package.reviewed if current_package.reviewed
@ -1908,6 +1937,7 @@ def package_pathways(request, package_uuid):
limit = 1 limit = 1
pw.setting = prediction_setting pw.setting = prediction_setting
pw.kv.update({"status": "running"})
pw.save() pw.save()
from .tasks import dispatch, predict from .tasks import dispatch, predict
@ -1924,7 +1954,9 @@ def package_pathways(request, package_uuid):
def package_pathway(request, package_uuid, pathway_uuid): def package_pathway(request, package_uuid, pathway_uuid):
current_user: User = _anonymous_or_real(request) current_user: User = _anonymous_or_real(request)
current_package: Package = PackageManager.get_package_by_id(current_user, package_uuid) current_package: Package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway: Pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid) current_pathway: Pathway = get_object_or_404(
Pathway, package=current_package, uuid=pathway_uuid
)
if request.method == "GET": if request.method == "GET":
if request.GET.get("last_modified", False): if request.GET.get("last_modified", False):
@ -1942,7 +1974,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
) )
if request.GET.get("download", False) == "true": if request.GET.get("download", False) == "true":
filename = f"{current_pathway.name.replace(' ', '_')}_{current_pathway.uuid}.csv" filename = f"{current_pathway.get_name().replace(' ', '_')}_{current_pathway.uuid}.csv"
csv_pw = current_pathway.to_csv() csv_pw = current_pathway.to_csv()
response = HttpResponse(csv_pw, content_type="text/csv") response = HttpResponse(csv_pw, content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{filename}"' response["Content-Disposition"] = f'attachment; filename="{filename}"'
@ -1962,7 +1994,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
current_user, identify_missing_rules, [current_pathway.pk], rule_package.pk current_user, identify_missing_rules, [current_pathway.pk], rule_package.pk
) )
filename = f"{current_pathway.name.replace(' ', '_')}_{current_pathway.uuid}.csv" filename = f"{current_pathway.get_name().replace(' ', '_')}_{current_pathway.uuid}.csv"
response = HttpResponse(res, content_type="text/csv") response = HttpResponse(res, content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{filename}"' response["Content-Disposition"] = f'attachment; filename="{filename}"'
@ -1985,7 +2017,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
).get(uuid=pathway_uuid) ).get(uuid=pathway_uuid)
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_pathway.name}" context["title"] = f"enviPath - {current_package.get_name()} - {current_pathway.get_name()}"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "pathway" context["object_type"] = "pathway"
@ -1997,9 +2029,9 @@ def package_pathway(request, package_uuid, pathway_uuid):
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Package": s.SERVER_URL + "/package"}, {"Package": s.SERVER_URL + "/package"},
{current_package.name: current_package.url}, {current_package.get_name(): current_package.url},
{"Pathway": current_package.url + "/pathway"}, {"Pathway": current_package.url + "/pathway"},
{current_pathway.name: current_pathway.url}, {current_pathway.get_name(): current_pathway.url},
] ]
return render(request, "objects/pathway.html", context) return render(request, "objects/pathway.html", context)
@ -2057,6 +2089,9 @@ def package_pathway(request, package_uuid, pathway_uuid):
if node_url: if node_url:
n = current_pathway.get_node(node_url) n = current_pathway.get_node(node_url)
current_pathway.kv.update({"status": "running"})
current_pathway.save()
from .tasks import dispatch, predict from .tasks import dispatch, predict
dispatch( dispatch(
@ -2079,20 +2114,22 @@ def package_pathway(request, package_uuid, pathway_uuid):
def package_pathway_nodes(request, package_uuid, pathway_uuid): def package_pathway_nodes(request, package_uuid, pathway_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid) current_pathway = get_object_or_404(Pathway, package=current_package, uuid=pathway_uuid)
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_pathway.name} - Nodes" context["title"] = (
f"enviPath - {current_package.get_name()} - {current_pathway.get_name()} - Nodes"
)
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "node" context["object_type"] = "node"
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Package": s.SERVER_URL + "/package"}, {"Package": s.SERVER_URL + "/package"},
{current_package.name: current_package.url}, {current_package.get_name(): current_package.url},
{"Pathway": current_package.url + "/pathway"}, {"Pathway": current_package.url + "/pathway"},
{current_pathway.name: current_pathway.url}, {current_pathway.get_name(): current_pathway.url},
{"Node": current_pathway.url + "/node"}, {"Node": current_pathway.url + "/node"},
] ]
@ -2108,7 +2145,7 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
return JsonResponse( return JsonResponse(
{ {
"objects": [ "objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in ( for pw in (
reviewed_node_qs if current_package.reviewed else unreviewed_node_qs reviewed_node_qs if current_package.reviewed else unreviewed_node_qs
) )
@ -2138,8 +2175,8 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid): def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid) current_pathway = get_object_or_404(Pathway, package=current_package, uuid=pathway_uuid)
current_node = Node.objects.get(pathway=current_pathway, uuid=node_uuid) current_node = get_object_or_404(Node, pathway=current_pathway, uuid=node_uuid)
if request.method == "GET": if request.method == "GET":
is_image_request = request.GET.get("image") is_image_request = request.GET.get("image")
@ -2182,7 +2219,7 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
return HttpResponse(svg_data, content_type="image/svg+xml") return HttpResponse(svg_data, content_type="image/svg+xml")
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_pathway.name}" context["title"] = f"enviPath - {current_package.get_name()} - {current_pathway.get_name()}"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "pathway" context["object_type"] = "pathway"
@ -2190,11 +2227,11 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Package": s.SERVER_URL + "/package"}, {"Package": s.SERVER_URL + "/package"},
{current_package.name: current_package.url}, {current_package.get_name(): current_package.url},
{"Pathway": current_package.url + "/pathway"}, {"Pathway": current_package.url + "/pathway"},
{current_pathway.name: current_pathway.url}, {current_pathway.get_name(): current_pathway.url},
{"Node": current_pathway.url + "/node"}, {"Node": current_pathway.url + "/node"},
{current_node.name: current_node.url}, {current_node.get_name(): current_node.url},
] ]
context["node"] = current_node context["node"] = current_node
@ -2243,20 +2280,22 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
def package_pathway_edges(request, package_uuid, pathway_uuid): def package_pathway_edges(request, package_uuid, pathway_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid) current_pathway = get_object_or_404(Pathway, package=current_package, uuid=pathway_uuid)
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_pathway.name} - Edges" context["title"] = (
f"enviPath - {current_package.get_name()} - {current_pathway.get_name()} - Edges"
)
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "edge" context["object_type"] = "edge"
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Package": s.SERVER_URL + "/package"}, {"Package": s.SERVER_URL + "/package"},
{current_package.name: current_package.url}, {current_package.get_name(): current_package.url},
{"Pathway": current_package.url + "/pathway"}, {"Pathway": current_package.url + "/pathway"},
{current_pathway.name: current_pathway.url}, {current_pathway.get_name(): current_pathway.url},
{"Edge": current_pathway.url + "/edge"}, {"Edge": current_pathway.url + "/edge"},
] ]
@ -2272,7 +2311,7 @@ def package_pathway_edges(request, package_uuid, pathway_uuid):
return JsonResponse( return JsonResponse(
{ {
"objects": [ "objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in ( for pw in (
reviewed_edge_qs if current_package.reviewed else unreviewed_edge_qs reviewed_edge_qs if current_package.reviewed else unreviewed_edge_qs
) )
@ -2312,8 +2351,8 @@ def package_pathway_edges(request, package_uuid, pathway_uuid):
def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid): def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid) current_pathway = get_object_or_404(Pathway, package=current_package, uuid=pathway_uuid)
current_edge = Edge.objects.get(pathway=current_pathway, uuid=edge_uuid) current_edge = get_object_or_404(Edge, pathway=current_pathway, uuid=edge_uuid)
if request.method == "GET": if request.method == "GET":
is_image_request = request.GET.get("image") is_image_request = request.GET.get("image")
@ -2324,7 +2363,7 @@ def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
context = get_base_context(request) context = get_base_context(request)
context["title"] = ( context["title"] = (
f"enviPath - {current_package.name} - {current_pathway.name} - {current_edge.edge_label.name}" f"enviPath - {current_package.get_name()} - {current_pathway.get_name()} - {current_edge.edge_label.get_name()}"
) )
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
@ -2377,11 +2416,11 @@ def package_scenarios(request, package_uuid):
"all", False "all", False
): ):
scens = Scenario.objects.filter(package=current_package).order_by("name") scens = Scenario.objects.filter(package=current_package).order_by("name")
res = [{"name": s_.name, "url": s_.url, "uuid": s_.uuid} for s_ in scens] res = [{"name": s_.get_name(), "url": s_.url, "uuid": s_.uuid} for s_ in scens]
return JsonResponse(res, safe=False) return JsonResponse(res, safe=False)
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Scenarios" context["title"] = f"enviPath - {current_package.get_name()} - Scenarios"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "scenario" context["object_type"] = "scenario"
@ -2405,7 +2444,7 @@ def package_scenarios(request, package_uuid):
return JsonResponse( return JsonResponse(
{ {
"objects": [ "objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in ( for pw in (
reviewed_scenario_qs reviewed_scenario_qs
if current_package.reviewed if current_package.reviewed
@ -2415,72 +2454,7 @@ def package_scenarios(request, package_uuid):
} }
) )
from envipy_additional_information import (
SEDIMENT_ADDITIONAL_INFORMATION,
SLUDGE_ADDITIONAL_INFORMATION,
SOIL_ADDITIONAL_INFORMATION,
)
context["scenario_types"] = {
"Soil Data": {
"name": "soil",
"widgets": [
HTMLGenerator.generate_html(ai, prefix=f"soil_{0}")
for ai in [x for sv in SOIL_ADDITIONAL_INFORMATION.values() for x in sv]
],
},
"Sludge Data": {
"name": "sludge",
"widgets": [
HTMLGenerator.generate_html(ai, prefix=f"sludge_{0}")
for ai in [x for sv in SLUDGE_ADDITIONAL_INFORMATION.values() for x in sv]
],
},
"Water-Sediment System Data": {
"name": "sediment",
"widgets": [
HTMLGenerator.generate_html(ai, prefix=f"sediment_{0}")
for ai in [x for sv in SEDIMENT_ADDITIONAL_INFORMATION.values() for x in sv]
],
},
}
context["sludge_additional_information"] = SLUDGE_ADDITIONAL_INFORMATION
context["soil_additional_information"] = SOIL_ADDITIONAL_INFORMATION
context["sediment_additional_information"] = SEDIMENT_ADDITIONAL_INFORMATION
return render(request, "collections/scenarios_paginated.html", context) return render(request, "collections/scenarios_paginated.html", context)
elif request.method == "POST":
log_post_params(request)
scenario_name = request.POST.get("scenario-name")
scenario_description = request.POST.get("scenario-description")
scenario_date_year = request.POST.get("scenario-date-year")
scenario_date_month = request.POST.get("scenario-date-month")
scenario_date_day = request.POST.get("scenario-date-day")
scenario_date = scenario_date_year
if scenario_date_month is not None and scenario_date_month.strip() != "":
scenario_date += f"-{int(scenario_date_month):02d}"
if scenario_date_day is not None and scenario_date_day.strip() != "":
scenario_date += f"-{int(scenario_date_day):02d}"
scenario_type = request.POST.get("scenario-type")
additional_information = HTMLGenerator.build_models(request.POST.dict())
additional_information = [x for sv in additional_information.values() for x in sv]
new_scen = Scenario.create(
current_package,
name=scenario_name,
description=scenario_description,
scenario_date=scenario_date,
scenario_type=scenario_type,
additional_information=additional_information,
)
return redirect(new_scen.url)
else: else:
return HttpResponseNotAllowed( return HttpResponseNotAllowed(
[ [
@ -2493,33 +2467,25 @@ def package_scenarios(request, package_uuid):
def package_scenario(request, package_uuid, scenario_uuid): def package_scenario(request, package_uuid, scenario_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_scenario = Scenario.objects.get(package=current_package, uuid=scenario_uuid) current_scenario = get_object_or_404(Scenario, package=current_package, uuid=scenario_uuid)
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_scenario.name}" context["title"] = (
f"enviPath - {current_package.get_name()} - {current_scenario.get_name()}"
)
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "scenario" context["object_type"] = "scenario"
context["breadcrumbs"] = breadcrumbs(current_package, "scenario", current_scenario) context["breadcrumbs"] = breadcrumbs(current_package, "scenario", current_scenario)
context["scenario"] = current_scenario context["scenario"] = current_scenario
# Get scenarios that have current_scenario as a parent
context["children"] = current_scenario.scenario_set.order_by("name")
available_add_infs = [] # Note: Modals now fetch schemas and data from API endpoints
for add_inf in NAME_MAPPING.values(): # Keeping these for backwards compatibility if needed elsewhere
available_add_infs.append( # They are no longer used by the main scenario template
{
"display_name": add_inf.property_name(None),
"name": add_inf.__name__,
"widget": HTMLGenerator.generate_html(add_inf, prefix=f"{0}"),
}
)
context["available_additional_information"] = available_add_infs
context["update_widgets"] = [
HTMLGenerator.generate_html(ai, prefix=f"{i}")
for i, ai in enumerate(current_scenario.get_additional_information())
]
return render(request, "objects/scenario.html", context) return render(request, "objects/scenario.html", context)
@ -2539,30 +2505,39 @@ def package_scenario(request, package_uuid, scenario_uuid):
current_scenario.save() current_scenario.save()
return redirect(current_scenario.url) return redirect(current_scenario.url)
elif hidden == "set-additional-information": elif hidden == "set-additional-information":
ais = HTMLGenerator.build_models(request.POST.dict()) # Legacy POST handler - no longer used, modals use API endpoints
return HttpResponseBadRequest(
if s.DEBUG: "This endpoint is deprecated. Please use the API endpoints."
logger.info(ais) )
elif hidden == "add-additional-information":
current_scenario.set_additional_information(ais) # Legacy POST handler - no longer used, modals use API endpoints
return redirect(current_scenario.url) return HttpResponseBadRequest(
elif hidden == "add-additional-information": "This endpoint is deprecated. Please use the API endpoints."
ais = HTMLGenerator.build_models(request.POST.dict())
if len(ais.keys()) != 1:
raise ValueError(
"Only one additional information field can be added at a time."
) )
ai = list(ais.values())[0][0]
if s.DEBUG:
logger.info(ais)
current_scenario.add_additional_information(ai)
return redirect(current_scenario.url)
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
new_scenario_name = request.POST.get("scenario-name")
if new_scenario_name is not None:
new_scenario_name = nh3.clean(new_scenario_name, tags=s.ALLOWED_HTML_TAGS).strip()
if new_scenario_name:
current_scenario.name = new_scenario_name
new_scenario_description = request.POST.get("scenario-description")
if new_scenario_description is not None:
new_scenario_description = nh3.clean(
new_scenario_description, tags=s.ALLOWED_HTML_TAGS
).strip()
if new_scenario_description:
current_scenario.description = new_scenario_description
if any([new_scenario_name, new_scenario_description]):
current_scenario.save()
return redirect(current_scenario.url)
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
else: else:
@ -2628,9 +2603,11 @@ def user(request, user_uuid):
if is_hidden_method and request.POST["hidden"] == "request-api-token": if is_hidden_method and request.POST["hidden"] == "request-api-token":
name = request.POST.get("name", "No Name") name = request.POST.get("name", "No Name")
valid_for = min(max(int(request.POST.get("valid-for", 90)), 1), 90) expires_days = min(max(int(request.POST.get("valid-for", 90)), 1), 90)
token, raw_token = APIToken.create_token(request.user, name=name, valid_for=valid_for) token, raw_token = APIToken.create_token(
request.user, name=name, expires_days=expires_days
)
return JsonResponse( return JsonResponse(
{"raw_token": raw_token, "token": {"id": token.id, "name": token.name}} {"raw_token": raw_token, "token": {"id": token.id, "name": token.name}}
@ -2712,19 +2689,21 @@ def group(request, group_uuid):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_group.name}" context["title"] = f"enviPath - {current_group.get_name()}"
context["object_type"] = "group" context["object_type"] = "group"
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Group": s.SERVER_URL + "/group"}, {"Group": s.SERVER_URL + "/group"},
{current_group.name: current_group.url}, {current_group.get_name(): current_group.url},
] ]
context["group"] = current_group context["group"] = current_group
context["users"] = UserManager.get_users_lp().exclude( context["users"] = (
id__in=current_group.user_member.all() UserManager.get_users_lp()
.exclude(id__in=current_group.user_member.all())
.filter(is_active=True)
) )
context["groups"] = ( context["groups"] = (
GroupManager.get_groups_lp() GroupManager.get_groups_lp()
@ -2778,10 +2757,17 @@ def settings(request):
context["object_type"] = "setting" context["object_type"] = "setting"
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Group": s.SERVER_URL + "/setting"}, {"Setting": s.SERVER_URL + "/setting"},
] ]
context["objects"] = SettingManager.get_all_settings(current_user) # Context for paginated template
context["entity_type"] = "setting"
context["api_endpoint"] = "/api/v1/settings/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "settings"
context["list_mode"] = "combined"
return render(request, "collections/settings_paginated.html", context)
return render(request, "collections/objects_list.html", context) return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
@ -2861,7 +2847,26 @@ def settings(request):
def setting(request, setting_uuid): def setting(request, setting_uuid):
pass current_user = _anonymous_or_real(request)
current_setting = SettingManager.get_setting_by_id(current_user, setting_uuid)
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_setting.get_name()}"
context["object_type"] = "setting"
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Setting": s.SERVER_URL + "/setting"},
{f"{current_setting.get_name()}": current_setting.url},
]
context["setting"] = current_setting
context["current_object"] = current_setting
return render(request, "objects/setting.html", context)
else:
return HttpResponseNotAllowed(["GET"])
def jobs(request): def jobs(request):
@ -2902,8 +2907,8 @@ def jobs(request):
target_package = PackageManager.create_package( target_package = PackageManager.create_package(
current_user, current_user,
f"Autogenerated Package for Pathway Engineering of {pathway_to_engineer.name}", f"Autogenerated Package for Pathway Engineering of {pathway_to_engineer.get_name()}",
f"This Package was generated automatically for the engineering Task of {pathway_to_engineer.name}.", f"This Package was generated automatically for the engineering Task of {pathway_to_engineer.get_name()}.",
) )
from .tasks import dispatch, engineer_pathways from .tasks import dispatch, engineer_pathways
@ -2957,7 +2962,7 @@ def jobs(request):
"This Package was generated automatically for the batch prediction task.", "This Package was generated automatically for the batch prediction task.",
) )
from .tasks import dispatch, batch_predict from .tasks import batch_predict, dispatch
res = dispatch( res = dispatch(
current_user, current_user,
@ -2995,6 +3000,8 @@ def job(request, job_uuid):
if job.job_name == "batch_predict": if job.job_name == "batch_predict":
filename = f"{job.job_name.replace(' ', '_')}_{job.task_id}.csv" filename = f"{job.job_name.replace(' ', '_')}_{job.task_id}.csv"
elif job.job_name == "identify_missing_rules":
filename = f"{job.job_name.replace(' ', '_')}_{job.task_id}.csv"
else: else:
raise BadRequest("Result is not downloadable!") raise BadRequest("Result is not downloadable!")

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -76,9 +76,7 @@ def migration(request):
open(s.BASE_DIR / "fixtures" / "migration_status_per_rule.json") open(s.BASE_DIR / "fixtures" / "migration_status_per_rule.json")
) )
else: else:
BBD = Package.objects.get( BBD = Package.objects.get(uuid="32de3cf4-e3e6-4168-956e-32fa5ddb0ce1")
url="http://localhost:8000/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1"
)
ALL_SMILES = [ ALL_SMILES = [
cs.smiles for cs in CompoundStructure.objects.filter(compound__package=BBD) cs.smiles for cs in CompoundStructure.objects.filter(compound__package=BBD)
] ]
@ -147,7 +145,7 @@ def migration_detail(request, package_uuid, rule_uuid):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
BBD = Package.objects.get(name="EAWAG-BBD") BBD = Package.objects.get(uuid="32de3cf4-e3e6-4168-956e-32fa5ddb0ce1")
STRUCTURES = CompoundStructure.objects.filter(compound__package=BBD) STRUCTURES = CompoundStructure.objects.filter(compound__package=BBD)
rule = Rule.objects.get(package=BBD, uuid=rule_uuid) rule = Rule.objects.get(package=BBD, uuid=rule_uuid)

View File

@ -8,14 +8,14 @@
"build": "tailwindcss -i static/css/input.css -o static/css/output.css --minify" "build": "tailwindcss -i static/css/input.css -o static/css/output.css --minify"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/cli": "^4.1.16", "@tailwindcss/cli": "^4.1.18",
"@tailwindcss/postcss": "^4.1.16", "@tailwindcss/postcss": "^4.1.18",
"daisyui": "^5.4.3", "daisyui": "^5.5.14",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.2", "prettier": "^3.7.4",
"prettier-plugin-jinja-template": "^2.1.0", "prettier-plugin-jinja-template": "^2.1.0",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.1.16" "tailwindcss": "^4.1.18"
}, },
"keywords": [ "keywords": [
"django", "django",

200
pnpm-lock.yaml generated
View File

@ -9,29 +9,29 @@ importers:
.: .:
devDependencies: devDependencies:
'@tailwindcss/cli': '@tailwindcss/cli':
specifier: ^4.1.16 specifier: ^4.1.18
version: 4.1.16 version: 4.1.18
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4.1.16 specifier: ^4.1.18
version: 4.1.16 version: 4.1.18
daisyui: daisyui:
specifier: ^5.4.3 specifier: ^5.5.14
version: 5.4.3 version: 5.5.14
postcss: postcss:
specifier: ^8.5.6 specifier: ^8.5.6
version: 8.5.6 version: 8.5.6
prettier: prettier:
specifier: ^3.6.2 specifier: ^3.7.4
version: 3.6.2 version: 3.7.4
prettier-plugin-jinja-template: prettier-plugin-jinja-template:
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0(prettier@3.6.2) version: 2.1.0(prettier@3.7.4)
prettier-plugin-tailwindcss: prettier-plugin-tailwindcss:
specifier: ^0.7.1 specifier: ^0.7.2
version: 0.7.1(prettier@3.6.2) version: 0.7.2(prettier@3.7.4)
tailwindcss: tailwindcss:
specifier: ^4.1.16 specifier: ^4.1.18
version: 4.1.16 version: 4.1.18
packages: packages:
@ -137,69 +137,69 @@ packages:
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@tailwindcss/cli@4.1.16': '@tailwindcss/cli@4.1.18':
resolution: {integrity: sha512-dsnANPrh2ZooHyZ/8uJhc9ecpcYtufToc21NY09NS9vF16rxPCjJ8dP7TUAtPqlUJTHSmRkN2hCdoYQIlgh4fw==} resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==}
hasBin: true hasBin: true
'@tailwindcss/node@4.1.16': '@tailwindcss/node@4.1.18':
resolution: {integrity: sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==} resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
'@tailwindcss/oxide-android-arm64@4.1.16': '@tailwindcss/oxide-android-arm64@4.1.18':
resolution: {integrity: sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==} resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@tailwindcss/oxide-darwin-arm64@4.1.16': '@tailwindcss/oxide-darwin-arm64@4.1.18':
resolution: {integrity: sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==} resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.16': '@tailwindcss/oxide-darwin-x64@4.1.18':
resolution: {integrity: sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==} resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.1.16': '@tailwindcss/oxide-freebsd-x64@4.1.18':
resolution: {integrity: sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==} resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
resolution: {integrity: sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==} resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.16': '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
resolution: {integrity: sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==} resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.1.16': '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.1.16': '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.1.16': '@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.1.16': '@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [wasm32] cpu: [wasm32]
bundledDependencies: bundledDependencies:
@ -210,31 +210,31 @@ packages:
- '@emnapi/wasi-threads' - '@emnapi/wasi-threads'
- tslib - tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.1.16': '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
resolution: {integrity: sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==} resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.16': '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
resolution: {integrity: sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==} resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@tailwindcss/oxide@4.1.16': '@tailwindcss/oxide@4.1.18':
resolution: {integrity: sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==} resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
'@tailwindcss/postcss@4.1.16': '@tailwindcss/postcss@4.1.18':
resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==} resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
braces@3.0.3: braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
daisyui@5.4.3: daisyui@5.5.14:
resolution: {integrity: sha512-dfDCJnN4utErGoWfElgdEE252FtfHV9Mxj5Dq1+JzUq3nVkluWdF3JYykP0Xy/y/yArnPXYztO1tLNCow3kjmg==} resolution: {integrity: sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==}
detect-libc@1.0.3: detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
@ -245,8 +245,8 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
enhanced-resolve@5.18.3: enhanced-resolve@5.18.4:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
fill-range@7.1.1: fill-range@7.1.1:
@ -377,8 +377,8 @@ packages:
peerDependencies: peerDependencies:
prettier: ^3.0.0 prettier: ^3.0.0
prettier-plugin-tailwindcss@0.7.1: prettier-plugin-tailwindcss@0.7.2:
resolution: {integrity: sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==} resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==}
engines: {node: '>=20.19'} engines: {node: '>=20.19'}
peerDependencies: peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*' '@ianvs/prettier-plugin-sort-imports': '*'
@ -432,8 +432,8 @@ packages:
prettier-plugin-svelte: prettier-plugin-svelte:
optional: true optional: true
prettier@3.6.2: prettier@3.7.4:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
@ -441,8 +441,8 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
tailwindcss@4.1.16: tailwindcss@4.1.18:
resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==} resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
tapable@2.3.0: tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
@ -535,96 +535,96 @@ snapshots:
'@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-ia32': 2.5.1
'@parcel/watcher-win32-x64': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1
'@tailwindcss/cli@4.1.16': '@tailwindcss/cli@4.1.18':
dependencies: dependencies:
'@parcel/watcher': 2.5.1 '@parcel/watcher': 2.5.1
'@tailwindcss/node': 4.1.16 '@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.16 '@tailwindcss/oxide': 4.1.18
enhanced-resolve: 5.18.3 enhanced-resolve: 5.18.4
mri: 1.2.0 mri: 1.2.0
picocolors: 1.1.1 picocolors: 1.1.1
tailwindcss: 4.1.16 tailwindcss: 4.1.18
'@tailwindcss/node@4.1.16': '@tailwindcss/node@4.1.18':
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.18.3 enhanced-resolve: 5.18.4
jiti: 2.6.1 jiti: 2.6.1
lightningcss: 1.30.2 lightningcss: 1.30.2
magic-string: 0.30.21 magic-string: 0.30.21
source-map-js: 1.2.1 source-map-js: 1.2.1
tailwindcss: 4.1.16 tailwindcss: 4.1.18
'@tailwindcss/oxide-android-arm64@4.1.16': '@tailwindcss/oxide-android-arm64@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.16': '@tailwindcss/oxide-darwin-arm64@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-darwin-x64@4.1.16': '@tailwindcss/oxide-darwin-x64@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.16': '@tailwindcss/oxide-freebsd-x64@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.16': '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.16': '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.16': '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.16': '@tailwindcss/oxide-linux-x64-musl@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-wasm32-wasi@4.1.16': '@tailwindcss/oxide-wasm32-wasi@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.16': '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
optional: true optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.16': '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
optional: true optional: true
'@tailwindcss/oxide@4.1.16': '@tailwindcss/oxide@4.1.18':
optionalDependencies: optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.16 '@tailwindcss/oxide-android-arm64': 4.1.18
'@tailwindcss/oxide-darwin-arm64': 4.1.16 '@tailwindcss/oxide-darwin-arm64': 4.1.18
'@tailwindcss/oxide-darwin-x64': 4.1.16 '@tailwindcss/oxide-darwin-x64': 4.1.18
'@tailwindcss/oxide-freebsd-x64': 4.1.16 '@tailwindcss/oxide-freebsd-x64': 4.1.18
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.16 '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.16 '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
'@tailwindcss/oxide-linux-arm64-musl': 4.1.16 '@tailwindcss/oxide-linux-arm64-musl': 4.1.18
'@tailwindcss/oxide-linux-x64-gnu': 4.1.16 '@tailwindcss/oxide-linux-x64-gnu': 4.1.18
'@tailwindcss/oxide-linux-x64-musl': 4.1.16 '@tailwindcss/oxide-linux-x64-musl': 4.1.18
'@tailwindcss/oxide-wasm32-wasi': 4.1.16 '@tailwindcss/oxide-wasm32-wasi': 4.1.18
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.16 '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.16 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18
'@tailwindcss/postcss@4.1.16': '@tailwindcss/postcss@4.1.18':
dependencies: dependencies:
'@alloc/quick-lru': 5.2.0 '@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.1.16 '@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.16 '@tailwindcss/oxide': 4.1.18
postcss: 8.5.6 postcss: 8.5.6
tailwindcss: 4.1.16 tailwindcss: 4.1.18
braces@3.0.3: braces@3.0.3:
dependencies: dependencies:
fill-range: 7.1.1 fill-range: 7.1.1
daisyui@5.4.3: {} daisyui@5.5.14: {}
detect-libc@1.0.3: {} detect-libc@1.0.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
enhanced-resolve@5.18.3: enhanced-resolve@5.18.4:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.0 tapable: 2.3.0
@ -719,19 +719,19 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
prettier-plugin-jinja-template@2.1.0(prettier@3.6.2): prettier-plugin-jinja-template@2.1.0(prettier@3.7.4):
dependencies: dependencies:
prettier: 3.6.2 prettier: 3.7.4
prettier-plugin-tailwindcss@0.7.1(prettier@3.6.2): prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
dependencies: dependencies:
prettier: 3.6.2 prettier: 3.7.4
prettier@3.6.2: {} prettier@3.7.4: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
tailwindcss@4.1.16: {} tailwindcss@4.1.18: {}
tapable@2.3.0: {} tapable@2.3.0: {}

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'

View File

@ -19,6 +19,7 @@ dependencies = [
"envipy-plugins", "envipy-plugins",
"epam-indigo>=1.30.1", "epam-indigo>=1.30.1",
"gunicorn>=23.0.0", "gunicorn>=23.0.0",
"jsonref>=1.1.0",
"networkx>=3.4.2", "networkx>=3.4.2",
"psycopg2-binary>=2.9.10", "psycopg2-binary>=2.9.10",
"python-dotenv>=1.1.0", "python-dotenv>=1.1.0",
@ -35,7 +36,7 @@ dependencies = [
[tool.uv.sources] [tool.uv.sources]
enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.4" } enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.4" }
envipy-plugins = { git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git", rev = "v0.1.0" } envipy-plugins = { git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git", rev = "v0.1.0" }
envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.1.7" } envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.4.2" }
envipy-ambit = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" } envipy-ambit = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" }
[project.optional-dependencies] [project.optional-dependencies]
@ -85,8 +86,15 @@ build = { sequence = [
], help = "Build frontend assets and collect static files" } ], help = "Build frontend assets and collect static files" }
# Database tasks # Database tasks
db-up = { cmd = "docker compose -f docker-compose.dev.yml up -d", help = "Start PostgreSQL database using Docker Compose" } db-up = { cmd = "docker compose -p envipath -f docker-compose.dev.yml up -d", help = "Start PostgreSQL database using Docker Compose" }
db-down = { cmd = "docker compose -f docker-compose.dev.yml down", help = "Stop PostgreSQL database" } db-down = { cmd = "docker compose -p envipath -f docker-compose.dev.yml down", help = "Stop PostgreSQL database" }
# Celery tasks
celery = { cmd = "celery -A envipath worker -l INFO -Q predict,model,background", help = "Start Celery worker for async task processing" }
celery-dev = { sequence = [
"db-up",
"celery",
], help = "Start database and Celery worker" }
# Frontend tasks # Frontend tasks
js-deps = { cmd = "uv run python scripts/pnpm_wrapper.py install", help = "Install frontend dependencies" } js-deps = { cmd = "uv run python scripts/pnpm_wrapper.py install", help = "Install frontend dependencies" }

View File

@ -11,6 +11,8 @@ import signal
import subprocess import subprocess
import sys import sys
import time import time
import os
import dotenv
def find_pnpm(): def find_pnpm():
@ -65,6 +67,7 @@ class DevServerManager:
bufsize=1, bufsize=1,
) )
self.processes.append((process, description)) self.processes.append((process, description))
print(" ".join(command))
print(f"✓ Started {description} (PID: {process.pid})") print(f"✓ Started {description} (PID: {process.pid})")
return process return process
except Exception as e: except Exception as e:
@ -146,6 +149,7 @@ class DevServerManager:
def main(): def main():
"""Main entry point.""" """Main entry point."""
dotenv.load_dotenv()
manager = DevServerManager() manager = DevServerManager()
manager.register_cleanup() manager.register_cleanup()
@ -174,9 +178,10 @@ def main():
time.sleep(1) time.sleep(1)
# Start Django dev server # Start Django dev server
port = os.environ.get("DJANGO_PORT", "8000")
django_process = manager.start_process( django_process = manager.start_process(
["uv", "run", "python", "manage.py", "runserver"], ["uv", "run", "python", "manage.py", "runserver", f"0:{port}"],
"Django server", f"Django server on port {port}",
shell=False, shell=False,
) )

View File

@ -0,0 +1,378 @@
/**
* Alpine.js Schema Renderer Component
*
* Renders forms dynamically from JSON Schema with RJSF format support.
* Supports uiSchema for widget hints, labels, help text, and field ordering.
*
* Usage:
* <div x-data="schemaRenderer({
* rjsf: { schema: {...}, uiSchema: {...}, formData: {...}, groups: [...] },
* data: { interval: { start: 20, end: 25 } },
* mode: 'view', // 'view' | 'edit'
* endpoint: '/api/v1/scenario/{uuid}/information/temperature/'
* })">
*/
document.addEventListener("alpine:init", () => {
// Global validation error store with context scoping
Alpine.store('validationErrors', {
errors: {},
// Set errors for a specific context (UUID) or globally (no context)
setErrors(errors, context = null) {
if (context) {
// Namespace all field names with context prefix
const namespacedErrors = {};
Object.entries(errors).forEach(([field, messages]) => {
const key = `${context}.${field}`;
namespacedErrors[key] = messages;
});
// Merge into existing errors (preserves other contexts)
this.errors = { ...this.errors, ...namespacedErrors };
} else {
// No context - merge as-is for backward compatibility
this.errors = { ...this.errors, ...errors };
}
},
// Clear errors for a specific context or all errors
clearErrors(context = null) {
if (context) {
// Clear only errors for this context
const newErrors = {};
const prefix = `${context}.`;
Object.keys(this.errors).forEach(key => {
if (!key.startsWith(prefix)) {
newErrors[key] = this.errors[key];
}
});
this.errors = newErrors;
} else {
// Clear all errors
this.errors = {};
}
},
// Clear a specific field, optionally within a context
clearField(fieldName, context = null) {
const key = context ? `${context}.${fieldName}` : fieldName;
if (this.errors[key]) {
delete this.errors[key];
// Trigger reactivity by creating new object
this.errors = { ...this.errors };
}
},
// Check if a field has errors, optionally within a context
hasError(fieldName, context = null) {
const key = context ? `${context}.${fieldName}` : fieldName;
return Array.isArray(this.errors[key]) && this.errors[key].length > 0;
},
// Get errors for a field, optionally within a context
getErrors(fieldName, context = null) {
const key = context ? `${context}.${fieldName}` : fieldName;
return this.errors[key] || [];
}
});
Alpine.data("schemaRenderer", (options = {}) => ({
schema: null,
uiSchema: {},
data: {},
mode: options.mode || "view", // 'view' | 'edit'
endpoint: options.endpoint || "",
loading: false,
error: null,
context: options.context || null, // UUID for items, null for single forms
debugErrors:
options.debugErrors ??
(typeof window !== "undefined" &&
window.location?.search?.includes("debugErrors=1")),
async init() {
if (options.schemaUrl) {
try {
this.loading = true;
const res = await fetch(options.schemaUrl);
if (!res.ok) {
throw new Error(`Failed to load schema: ${res.statusText}`);
}
const rjsf = await res.json();
// RJSF format: {schema, uiSchema, formData, groups}
if (!rjsf.schema) {
throw new Error("Invalid RJSF format: missing schema property");
}
this.schema = rjsf.schema;
this.uiSchema = rjsf.uiSchema || {};
this.data = options.data
? JSON.parse(JSON.stringify(options.data))
: rjsf.formData || {};
} catch (err) {
this.error = err.message;
console.error("Error loading schema:", err);
} finally {
this.loading = false;
}
} else if (options.rjsf) {
// Direct RJSF object passed
if (!options.rjsf.schema) {
throw new Error("Invalid RJSF format: missing schema property");
}
this.schema = options.rjsf.schema;
this.uiSchema = options.rjsf.uiSchema || {};
this.data = options.data
? JSON.parse(JSON.stringify(options.data))
: options.rjsf.formData || {};
}
// Initialize data from formData or options
if (!this.data || Object.keys(this.data).length === 0) {
this.data = {};
}
// Ensure all schema fields are properly initialized
if (this.schema && this.schema.properties) {
for (const [key, propSchema] of Object.entries(
this.schema.properties,
)) {
const widget = this.getWidget(key, propSchema);
if (widget === "interval") {
// Ensure interval fields are objects with start/end
if (!this.data[key] || typeof this.data[key] !== "object") {
this.data[key] = { start: null, end: null };
} else {
// Ensure start and end exist
if (this.data[key].start === undefined)
this.data[key].start = null;
if (this.data[key].end === undefined) this.data[key].end = null;
}
} else if (widget === "timeseries-table") {
// Ensure timeseries fields are arrays
if (!this.data[key] || !Array.isArray(this.data[key])) {
this.data[key] = [];
}
} else if (this.data[key] === undefined) {
// ONLY initialize if truly undefined, not just falsy
// This preserves empty strings, null, 0, false as valid values
if (propSchema.type === "boolean") {
this.data[key] = false;
} else if (
propSchema.type === "number" ||
propSchema.type === "integer"
) {
this.data[key] = null;
} else if (propSchema.enum) {
// For select fields, use null to show placeholder
this.data[key] = null;
} else {
this.data[key] = "";
}
}
// If data[key] exists (even if empty string or null), don't overwrite
}
}
// UX: Clear field errors when fields change (with context)
if (this.mode === "edit" && this.schema?.properties) {
Object.keys(this.schema.properties).forEach((key) => {
this.$watch(
`data.${key}`,
() => {
Alpine.store('validationErrors').clearField(key, this.context);
},
{ deep: true },
);
});
}
},
getWidget(fieldName, fieldSchema) {
// Defensive check: ensure fieldSchema is provided
if (!fieldSchema) return "text";
try {
// Check uiSchema first (RJSF format)
if (
this.uiSchema &&
this.uiSchema[fieldName] &&
this.uiSchema[fieldName]["ui:widget"]
) {
return this.uiSchema[fieldName]["ui:widget"];
}
// Check for interval type (object with start/end properties)
if (
fieldSchema.type === "object" &&
fieldSchema.properties &&
fieldSchema.properties.start &&
fieldSchema.properties.end
) {
return "interval";
}
// Check for measurements array type (timeseries-table widget)
if (
fieldSchema.type === "array" &&
fieldSchema.items?.properties?.timestamp &&
fieldSchema.items?.properties?.value
) {
return "timeseries-table";
}
// Infer from JSON Schema type
if (fieldSchema.enum) return "select";
if (fieldSchema.type === "number" || fieldSchema.type === "integer")
return "number";
if (fieldSchema.type === "boolean") return "checkbox";
return "text";
} catch (e) {
// Fallback to text widget if anything fails
console.warn("Error in getWidget:", e);
return "text";
}
},
getLabel(fieldName, fieldSchema) {
// Check uiSchema (RJSF format)
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]["ui:label"]) {
return this.uiSchema[fieldName]["ui:label"];
}
// Default: format field name
return fieldName
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
},
getFieldOrder() {
try {
// Get ordered list of field names based on ui:order
if (!this.schema || !this.schema.properties) return [];
// Only include fields that have UI configs
const fields = Object.keys(this.schema.properties).filter(
(fieldName) => this.uiSchema && this.uiSchema[fieldName],
);
// Sort by ui:order if available
return fields.sort((a, b) => {
const orderA = this.uiSchema[a]?.["ui:order"] || "999";
const orderB = this.uiSchema[b]?.["ui:order"] || "999";
return parseInt(orderA) - parseInt(orderB);
});
} catch (e) {
// Return empty array if anything fails to prevent errors
console.warn("Error in getFieldOrder:", e);
return [];
}
},
hasTimeseriesField() {
try {
// Check if any field in the schema is a timeseries-table widget
if (!this.schema || !this.schema.properties) {
return false;
}
return Object.keys(this.schema.properties).some((fieldName) => {
const fieldSchema = this.schema.properties[fieldName];
if (!fieldSchema) return false;
return this.getWidget(fieldName, fieldSchema) === "timeseries-table";
});
} catch (e) {
// Return false if anything fails to prevent errors
console.warn("Error in hasTimeseriesField:", e);
return false;
}
},
async submit() {
if (!this.endpoint) {
console.error("No endpoint specified for submission");
return;
}
this.loading = true;
this.error = null;
try {
const csrftoken =
document.querySelector("[name=csrf-token]")?.content || "";
const res = await fetch(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrftoken,
},
body: JSON.stringify(this.data),
});
if (!res.ok) {
let errorData;
try {
errorData = await res.json();
} catch {
errorData = { error: res.statusText };
}
// Handle validation errors (field-level)
Alpine.store('validationErrors').clearErrors();
// Try to parse structured error response
let parsedError = errorData;
// If error is a JSON string, parse it
if (
typeof errorData.error === "string" &&
errorData.error.startsWith("{")
) {
parsedError = JSON.parse(errorData.error);
}
if (parsedError.detail && Array.isArray(parsedError.detail)) {
// Pydantic validation errors format: [{loc: ['field'], msg: '...', type: '...'}]
const fieldErrors = {};
for (const err of parsedError.detail) {
const field =
err.loc && err.loc.length > 0
? err.loc[err.loc.length - 1]
: "root";
if (!fieldErrors[field]) {
fieldErrors[field] = [];
}
fieldErrors[field].push(
err.msg || err.message || "Validation error",
);
}
Alpine.store('validationErrors').setErrors(fieldErrors);
throw new Error(
"Validation failed. Please check the fields below.",
);
} else {
// General error
throw new Error(
parsedError.error ||
parsedError.detail ||
`Request failed: ${res.statusText}`,
);
}
}
// Clear errors on success
Alpine.store('validationErrors').clearErrors();
const result = await res.json();
return result;
} catch (err) {
this.error = err.message;
throw err;
} finally {
this.loading = false;
}
},
}));
});

View File

@ -0,0 +1,462 @@
/**
* Alpine.js Widget Components for Schema Forms
*
* Centralized widget component definitions for dynamic form rendering.
* Each widget receives explicit parameters instead of context object for better traceability.
*/
document.addEventListener("alpine:init", () => {
// Base widget factory with common functionality
const baseWidget = (
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context = null // NEW: context for error namespacing
) => ({
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context, // Store context for use in templates
// Field schema access
get fieldSchema() {
return this.schema?.properties?.[this.fieldName] || {};
},
// Common metadata
get label() {
// Check uiSchema first (RJSF format)
if (this.uiSchema?.[this.fieldName]?.["ui:label"]) {
return this.uiSchema[this.fieldName]["ui:label"];
}
// Fall back to schema title
if (this.fieldSchema.title) {
return this.fieldSchema.title;
}
// Default: format field name
return this.fieldName
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
},
get helpText() {
return this.fieldSchema.description || "";
},
// Field-level unit extraction from uiSchema (RJSF format)
get unit() {
return this.uiSchema?.[this.fieldName]?.["ui:unit"] || null;
},
// Mode checks
get isViewMode() {
return this.mode === "view";
},
get isEditMode() {
return this.mode === "edit";
},
});
// Text widget
Alpine.data(
"textWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// Textarea widget
Alpine.data(
"textareaWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// Number widget with unit support
Alpine.data(
"numberWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName];
},
set value(v) {
this.data[this.fieldName] =
v === "" || v === null ? null : parseFloat(v);
},
get hasValue() {
return (
this.value !== null && this.value !== undefined && this.value !== ""
);
},
// Format value with unit for view mode
get displayValue() {
if (!this.hasValue) return "—";
return this.unit ? `${this.value} ${this.unit}` : String(this.value);
},
}),
);
// Select widget
Alpine.data(
"selectWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
get options() {
return this.fieldSchema.enum || [];
},
}),
);
// Checkbox widget
Alpine.data(
"checkboxWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get checked() {
return !!this.data[this.fieldName];
},
set checked(v) {
this.data[this.fieldName] = v;
},
}),
);
// Interval widget with unit support
Alpine.data(
"intervalWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get start() {
return this.data[this.fieldName]?.start ?? null;
},
set start(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].start =
v === "" || v === null ? null : parseFloat(v);
},
get end() {
return this.data[this.fieldName]?.end ?? null;
},
set end(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].end =
v === "" || v === null ? null : parseFloat(v);
},
// Format interval with unit for view mode
get displayValue() {
const s = this.start,
e = this.end;
const unitStr = this.unit ? ` ${this.unit}` : "";
if (s !== null && e !== null) return `${s} ${e}${unitStr}`;
if (s !== null) return `${s}${unitStr}`;
if (e !== null) return `${e}${unitStr}`;
return "—";
},
get isSameValue() {
return this.start !== null && this.start === this.end;
},
// Validation: start must be <= end (client-side)
get hasValidationError() {
if (this.isViewMode) return false;
const s = this.start;
const e = this.end;
// Only validate if both values are provided
if (
s !== null &&
e !== null &&
typeof s === "number" &&
typeof e === "number"
) {
return s > e;
}
return false;
},
}),
);
// PubMed link widget
Alpine.data(
"pubmedWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
get pubmedUrl() {
return this.value
? `https://pubmed.ncbi.nlm.nih.gov/${this.value}`
: null;
},
}),
);
// Compound link widget
Alpine.data(
"compoundWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// TimeSeries table widget
Alpine.data(
"timeseriesTableWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
chartInstance: null,
// Getter/setter for measurements array
get measurements() {
return this.data[this.fieldName] || [];
},
set measurements(v) {
this.data[this.fieldName] = v;
},
// Get description from sibling field
get description() {
return this.data?.description || "";
},
// Get method from sibling field
get method() {
return this.data?.method || "";
},
// Computed property for chart options
get chartOptions() {
return {
measurements: this.measurements,
xAxisLabel: this.data?.x_axis_label || "Time",
yAxisLabel: this.data?.y_axis_label || "Value",
xAxisUnit: this.data?.x_axis_unit || "",
yAxisUnit: this.data?.y_axis_unit || "",
};
},
// Add new measurement
addMeasurement() {
if (!this.data[this.fieldName]) {
this.data[this.fieldName] = [];
}
this.data[this.fieldName].push({
timestamp: null,
value: null,
error: null,
note: "",
});
},
// Remove measurement by index
removeMeasurement(index) {
if (
this.data[this.fieldName] &&
Array.isArray(this.data[this.fieldName])
) {
this.data[this.fieldName].splice(index, 1);
}
},
// Update specific measurement field
updateMeasurement(index, field, value) {
if (this.data[this.fieldName] && this.data[this.fieldName][index]) {
if (field === "timestamp" || field === "value" || field === "error") {
// Parse all numeric fields (timestamp is days as float)
this.data[this.fieldName][index][field] =
value === "" || value === null ? null : parseFloat(value);
} else {
// Store other fields as-is
this.data[this.fieldName][index][field] = value;
}
}
},
// Format timestamp for display (timestamp is numeric days as float)
formatTimestamp(timestamp) {
return timestamp ?? "";
},
// Sort by timestamp (numeric days)
sortByTimestamp() {
if (
this.data[this.fieldName] &&
Array.isArray(this.data[this.fieldName])
) {
this.data[this.fieldName].sort((a, b) => {
const tsA = a.timestamp ?? Infinity;
const tsB = b.timestamp ?? Infinity;
return tsA - tsB;
});
}
},
// Chart lifecycle methods (delegates to TimeSeriesChart utility)
initChart() {
if (!this.isViewMode || !window.Chart || !window.TimeSeriesChart)
return;
const canvas = this.$refs?.chartCanvas;
if (!canvas) return;
this.destroyChart();
if (this.measurements.length === 0) return;
this.chartInstance = window.TimeSeriesChart.create(
canvas,
this.chartOptions,
);
},
updateChart() {
if (!this.chartInstance || !this.isViewMode) return;
window.TimeSeriesChart.update(
this.chartInstance,
this.measurements,
this.chartOptions,
);
},
destroyChart() {
if (this.chartInstance) {
window.TimeSeriesChart.destroy(this.chartInstance);
this.chartInstance = null;
}
},
// Alpine lifecycle hooks
init() {
if (this.isViewMode && window.Chart) {
// Use $nextTick to ensure DOM is ready
this.$nextTick(() => {
this.initChart();
});
// Watch measurements array for changes and update chart
this.$watch("data." + this.fieldName, () => {
if (this.chartInstance) {
this.updateChart();
}
});
}
},
}),
);
});

View File

@ -21,6 +21,7 @@ document.addEventListener('alpine:init', () => {
Alpine.data('pathwayViewer', (config) => ({ Alpine.data('pathwayViewer', (config) => ({
status: config.status, status: config.status,
modified: config.modified, modified: config.modified,
modifiedDate: null,
statusUrl: config.statusUrl, statusUrl: config.statusUrl,
emptyDueToThreshold: config.emptyDueToThreshold === "True", emptyDueToThreshold: config.emptyDueToThreshold === "True",
showUpdateNotice: false, showUpdateNotice: false,
@ -39,6 +40,8 @@ document.addEventListener('alpine:init', () => {
}, },
init() { init() {
this.modifiedDate = this.parseDate(this.modified);
if (this.status === 'running') { if (this.status === 'running') {
this.startPolling(); this.startPolling();
} }
@ -66,26 +69,39 @@ document.addEventListener('alpine:init', () => {
this.showEmptyDueToThresholdNotice = true; this.showEmptyDueToThresholdNotice = true;
} }
if (data.modified > this.modified) { const nextModifiedDate = this.parseDate(data.modified);
if (!this.emptyDueToThreshold) { const modifiedChanged = this.hasNewerTimestamp(nextModifiedDate, this.modifiedDate);
const statusChanged = data.status !== this.status;
if ((modifiedChanged || statusChanged) && !this.emptyDueToThreshold) {
this.showUpdateNotice = true; this.showUpdateNotice = true;
this.updateMessage = this.getUpdateMessage(data.status); this.updateMessage = this.getUpdateMessage(data.status, modifiedChanged, statusChanged);
}
} }
if (data.status !== 'running') { this.modified = data.modified;
this.modifiedDate = nextModifiedDate;
this.status = data.status; this.status = data.status;
if (this.pollInterval) {
if (data.status !== 'running' && this.pollInterval) {
clearInterval(this.pollInterval); clearInterval(this.pollInterval);
this.pollInterval = null; this.pollInterval = null;
} }
}
} catch (err) { } catch (err) {
console.error('Polling error:', err); console.error('Polling error:', err);
} }
}, },
getUpdateMessage(status) { getUpdateMessage(status, modifiedChanged, statusChanged) {
// Prefer explicit status change messaging, otherwise fall back to modified change copy
if (statusChanged) {
if (status === 'completed') {
return 'Prediction completed. Reload the page to see the updated Pathway.';
}
if (status === 'failed') {
return 'Prediction failed. Reload the page to see the latest status.';
}
}
let msg = 'Prediction '; let msg = 'Prediction ';
if (status === 'running') { if (status === 'running') {
@ -99,6 +115,18 @@ document.addEventListener('alpine:init', () => {
return msg; return msg;
}, },
parseDate(dateString) {
// Normalize "YYYY-MM-DD HH:mm:ss" into an ISO-compatible string to avoid locale issues
if (!dateString) return null;
return new Date(dateString.replace(' ', 'T'));
},
hasNewerTimestamp(nextDate, currentDate) {
if (!nextDate) return false;
if (!currentDate) return true;
return nextDate.getTime() > currentDate.getTime();
},
reloadPage() { reloadPage() {
location.reload(); location.reload();
} }

View File

@ -0,0 +1,419 @@
/**
* Unified API client for Additional Information endpoints
* Provides consistent error handling, logging, and CRUD operations
*/
window.AdditionalInformationApi = {
// Configuration
_debug: false,
/**
* Enable or disable debug logging
* @param {boolean} enabled - Whether to enable debug mode
*/
setDebug(enabled) {
this._debug = enabled;
},
/**
* Internal logging helper
* @private
*/
_log(action, data) {
if (this._debug) {
console.log(`[AdditionalInformationApi] ${action}:`, data);
}
},
//FIXME: this has the side effect of users not being able to explicitly set an empty string for a field.
/**
* Remove empty strings from payload recursively
* @param {any} value
* @returns {any}
*/
sanitizePayload(value) {
if (Array.isArray(value)) {
return value
.map((item) => this.sanitizePayload(item))
.filter((item) => item !== "");
}
if (value && typeof value === "object") {
const cleaned = {};
for (const [key, item] of Object.entries(value)) {
if (item === "") continue;
cleaned[key] = this.sanitizePayload(item);
}
return cleaned;
}
return value;
},
/**
* Get CSRF token from meta tag
* @returns {string} CSRF token
*/
getCsrfToken() {
return document.querySelector("[name=csrf-token]")?.content || "";
},
/**
* Build headers for API requests
* @private
*/
_buildHeaders(includeContentType = true) {
const headers = {
"X-CSRFToken": this.getCsrfToken(),
};
if (includeContentType) {
headers["Content-Type"] = "application/json";
}
return headers;
},
/**
* Handle API response with consistent error handling
* @private
*/
async _handleResponse(response, action) {
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { error: response.statusText };
}
// Try to parse the error if it's a JSON string
let parsedError = errorData;
const errorStr = errorData.detail || errorData.error;
if (typeof errorStr === "string" && errorStr.startsWith("{")) {
try {
parsedError = JSON.parse(errorStr);
} catch {
// Not JSON, use as-is
}
}
// If it's a structured validation error, throw with field errors
if (parsedError.type === "validation_error" && parsedError.field_errors) {
this._log(`${action} VALIDATION ERROR`, parsedError);
const error = new Error(parsedError.message || "Validation failed");
error.fieldErrors = parsedError.field_errors;
error.isValidationError = true;
throw error;
}
// General error
const errorMsg =
parsedError.message ||
parsedError.error ||
parsedError.detail ||
`${action} failed: ${response.statusText}`;
this._log(`${action} ERROR`, {
status: response.status,
error: errorMsg,
});
throw new Error(errorMsg);
}
const data = await response.json();
this._log(`${action} SUCCESS`, data);
return data;
},
/**
* Load all available schemas
* @returns {Promise<Object>} Object with schema definitions
*/
async loadSchemas() {
this._log("loadSchemas", "Starting...");
const response = await fetch("/api/v1/information/schema/");
return this._handleResponse(response, "loadSchemas");
},
/**
* Load additional information items for a scenario
* @param {string} scenarioUuid - UUID of the scenario
* @returns {Promise<Array>} Array of additional information items
*/
async loadItems(scenarioUuid) {
this._log("loadItems", { scenarioUuid });
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/`,
);
return this._handleResponse(response, "loadItems");
},
/**
* Load both schemas and items in parallel
* @param {string} scenarioUuid - UUID of the scenario
* @returns {Promise<{schemas: Object, items: Array}>}
*/
async loadSchemasAndItems(scenarioUuid) {
this._log("loadSchemasAndItems", { scenarioUuid });
const [schemas, items] = await Promise.all([
this.loadSchemas(),
this.loadItems(scenarioUuid),
]);
return { schemas, items };
},
/**
* Create new additional information for a scenario
* @param {string} scenarioUuid - UUID of the scenario
* @param {string} modelName - Name/type of the additional information model
* @param {Object} data - Data for the new item
* @returns {Promise<{status: string, uuid: string}>}
*/
async createItem(scenarioUuid, modelName, data) {
const sanitizedData = this.sanitizePayload(data);
this._log("createItem", { scenarioUuid, modelName, data: sanitizedData });
// Normalize model name to lowercase
const normalizedName = modelName.toLowerCase();
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/${normalizedName}/`,
{
method: "POST",
headers: this._buildHeaders(),
body: JSON.stringify(sanitizedData),
},
);
return this._handleResponse(response, "createItem");
},
/**
* Delete additional information from a scenario
* @param {string} scenarioUuid - UUID of the scenario
* @param {string} itemUuid - UUID of the item to delete
* @returns {Promise<{status: string}>}
*/
async deleteItem(scenarioUuid, itemUuid) {
this._log("deleteItem", { scenarioUuid, itemUuid });
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/item/${itemUuid}/`,
{
method: "DELETE",
headers: this._buildHeaders(false),
},
);
return this._handleResponse(response, "deleteItem");
},
/**
* Update existing additional information
* Tries PATCH first, falls back to delete+recreate if not supported
* @param {string} scenarioUuid - UUID of the scenario
* @param {Object} item - Item object with uuid, type, and data properties
* @returns {Promise<{status: string, uuid: string}>}
*/
async updateItem(scenarioUuid, item) {
const sanitizedData = this.sanitizePayload(item.data);
this._log("updateItem", {
scenarioUuid,
item: { ...item, data: sanitizedData },
});
const { uuid, type } = item;
// Try PATCH first (preferred method - preserves UUID)
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/item/${uuid}/`,
{
method: "PATCH",
headers: this._buildHeaders(),
body: JSON.stringify(sanitizedData),
},
);
if (response.status === 405) {
// PATCH not supported, fall back to delete+recreate
this._log(
"updateItem",
"PATCH not supported, falling back to delete+recreate",
);
await this.deleteItem(scenarioUuid, uuid);
return await this.createItem(scenarioUuid, type, sanitizedData);
}
return this._handleResponse(response, "updateItem");
},
/**
* Update multiple items sequentially to avoid race conditions
* @param {string} scenarioUuid - UUID of the scenario
* @param {Array<Object>} items - Array of items to update
* @returns {Promise<Array>} Array of results with success status
*/
async updateItems(scenarioUuid, items) {
this._log("updateItems", { scenarioUuid, itemCount: items.length });
const results = [];
for (const item of items) {
try {
const result = await this.updateItem(scenarioUuid, item);
results.push({
success: true,
oldUuid: item.uuid,
newUuid: result.uuid,
});
} catch (error) {
results.push({
success: false,
oldUuid: item.uuid,
error: error.message,
fieldErrors: error.fieldErrors,
isValidationError: error.isValidationError,
});
}
}
const failed = results.filter((r) => !r.success);
if (failed.length > 0) {
// If all failures are validation errors, return all validation errors for display
const validationErrors = failed.filter((f) => f.isValidationError);
if (validationErrors.length === failed.length) {
// All failures are validation errors - return all field errors by item UUID
const allFieldErrors = {};
validationErrors.forEach((ve) => {
allFieldErrors[ve.oldUuid] = ve.fieldErrors || {};
});
const error = new Error(
`${failed.length} item(s) have validation errors. Please correct them.`,
);
error.fieldErrors = allFieldErrors; // Map of UUID -> field errors
error.isValidationError = true;
error.isMultipleErrors = true; // Flag indicating multiple items have errors
throw error;
}
// Multiple failures or mixed errors - show count
throw new Error(
`Failed to update ${failed.length} item(s). Please check the form for errors.`,
);
}
return results;
},
/**
* Create a new scenario with optional additional information
* @param {string} packageUuid - UUID of the package
* @param {Object} payload - Scenario data matching ScenarioCreateSchema
* @param {string} payload.name - Scenario name (required)
* @param {string} payload.description - Scenario description (optional, default: "")
* @param {string} payload.scenario_date - Scenario date (optional, default: "No date")
* @param {string} payload.scenario_type - Scenario type (optional, default: "Not specified")
* @param {Array} payload.additional_information - Array of additional information (optional, default: [])
* @returns {Promise<{uuid, url, name, description, review_status, package}>}
*/
async createScenario(packageUuid, payload) {
this._log("createScenario", { packageUuid, payload });
const response = await fetch(`/api/v1/package/${packageUuid}/scenario/`, {
method: "POST",
headers: this._buildHeaders(),
body: JSON.stringify(payload),
});
return this._handleResponse(response, "createScenario");
},
/**
* Load all available group names
* @returns {Promise<{groups: string[]}>}
*/
async loadGroups() {
this._log("loadGroups", "Starting...");
const response = await fetch("/api/v1/information/groups/");
return this._handleResponse(response, "loadGroups");
},
/**
* Load model definitions for a specific group
* @param {string} groupName - One of 'soil', 'sludge', 'sediment'
* @returns {Promise<Object>} Object with subcategories as keys and arrays of model info
*/
async loadGroupModels(groupName) {
this._log("loadGroupModels", { groupName });
const response = await fetch(`/api/v1/information/groups/${groupName}/`);
return this._handleResponse(response, `loadGroupModels-${groupName}`);
},
/**
* Load model information for multiple groups in parallel
* @param {Array<string>} groupNames - Defaults to ['soil', 'sludge', 'sediment']
* @returns {Promise<Object>} Object with group names as keys
*/
async loadGroupsWithModels(groupNames = ["soil", "sludge", "sediment"]) {
this._log("loadGroupsWithModels", { groupNames });
const results = {};
const promises = groupNames.map(async (groupName) => {
try {
results[groupName] = await this.loadGroupModels(groupName);
} catch (err) {
this._log(`loadGroupsWithModels-${groupName} ERROR`, err);
results[groupName] = {};
}
});
await Promise.all(promises);
return results;
},
/**
* Helper to organize schemas by group based on group model information
* @param {Object} schemas - Full schema map from loadSchemas()
* @param {Object} groupModelsData - Group models data from loadGroupsWithModels()
* @returns {Object} Object with group names as keys and filtered schemas as values
*/
organizeSchemasByGroup(schemas, groupModelsData) {
this._log("organizeSchemasByGroup", {
schemaCount: Object.keys(schemas).length,
groupCount: Object.keys(groupModelsData).length,
});
const organized = {};
for (const groupName in groupModelsData) {
organized[groupName] = {};
const groupData = groupModelsData[groupName];
// Iterate through subcategories in the group
for (const subcategory in groupData) {
for (const model of groupData[subcategory]) {
// Look up schema by lowercase model name
if (schemas[model.name]) {
organized[groupName][model.name] = schemas[model.name];
}
}
}
}
return organized;
},
/**
* Convenience method that loads schemas and organizes them by group in one call
* @param {Array<string>} groupNames - Defaults to ['soil', 'sludge', 'sediment']
* @returns {Promise<{schemas, groupSchemas, groupModels}>}
*/
async loadSchemasWithGroups(groupNames = ["soil", "sludge", "sediment"]) {
this._log("loadSchemasWithGroups", { groupNames });
// Load schemas and all groups in parallel
const [schemas, groupModels] = await Promise.all([
this.loadSchemas(),
this.loadGroupsWithModels(groupNames),
]);
// Organize schemas by group
const groupSchemas = this.organizeSchemasByGroup(schemas, groupModels);
return { schemas, groupSchemas, groupModels };
},
};

View File

@ -361,6 +361,83 @@ function draw(pathway, elem) {
function node_popup(n) { function node_popup(n) {
popupContent = ""; popupContent = "";
if (timeseriesViewEnabled && n.timeseries && n.timeseries.measurements) {
for (var s of n.scenarios) {
popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>";
}
popupContent += '<div style="width:100%;height:120px"><canvas id="ts-popover-canvas"></canvas></div>';
const tsMeasurements = n.timeseries.measurements;
setTimeout(() => {
const canvas = document.getElementById('ts-popover-canvas');
if (canvas && window.Chart) {
const valid = tsMeasurements
.filter(m => m.timestamp != null && m.value != null)
.map(m => ({ ...m, timestamp: typeof m.timestamp === 'number' ? m.timestamp : new Date(m.timestamp).getTime() }))
.sort((a, b) => a.timestamp - b.timestamp);
const datasets = [];
// Error band (lower + upper with fill between)
const withErrors = valid.filter(m => m.error != null && m.error > 0);
if (withErrors.length > 0) {
datasets.push({
data: withErrors.map(m => ({ x: m.timestamp, y: m.value - m.error })),
borderColor: 'rgba(59,130,246,0.3)',
backgroundColor: 'rgba(59,130,246,0.15)',
pointRadius: 0,
fill: false,
tension: 0.1,
});
datasets.push({
data: withErrors.map(m => ({ x: m.timestamp, y: m.value + m.error })),
borderColor: 'rgba(59,130,246,0.3)',
backgroundColor: 'rgba(59,130,246,0.15)',
pointRadius: 0,
fill: '-1',
tension: 0.1,
});
}
// Main value line
datasets.push({
data: valid.map(m => ({ x: m.timestamp, y: m.value })),
borderColor: 'rgb(59,130,246)',
pointRadius: 0,
tension: 0.1,
fill: false,
});
new Chart(canvas.getContext('2d'), {
type: 'line',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
scales: {
x: {
type: 'linear',
ticks: { font: { size: 10 } },
title: { display: false },
},
y: {
ticks: { font: { size: 10 } },
title: { display: false },
},
},
},
});
}
}, 0);
return popupContent;
}
if (n.stereo_removed) { if (n.stereo_removed) {
popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>"; popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>";
} }
@ -393,7 +470,14 @@ function draw(pathway, elem) {
} }
function edge_popup(e) { function edge_popup(e) {
popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br>"; popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br><br>";
if (e.reaction.rules) {
console.log(e.reaction.rules);
for (var rule of e.reaction.rules) {
popupContent += "Rule <a href='" + rule.url + "'>" + rule.name + "</a><br>";
}
}
if (e.app_domain) { if (e.app_domain) {
adcontent = "<p>"; adcontent = "<p>";
@ -498,7 +582,8 @@ function draw(pathway, elem) {
.enter().append("line") .enter().append("line")
// Check if target is pseudo and draw marker only if not pseudo // Check if target is pseudo and draw marker only if not pseudo
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link") .attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
.attr("marker-end", d => d.target.pseudo ? '' : 'url(#arrow)') .attr("marker-end", d => d.target.pseudo ? '' : d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)')
// add element to links array // add element to links array
link.each(function (d) { link.each(function (d) {

View File

@ -0,0 +1,351 @@
/**
* TimeSeriesChart Utility
*
* Provides chart rendering capabilities for time series data with error bounds.
* Uses Chart.js to create interactive and static visualizations.
*
* Usage:
* const chart = window.TimeSeriesChart.create(canvas, {
* measurements: [...],
* xAxisLabel: "Time",
* yAxisLabel: "Concentration",
* xAxisUnit: "days",
* yAxisUnit: "mg/L"
* });
*
* window.TimeSeriesChart.update(chart, newMeasurements, options);
* window.TimeSeriesChart.destroy(chart);
*/
window.TimeSeriesChart = {
// === PUBLIC API ===
/**
* Create an interactive time series chart
*
* @param {HTMLCanvasElement} canvas - Canvas element to render chart on
* @param {Object} options - Chart configuration options
* @param {Array} options.measurements - Array of measurement objects with timestamp, value, error, note
* @param {string} options.xAxisLabel - Label for x-axis (default: "Time")
* @param {string} options.yAxisLabel - Label for y-axis (default: "Value")
* @param {string} options.xAxisUnit - Unit for x-axis (default: "")
* @param {string} options.yAxisUnit - Unit for y-axis (default: "")
* @returns {Chart|null} Chart.js instance or null if creation failed
*/
create(canvas, options = {}) {
if (!this._validateCanvas(canvas)) return null;
if (!window.Chart) {
console.warn("Chart.js is not loaded");
return null;
}
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const chartData = this._transformData(options.measurements || [], options);
if (chartData.datasets.length === 0) {
return null; // No data to display
}
const config = this._buildConfig(chartData, options);
return new Chart(ctx, config);
},
/**
* Update an existing chart with new data
*
* @param {Chart} chartInstance - Chart.js instance to update
* @param {Array} measurements - New measurements array
* @param {Object} options - Chart configuration options
*/
update(chartInstance, measurements, options = {}) {
if (!chartInstance) return;
const chartData = this._transformData(measurements || [], options);
chartInstance.data.datasets = chartData.datasets;
chartInstance.options.scales.x.title.text = chartData.xAxisLabel;
chartInstance.options.scales.y.title.text = chartData.yAxisLabel;
chartInstance.update("none");
},
/**
* Destroy chart instance and cleanup
*
* @param {Chart} chartInstance - Chart.js instance to destroy
*/
destroy(chartInstance) {
if (chartInstance && typeof chartInstance.destroy === "function") {
chartInstance.destroy();
}
},
// === PRIVATE HELPERS ===
/**
* Transform measurements into Chart.js datasets
* @private
*/
_transformData(measurements, options) {
const preparedData = this._prepareData(measurements);
if (preparedData.length === 0) {
return { datasets: [], xAxisLabel: "Time", yAxisLabel: "Value" };
}
const xAxisLabel = options.xAxisLabel || "Time";
const yAxisLabel = options.yAxisLabel || "Value";
const xAxisUnit = options.xAxisUnit || "";
const yAxisUnit = options.yAxisUnit || "";
const datasets = [];
// Error bounds datasets FIRST (if errors exist) - renders as background
const errorDatasets = this._buildErrorDatasets(preparedData);
if (errorDatasets.length > 0) {
datasets.push(...errorDatasets);
}
// Main line dataset LAST - renders on top
datasets.push(this._buildMainDataset(preparedData, yAxisLabel));
return {
datasets: datasets,
xAxisLabel: this._formatAxisLabel(xAxisLabel, xAxisUnit),
yAxisLabel: this._formatAxisLabel(yAxisLabel, yAxisUnit),
};
},
/**
* Prepare and validate measurements data
* @private
*/
_prepareData(measurements) {
return measurements
.filter(
(m) => m.timestamp != null && m.value != null,
)
.map((m) => {
// Normalize timestamp - handle both numeric and date strings
let timestamp;
if (typeof m.timestamp === "number") {
timestamp = m.timestamp;
} else {
timestamp = new Date(m.timestamp).getTime();
}
return {
...m,
timestamp: timestamp,
};
})
.sort((a, b) => a.timestamp - b.timestamp);
},
/**
* Build main line dataset
* @private
*/
_buildMainDataset(validMeasurements, yAxisLabel) {
return {
label: yAxisLabel,
data: validMeasurements.map((m) => ({
x: m.timestamp,
y: m.value,
error: m.error || null,
})),
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
tension: 0.1,
pointRadius: 0, // Hide individual points
pointHoverRadius: 6,
fill: false,
};
},
/**
* Build error bound datasets (upper and lower)
* @private
*/
_buildErrorDatasets(validMeasurements) {
const hasErrors = validMeasurements.some(
(m) => m.error !== null && m.error !== undefined && m.error > 0,
);
if (!hasErrors) return [];
const measurementsWithErrors = validMeasurements.filter(
(m) => m.error !== null && m.error !== undefined && m.error > 0,
);
return [
// Lower error bound - FIRST (bottom layer)
{
label: "Error (lower)",
data: measurementsWithErrors.map((m) => ({
x: m.timestamp,
y: m.value - m.error,
})),
borderColor: "rgba(59, 130, 246, 0.3)",
backgroundColor: "rgba(59, 130, 246, 0.15)",
pointRadius: 0,
fill: false,
tension: 0.1,
},
// Upper error bound - SECOND (fill back to lower)
{
label: "Error (upper)",
data: measurementsWithErrors.map((m) => ({
x: m.timestamp,
y: m.value + m.error,
})),
borderColor: "rgba(59, 130, 246, 0.3)",
backgroundColor: "rgba(59, 130, 246, 0.15)",
pointRadius: 0,
fill: "-1", // Fill back to previous dataset (lower bound)
tension: 0.1,
},
];
},
/**
* Build complete Chart.js configuration
* @private
*/
_buildConfig(chartData, options) {
return {
type: "line",
data: { datasets: chartData.datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: false,
},
tooltip: this._buildTooltipConfig(
options.xAxisUnit || "",
options.yAxisUnit || "",
),
},
scales: this._buildScalesConfig(
chartData.xAxisLabel,
chartData.yAxisLabel,
),
},
};
},
/**
* Build tooltip configuration with custom callbacks
* @private
*/
_buildTooltipConfig(xAxisUnit, yAxisUnit) {
return {
enabled: true,
callbacks: {
title: (contexts) => {
// Show timestamp
const context = contexts[0];
if (!context) return "Measurement";
const timestamp = context.parsed.x;
return xAxisUnit
? `Time: ${timestamp} ${xAxisUnit}`
: `Time: ${timestamp}`;
},
label: (context) => {
// Show value with unit
try {
const value = context.parsed.y;
if (value === null || value === undefined) {
return `${context.dataset.label || "Value"}: N/A`;
}
const valueStr = yAxisUnit
? `${value} ${yAxisUnit}`
: String(value);
return `${context.dataset.label || "Value"}: ${valueStr}`;
} catch (e) {
console.error("Tooltip label error:", e);
return `${context.dataset.label || "Value"}: ${context.parsed.y ?? "N/A"}`;
}
},
afterLabel: (context) => {
// Show error information
try {
const point = context.raw;
// Main line is now the last dataset (after error bounds if they exist)
const isMainDataset = context.dataset.label &&
!context.dataset.label.startsWith("Error");
if (!point || !isMainDataset) return null;
const lines = [];
// Show error if available
if (
point.error !== null &&
point.error !== undefined &&
point.error > 0
) {
const errorStr = yAxisUnit
? `±${point.error.toFixed(4)} ${yAxisUnit}`
: `±${point.error.toFixed(4)}`;
lines.push(`Error: ${errorStr}`);
}
return lines.length > 0 ? lines : null;
} catch (e) {
console.error("Tooltip afterLabel error:", e);
return null;
}
},
},
};
},
/**
* Build scales configuration
* @private
*/
_buildScalesConfig(xAxisLabel, yAxisLabel) {
return {
x: {
type: "linear",
title: {
display: true,
text: xAxisLabel || "Time",
},
},
y: {
title: {
display: true,
text: yAxisLabel || "Value",
},
},
};
},
/**
* Format axis label with unit
* @private
*/
_formatAxisLabel(label, unit) {
return unit ? `${label} (${unit})` : label;
},
/**
* Validate canvas element
* @private
*/
_validateCanvas(canvas) {
if (!canvas || !(canvas instanceof HTMLCanvasElement)) {
console.warn("Invalid canvas element provided to TimeSeriesChart");
return false;
}
return true;
},
};

View File

@ -1,4 +1,12 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li>
<a
class="button"
onclick="document.getElementById('edit_scenario_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Edit Scenario</a
>
</li>
<li> <li>
<a <a
class="button" class="button"

View File

@ -98,7 +98,7 @@
class="mt-6" class="mt-6"
x-show="activeTab === 'reviewed' && !isEmpty" x-show="activeTab === 'reviewed' && !isEmpty"
x-data="remotePaginatedList({ x-data="remotePaginatedList({
endpoint: '{{ api_endpoint }}?review_status=true', endpoint: '{{ api_endpoint }}?review_status=true{% if entity_type == 'scenario' %}&exclude_related=true{% endif %}',
instanceId: '{{ entity_type }}_reviewed', instanceId: '{{ entity_type }}_reviewed',
isReviewed: true, isReviewed: true,
perPage: {{ per_page|default:50 }} perPage: {{ per_page|default:50 }}
@ -113,7 +113,7 @@
class="mt-6" class="mt-6"
x-show="activeTab === 'unreviewed' && !isEmpty" x-show="activeTab === 'unreviewed' && !isEmpty"
x-data="remotePaginatedList({ x-data="remotePaginatedList({
endpoint: '{{ api_endpoint }}?review_status=false', endpoint: '{{ api_endpoint }}?review_status=false{% if entity_type == 'scenario' %}&exclude_related=true{% endif %}',
instanceId: '{{ entity_type }}_unreviewed', instanceId: '{{ entity_type }}_unreviewed',
isReviewed: false, isReviewed: false,
perPage: {{ per_page|default:50 }} perPage: {{ per_page|default:50 }}

View File

@ -1,7 +1,15 @@
{% extends "collections/paginated_base.html" %} {% extends "collections/paginated_base.html" %}
{% load static %}
{% block page_title %}Scenarios{% endblock %} {% block page_title %}Scenarios{% endblock %}
{% block action_modals %}
{# Load required scripts before modal #}
<script src="{% static 'js/alpine/components/widgets.js' %}"></script>
<script src="{% static 'js/api/additional-information.js' %}"></script>
{% include "modals/collections/new_scenario_modal.html" %}
{% endblock action_modals %}
{% block action_button %} {% block action_button %}
{% if meta.can_edit %} {% if meta.can_edit %}
<button <button
@ -14,10 +22,6 @@
{% endif %} {% endif %}
{% endblock action_button %} {% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_scenario_modal.html" %}
{% endblock action_modals %}
{% block description %} {% block description %}
<p> <p>
A scenario contains meta-information that can be attached to other data A scenario contains meta-information that can be attached to other data

View File

@ -0,0 +1,20 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Settings{% endblock %}
{% block action_button %}
{% endblock action_button %}
{% block action_modals %}
{% endblock action_modals %}
{% block description %}
<p>A setting includes configuration parameters for pathway predictions.</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/Setting"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -5,10 +5,8 @@
<nav> <nav>
<h6 class="footer-title">Services</h6> <h6 class="footer-title">Services</h6>
<a class="link link-hover" href="/predict">Predict</a> <a class="link link-hover" href="/predict">Predict</a>
<a class="link link-hover" href="/batch-predict">Batch Predict</a>
<a class="link link-hover" href="/package">Packages</a> <a class="link link-hover" href="/package">Packages</a>
{# {% if user.is_authenticated %}#}
{# <a class="link link-hover" href="/model">Your Collections</a>#}
{# {% endif %}#}
<a <a
href="https://wiki.envipath.org/" href="https://wiki.envipath.org/"
target="_blank" target="_blank"
@ -19,10 +17,7 @@
{% endif %} {% endif %}
<nav> <nav>
<h6 class="footer-title">Company</h6> <h6 class="footer-title">Company</h6>
<a class="link link-hover" href="/about">About us</a> <a class="link link-hover" href="/about" target="_blank">About us</a>
<a class="link link-hover" href="/contact">Contact us</a>
<a class="link link-hover" href="/careers">Careers</a>
<a class="link link-hover" href="/legal">Legal</a>
</nav> </nav>
<nav> <nav>
<h6 class="footer-title">Legal</h6> <h6 class="footer-title">Legal</h6>

View File

@ -9,6 +9,11 @@
} }
.spinner-slow svg { .spinner-slow svg {
animation: spin-slow 3s linear infinite; animation: spin-slow 3s linear infinite;
width: 100%;
height: 100%;
transform-origin: center;
transform-box: fill-box;
display: block;
} }
</style> </style>
<div class="spinner-slow flex h-full w-full items-center justify-center"> <div class="spinner-slow flex h-full w-full items-center justify-center">

View File

@ -0,0 +1,5 @@
<template x-if="{{ error_var }}">
<div class="alert alert-error mb-4">
<span x-text="{{ error_var }}"></span>
</div>
</template>

View File

@ -0,0 +1,5 @@
<template x-if="{{ loading_var }}">
<div class="flex justify-center items-center p-4">
<span class="loading loading-spinner loading-md"></span>
</div>
</template>

View File

@ -0,0 +1,146 @@
{% load static %}
<div>
<!-- Loading state -->
<template x-if="loading">
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-md"></span>
</div>
</template>
<!-- Error state -->
<template x-if="error">
<div class="alert alert-error mb-4">
<span x-text="error"></span>
</div>
</template>
<!-- Schema form -->
<template x-if="schema && !loading">
<div class="space-y-4">
<!-- Title from schema -->
<template x-if="schema['x-title'] || schema.title">
<h4
class="text-lg font-semibold"
x-text="data.name || schema['x-title'] || schema.title"
></h4>
</template>
<!-- Render each field (ordered by ui:order) -->
<template x-for="fieldName in getFieldOrder()" :key="fieldName">
<div>
<!-- Text widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'text'"
>
<div
x-data="textWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/text_widget.html" %}
</div>
</template>
<!-- Textarea widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'textarea'"
>
<div
x-data="textareaWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/textarea_widget.html" %}
</div>
</template>
<!-- Number widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'number'"
>
<div
x-data="numberWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/number_widget.html" %}
</div>
</template>
<!-- Select widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'select'"
>
<div
x-data="selectWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/select_widget.html" %}
</div>
</template>
<!-- Checkbox widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'checkbox'"
>
<div
x-data="checkboxWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/checkbox_widget.html" %}
</div>
</template>
<!-- Interval widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'interval'"
>
<div
x-data="intervalWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/interval_widget.html" %}
</div>
</template>
<!-- PubMed link widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'pubmed-link'"
>
<div
x-data="pubmedWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/pubmed_link_widget.html" %}
</div>
</template>
<!-- Compound link widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'"
>
<div
x-data="compoundWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/compound_link_widget.html" %}
</div>
</template>
<!-- TimeSeries table widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'timeseries-table'"
>
<div
x-data="timeseriesTableWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/timeseries_table_widget.html" %}
</div>
</template>
</div>
</template>
<!-- Submit button (only in edit mode with endpoint) -->
<template x-if="mode === 'edit' && endpoint">
<div class="form-control mt-4">
<button class="btn btn-primary" @click="submit()" :disabled="loading">
<template x-if="loading">
<span class="loading loading-spinner loading-sm"></span>
</template>
<span x-text="loading ? 'Submitting...' : 'Submit'"></span>
</button>
</div>
</template>
</div>
</template>
</div>

View File

@ -0,0 +1,53 @@
{# Checkbox widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode -->
<template x-if="isViewMode">
<div class="mt-1">
<span class="text-base" x-text="checked ? 'Yes' : 'No'"></span>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<input type="checkbox" class="checkbox" x-model="checked" />
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,69 @@
{# Compound link widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode: display as link -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value">
<a
:href="value"
class="link link-primary break-all"
target="_blank"
x-text="value"
></a>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<input
type="url"
class="input input-bordered w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="Compound URL"
x-model="value"
/>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,89 @@
{# Interval widget for range inputs - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': hasValidationError || $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode: formatted range with unit -->
<template x-if="isViewMode">
<div class="mt-1">
<span class="text-base" x-text="start"></span>
<span class="text-base-content/60 text-xs" x-show="!isSameValue"
>to</span
>
<span class="text-base" x-text="end" x-show="!isSameValue"></span>
<template x-if="start && end && unit">
<span class="text-xs" x-text="unit"></span>
</template>
</div>
</template>
<!-- Edit mode: two inputs with shared unit badge -->
<template x-if="isEditMode">
<div class="flex items-center gap-2">
<input
type="number"
class="input input-bordered flex-1"
:class="{ 'input-error': hasValidationError || $store.validationErrors.hasError(fieldName, context) }"
placeholder="Min"
x-model="start"
/>
<span class="text-base-content/60">to</span>
<input
type="number"
class="input input-bordered flex-1"
:class="{ 'input-error': hasValidationError || $store.validationErrors.hasError(fieldName, context) }"
placeholder="Max"
x-model="end"
/>
<template x-if="unit">
<span class="badge badge-ghost badge-lg" x-text="unit"></span>
</template>
</div>
</template>
<!-- Errors -->
<template
x-if="hasValidationError || $store.validationErrors.hasError(fieldName, context)"
>
<div class="label">
<!-- Client-side validation error -->
<template x-if="hasValidationError">
<span class="label-text-alt text-error">
Start value must be less than or equal to end value
</span>
</template>
<!-- Server-side validation errors from store -->
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,69 @@
{# Number input widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode: show value with unit -->
<template x-if="isViewMode">
<div class="mt-1">
<span class="text-base" x-text="value"></span>
<template x-if="value && unit">
<span class="text-xs" x-text="unit"></span>
</template>
</div>
</template>
<!-- Edit mode: input with unit suffix -->
<template x-if="isEditMode">
<div :class="unit ? 'join w-full' : ''">
<input
type="number"
:class="unit ? 'input input-bordered join-item flex-1' : 'input input-bordered w-full'"
class:input-error="$store.validationErrors.hasError(fieldName, context)"
x-model="value"
/>
<template x-if="unit">
<span
class="btn btn-ghost join-item no-animation pointer-events-none"
x-text="unit"
></span>
</template>
</div>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,69 @@
{# PubMed link widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode: display as link -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value && pubmedUrl">
<a
:href="pubmedUrl"
class="link link-primary"
target="_blank"
x-text="value"
></a>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<input
type="text"
class="input input-bordered w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="PubMed ID"
x-model="value"
/>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,71 @@
{# Select dropdown widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value">
<span class="text-base" x-text="value"></span>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<select
class="select select-bordered w-full"
:class="{ 'select-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value"
>
<option value="" :selected="!value">Select...</option>
<template x-for="opt in options" :key="opt">
<option
:value="opt"
:selected="value === opt"
x-text="opt"
></option>
</template>
</select>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,63 @@
{# Text input widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value">
<span class="text-base" x-text="value"></span>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<input
type="text"
class="input input-bordered w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value"
/>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,62 @@
{# Textarea widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value">
<p class="text-base whitespace-pre-wrap" x-text="value"></p>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<textarea
class="textarea textarea-bordered w-full"
:class="{ 'textarea-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value"
></textarea>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,234 @@
{# TimeSeries table widget for measurement data #}
<div class="form-control">
<div class="flex flex-col gap-2">
<!-- Label -->
<label class="label">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- Form-level validation errors (root errors) for timeseries -->
<template x-if="$store.validationErrors.hasError('root', context)">
<div class="text-error">
<template
x-for="errMsg in $store.validationErrors.getErrors('root', context)"
:key="errMsg"
>
<span x-text="errMsg"></span>
</template>
</div>
</template>
<!-- View mode: display measurements as chart -->
<template x-if="isViewMode">
<div class="space-y-4">
<!-- Chart container -->
<template x-if="measurements.length > 0">
<div class="w-full">
<div class="h-64 w-full">
<canvas x-ref="chartCanvas"></canvas>
</div>
</div>
</template>
<template x-if="measurements.length === 0">
<div class="text-base-content/60 text-sm italic">No measurements</div>
</template>
<!-- Description and Method metadata -->
<template x-if="description || method">
<div class="space-y-2 text-sm">
<template x-if="description">
<div>
<span class="text-base-content/80 font-semibold"
>Description:</span
>
<p
class="text-base-content/70 mt-1 whitespace-pre-wrap"
x-text="description"
></p>
</div>
</template>
<template x-if="method">
<div>
<span class="text-base-content/80 font-semibold">Method:</span>
<span class="text-base-content/70 ml-2" x-text="method"></span>
</div>
</template>
</div>
</template>
</div>
</template>
<!-- Edit mode: editable table with add/remove controls -->
<template x-if="isEditMode">
<div class="space-y-2">
<!-- Measurements table -->
<div class="overflow-x-auto">
<template x-if="measurements.length > 0">
<table class="table-zebra table-sm table">
<thead>
<tr>
<th>Timestamp</th>
<th>Value</th>
<th>Error</th>
<th>Note</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
<template
x-for="(measurement, index) in measurements"
:key="index"
>
<tr>
<td>
<input
type="number"
class="input input-bordered input-sm w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
:value="formatTimestamp(measurement.timestamp)"
@input="updateMeasurement(index, 'timestamp', $event.target.value)"
/>
</td>
<td>
<input
type="number"
class="input input-bordered input-sm w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="Value"
:value="measurement.value"
@input="updateMeasurement(index, 'value', $event.target.value)"
/>
</td>
<td>
<input
type="number"
class="input input-bordered input-sm w-full"
placeholder="±"
:value="measurement.error"
@input="updateMeasurement(index, 'error', $event.target.value)"
/>
</td>
<td>
<input
type="text"
class="input input-bordered input-sm w-full"
placeholder="Note"
:value="measurement.note"
@input="updateMeasurement(index, 'note', $event.target.value)"
/>
</td>
<td>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
@click="removeMeasurement(index)"
title="Remove measurement"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="measurements.length === 0">
<div class="text-base-content/60 py-2 text-sm italic">
No measurements yet. Click "Add Measurement" to start.
</div>
</template>
</div>
<!-- Action buttons -->
<div class="flex gap-2">
<button
type="button"
class="btn btn-sm btn-primary"
@click="addMeasurement()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add Measurement
</button>
<template x-if="measurements.length > 1">
<button
type="button"
class="btn btn-sm btn-ghost"
@click="sortByTimestamp()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
/>
</svg>
Sort by Timestamp
</button>
</template>
</div>
</div>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>

View File

@ -21,6 +21,10 @@
type="text/css" type="text/css"
/> />
{# Chart.js - For timeseries charts #}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="{% static 'js/utils/timeseries-chart.js' %}"></script>
{# Alpine.js - For reactive components #} {# Alpine.js - For reactive components #}
<script <script
defer defer
@ -30,6 +34,8 @@
<script src="{% static 'js/alpine/search.js' %}"></script> <script src="{% static 'js/alpine/search.js' %}"></script>
<script src="{% static 'js/alpine/pagination.js' %}"></script> <script src="{% static 'js/alpine/pagination.js' %}"></script>
<script src="{% static 'js/alpine/pathway.js' %}"></script> <script src="{% static 'js/alpine/pathway.js' %}"></script>
<script src="{% static 'js/alpine/components/schema-form.js' %}"></script>
<script src="{% static 'js/alpine/components/widgets.js' %}"></script>
{# Font Awesome #} {# Font Awesome #}
<link <link

View File

@ -176,8 +176,8 @@
href="#" href="#"
class="example-link hover:text-primary cursor-pointer" class="example-link hover:text-primary cursor-pointer"
title="load example" title="load example"
@click.prevent="loadExample('CC(C)CC1=CC=C(C=C1)C(C)C(=O)O', $el)" @click.prevent="loadExample('COC(=O)[C@H](CC1=CC=CC=C1)NC(=O)[C@H](CC(=O)O)N', $el)"
>Ibuprofen</a >Aspartame</a
> >
</div> </div>
<a <a
@ -287,7 +287,7 @@
</p> </p>
<div class="float-right flex flex-row gap-4"> <div class="float-right flex flex-row gap-4">
<a href="/about" class="btn btn-ghost-neutral">Read More</a> <a href="/about" class="btn btn-ghost-neutral">Read More</a>
<a href="/about" class="btn btn-neutral">Publications</a> <a href="/cite" class="btn btn-neutral">Publications</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -4,17 +4,152 @@
id="new_scenario_modal" id="new_scenario_modal"
class="modal" class="modal"
x-data="{ x-data="{
...modalForm(), isSubmitting: false,
error: null,
scenarioType: 'empty', scenarioType: 'empty',
schemas: {},
groupSchemas: {},
loadingSchemas: false,
formData: {
name: '',
description: '',
dateYear: '',
dateMonth: '',
dateDay: '',
scenarioType: 'empty',
additionalInformation: {}
},
// Track form data from each schema renderer
schemaFormData: {},
validateYear(el) { validateYear(el) {
if (el.value && el.value.length < 4) { if (el.value && el.value.length < 4) {
el.value = new Date().getFullYear(); el.value = new Date().getFullYear();
} }
},
async init() {
try {
this.loadingSchemas = true;
// Ensure API client is available
if (!window.AdditionalInformationApi) {
throw new Error('Additional Information API client not loaded. Please refresh the page.');
}
// Use single API call to load schemas organized by groups
const { schemas, groupSchemas } =
await window.AdditionalInformationApi.loadSchemasWithGroups(['soil', 'sludge', 'sediment']);
this.schemas = schemas;
this.groupSchemas = groupSchemas;
} catch (err) {
this.error = err.message;
console.error('Error loading schemas:', err);
} finally {
this.loadingSchemas = false;
}
},
reset() {
this.isSubmitting = false;
this.error = null;
this.scenarioType = 'empty';
this.formData = {
name: '',
description: '',
dateYear: '',
dateMonth: '',
dateDay: '',
scenarioType: 'empty',
additionalInformation: {}
};
this.schemaFormData = {};
},
setSchemaFormData(schemaName, data) {
this.schemaFormData[schemaName] = data;
},
async submit() {
if (!this.formData.name || this.formData.name.trim() === '') {
this.error = 'Please enter a scenario name';
return;
}
this.isSubmitting = true;
this.error = null;
try {
// Build scenario date
let scenarioDate = this.formData.dateYear || '';
if (this.formData.dateMonth && this.formData.dateMonth.trim() !== '') {
scenarioDate += `-${parseInt(this.formData.dateMonth).toString().padStart(2, '0')}`;
if (this.formData.dateDay && this.formData.dateDay.trim() !== '') {
scenarioDate += `-${parseInt(this.formData.dateDay).toString().padStart(2, '0')}`;
}
}
if (!scenarioDate || scenarioDate.trim() === '') {
scenarioDate = 'No date';
}
// Collect additional information from schema forms
const additionalInformation = [];
const currentGroupSchemas = this.groupSchemas[this.scenarioType] || {};
for (const schemaName in this.schemaFormData) {
const data = this.schemaFormData[schemaName];
// Only include if schema belongs to current group and has data
if (currentGroupSchemas[schemaName] && data && Object.keys(data).length > 0) {
// Check if data has any non-null/non-empty values
const hasData = Object.values(data).some(val => {
if (val === null || val === undefined || val === '') return false;
if (typeof val === 'object' && val !== null) {
// For interval objects, check if start or end has value
if (val.start !== null && val.start !== undefined && val.start !== '') return true;
if (val.end !== null && val.end !== undefined && val.end !== '') return true;
return false;
}
return true;
});
if (hasData) {
additionalInformation.push({
type: schemaName,
data: data
});
}
}
}
// Build payload
const payload = {
name: this.formData.name.trim(),
description: this.formData.description ? this.formData.description.trim() : '',
scenario_date: scenarioDate,
scenario_type: this.scenarioType === 'empty' ? 'Not specified' : this.scenarioType,
additional_information: additionalInformation
};
const packageUuid = '{{ meta.current_package.uuid }}';
// Use API client for scenario creation
const result = await window.AdditionalInformationApi.createScenario(packageUuid, payload);
// Close modal and redirect to new scenario
document.getElementById('new_scenario_modal').close();
window.location.href = result.url || `{{ meta.current_package.url }}/scenario/${result.uuid}`;
} catch (err) {
this.error = err.message;
} finally {
this.isSubmitting = false;
}
} }
}" }"
@close="reset()" @close="reset()"
@schema-form-data-changed.window="setSchemaFormData($event.detail.schemaName, $event.detail.data)"
> >
<div class="modal-box max-w-3xl"> <div class="modal-box max-w-3xl max-h-[90vh] overflow-y-auto">
<!-- Header --> <!-- Header -->
<h3 class="text-lg font-bold">New Scenario</h3> <h3 class="text-lg font-bold">New Scenario</h3>
@ -30,20 +165,12 @@
<!-- Body --> <!-- Body -->
<div class="py-4"> <div class="py-4">
<form
id="new-scenario-modal-form"
accept-charset="UTF-8"
action="{{ meta.current_package.url }}/scenario"
method="post"
>
{% csrf_token %}
<div class="alert alert-info mb-4"> <div class="alert alert-info mb-4">
<span> <span>
Please enter name, description, and date of scenario. Date should be Please enter name, description, and date of scenario. Date should be
associated to the data, not the current date. For example, this associated to the data, not the current date. For example, this could
could reflect the publishing date of a study. You can leave all reflect the publishing date of a study. You can leave all fields but
fields but the name empty and fill them in later. the name empty and fill them in later.
<a <a
target="_blank" target="_blank"
href="https://wiki.envipath.org/index.php/scenario" href="https://wiki.envipath.org/index.php/scenario"
@ -53,15 +180,33 @@
</span> </span>
</div> </div>
<!-- Error state -->
<template x-if="error">
<div class="alert alert-error mb-4">
<span x-text="error"></span>
</div>
</template>
<!-- Loading state -->
<template x-if="loadingSchemas">
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-md"></span>
</div>
</template>
<!-- Form fields -->
<template x-if="!loadingSchemas">
<div>
<div class="form-control mb-3"> <div class="form-control mb-3">
<label class="label" for="scenario-name"> <label class="label" for="scenario-name">
<span class="label-text">Name</span> <span class="label-text">Name</span>
</label> </label>
<input <input
id="scenario-name" id="scenario-name"
name="scenario-name" type="text"
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder="Name" placeholder="Name"
x-model="formData.name"
required required
/> />
</div> </div>
@ -72,9 +217,10 @@
</label> </label>
<input <input
id="scenario-description" id="scenario-description"
name="scenario-description" type="text"
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder="Description" placeholder="Description"
x-model="formData.description"
/> />
</div> </div>
@ -85,30 +231,27 @@
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
type="number" type="number"
id="dateYear"
name="scenario-date-year"
class="input input-bordered w-24" class="input input-bordered w-24"
placeholder="YYYY" placeholder="YYYY"
max="{% now 'Y' %}" max="{% now 'Y' %}"
x-model="formData.dateYear"
@blur="validateYear($el)" @blur="validateYear($el)"
/> />
<input <input
type="number" type="number"
id="dateMonth"
name="scenario-date-month"
min="1" min="1"
max="12" max="12"
class="input input-bordered w-20" class="input input-bordered w-20"
placeholder="MM" placeholder="MM"
x-model="formData.dateMonth"
/> />
<input <input
type="number" type="number"
id="dateDay"
name="scenario-date-day"
min="1" min="1"
max="31" max="31"
class="input input-bordered w-20" class="input input-bordered w-20"
placeholder="DD" placeholder="DD"
x-model="formData.dateDay"
/> />
</div> </div>
</div> </div>
@ -127,38 +270,107 @@
> >
Empty Scenario Empty Scenario
</button> </button>
{% for k, v in scenario_types.items %}
<button <button
type="button" type="button"
role="tab" role="tab"
class="tab" class="tab"
:class="{ 'tab-active': scenarioType === '{{ v.name }}' }" :class="{ 'tab-active': scenarioType === 'soil' }"
@click="scenarioType = '{{ v.name }}'" @click="scenarioType = 'soil'"
> >
{{ k }} Soil Data
</button>
<button
type="button"
role="tab"
class="tab"
:class="{ 'tab-active': scenarioType === 'sludge' }"
@click="scenarioType = 'sludge'"
>
Sludge Data
</button>
<button
type="button"
role="tab"
class="tab"
:class="{ 'tab-active': scenarioType === 'sediment' }"
@click="scenarioType = 'sediment'"
>
Water-Sediment System Data
</button> </button>
{% endfor %}
</div> </div>
<input
type="hidden"
id="scenario-type"
name="scenario-type"
x-model="scenarioType"
/>
</div> </div>
{% for type in scenario_types.values %} <!-- Schema forms for each scenario type -->
<div <template x-if="scenarioType === 'soil'">
id="{{ type.name }}-specific-inputs" <div class="space-y-4 mt-4">
x-show="scenarioType === '{{ type.name }}'" <template
x-cloak x-for="(rjsf, schemaName) in groupSchemas.soil"
:key="schemaName"
> >
{% for widget in type.widgets %} <div
{{ widget|safe }} x-data="schemaRenderer({
{% endfor %} rjsf: rjsf,
mode: 'edit'
})"
x-init="await init();
const currentSchemaName = schemaName;
$watch('data', (value) => {
$dispatch('schema-form-data-changed', { schemaName: currentSchemaName, data: value });
}, { deep: true })"
>
{% include "components/schema_form.html" %}
</div> </div>
{% endfor %} </template>
</form> </div>
</template>
<template x-if="scenarioType === 'sludge'">
<div class="space-y-4 mt-4">
<template
x-for="(rjsf, schemaName) in groupSchemas.sludge"
:key="schemaName"
>
<div
x-data="schemaRenderer({
rjsf: rjsf,
mode: 'edit'
})"
x-init="await init();
const currentSchemaName = schemaName;
$watch('data', (value) => {
$dispatch('schema-form-data-changed', { schemaName: currentSchemaName, data: value });
}, { deep: true })"
>
{% include "components/schema_form.html" %}
</div>
</template>
</div>
</template>
<template x-if="scenarioType === 'sediment'">
<div class="space-y-4 mt-4">
<template
x-for="(rjsf, schemaName) in groupSchemas.sediment"
:key="schemaName"
>
<div
x-data="schemaRenderer({
rjsf: rjsf,
mode: 'edit'
})"
x-init="await init();
const currentSchemaName = schemaName;
$watch('data', (value) => {
$dispatch('schema-form-data-changed', { schemaName: currentSchemaName, data: value });
}, { deep: true })"
>
{% include "components/schema_form.html" %}
</div>
</template>
</div>
</template>
</div>
</template>
</div> </div>
<!-- Footer --> <!-- Footer -->
@ -174,8 +386,8 @@
<button <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
@click="submit('new-scenario-modal-form')" @click="submit()"
:disabled="isSubmitting" :disabled="isSubmitting || loadingSchemas"
> >
<span x-show="!isSubmitting">Submit</span> <span x-show="!isSubmitting">Submit</span>
<span <span

Some files were not shown because too many files have changed in this diff Show More