9 Commits

16 changed files with 407 additions and 32 deletions

View File

@ -36,7 +36,9 @@ RUN --mount=type=ssh \
# Now copy source and do a final sync to install the project itself
# Ensure .dockerignore is reasonable
COPY bayer bayer
COPY bridge bridge
COPY biotransformer biotransformer
COPY envipath envipath
COPY epapi epapi
COPY epauth epauth

View File

@ -0,0 +1,20 @@
# Generated by Django 6.0.3 on 2026-04-14 19:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bayer', '0002_initial'),
('epdb', '0023_alter_compoundstructure_options_and_more'),
]
operations = [
migrations.AddField(
model_name='package',
name='data_pool',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.group', verbose_name='Data pool'),
),
]

View File

@ -29,6 +29,9 @@ class Package(EnviPathModel):
default=Classification.RESTRICTED,
)
data_pool = models.ForeignKey("epdb.Group", on_delete=models.SET_NULL, blank=True, null=True,
verbose_name="Data pool", default=None)
def delete(self, *args, **kwargs):
# explicitly handle related Rules
for r in self.rules.all():

View File

@ -0,0 +1,181 @@
{% load static %}
<dialog
id="new_package_modal"
class="modal"
x-data="{
isSubmitting: false,
packageClassification: null,
reset() {
this.isSubmitting = false;
this.selectedType = '';
this.buildAppDomain = false;
this.requiresRulePackages = false;
this.requiresDataPackages = false;
this.additional_parameters = null;
},
setFormData(data) {
this.formData = data;
},
get isSecret() {
return this.packageClassification === '20';
},
submit(formId) {
const form = document.getElementById(formId);
// Remove previously injected inputs
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
// Add values from dynamic form into the html form
if (this.formData) {
Object.entries(this.formData).forEach(([key, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
input.classList.add('dynamic-param');
form.appendChild(input);
});
}
if (form && form.checkValidity()) {
this.isSubmitting = true;
form.submit();
} else if (form) {
form.reportValidity();
}
}
}"
@close="reset()"
>
<div class="modal-box max-w-3xl">
<!-- Header -->
<h3 class="text-lg font-bold">New Package</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<form
id="new_package_form"
accept-charset="UTF-8"
action=""
method="post"
>
{% csrf_token %}
<!-- Name -->
<div class="form-control mb-3">
<label class="label" for="package-name">
<span class="label-text">Name</span>
</label>
<input
id="package-name"
class="input input-bordered w-full"
name="package-name"
placeholder="Name"
required
/>
</div>
<!-- Description -->
<div class="form-control mb-3">
<label class="label" for="package-description">
<span class="label-text">Description</span>
</label>
<input
id="package-description"
type="text"
class="input input-bordered w-full"
placeholder="Description..."
name="package-description"
/>
</div>
<!-- Classification Level -->
<div class="form-control mb-3">
<label class="label" for="package-classification">
<span class="label-text">Package Classification</span>
</label>
<select
id="package-classification"
name="package-classification"
class="select select-bordered w-full"
x-model="packageClassification"
required
>
<option value="null" disabled selected>Select Classification</option>
<option value="0">Internal</option>
<option value="10">Restricted</option>
<option value="20">Secret</option>
</select>
</div>
<!-- Secret Groups -->
<div class="form-control mb-3" x-show="isSecret" x-cloak>
<label class="label" for="package-data-pool">
<span class="label-text">Data Pool for SECRET Package</span>
</label>
<p>Only users with this role can be granted access to this package</p>
<select
id="package-data-pool"
name="package-data-pool"
class="select select-bordered w-full"
>
<option value="" disabled selected>Select Data Pool</option>
{% for obj in meta.available_groups %}
{% if obj.secret %}
<option value="{{ obj.url }}">{{ obj.name|safe }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('new_package_form')"
:disabled="isSubmitting || !selectedType || loadingSchemas"
>
<span x-show="!isSubmitting">Submit</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Creating...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -1,15 +1,15 @@
services:
db:
image: postgres:18
container_name: envipath-postgres
container_name: eppostgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: envipath
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql
- ep_bayer_postgres_data:/var/lib/postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s
@ -18,9 +18,37 @@ services:
redis:
image: redis:7-alpine
container_name: envipath-redis
container_name: epredis
ports:
- "6379:6379"
volumes:
- ep_bayer_redis_data:/data
biotransformer3:
image: envipath/biotransformer3:1.0
container_name: epbiotransformer3
# web:
# image: envipath/envipy-bayer:1.0
# container_name: epdjango
# ports:
# - "127.0.0.1:8000:8000"
# env_file:
# - .env
# command: gunicorn envipath.wsgi:application --bind 0.0.0.0:8000 --workers 3
# volumes:
# - ep_bayer_data:/opt/enviPy/
celery_worker:
image: envipath/envipy-bayer:1.0
container_name: epcelery
env_file:
- .env.dev
command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads
volumes:
- ep_bayer_data:/opt/enviPy/
volumes:
postgres_data:
ep_bayer_postgres_data:
ep_bayer_redis_data:
ep_bayer_data:

View File

@ -7,7 +7,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ep_postgres_data:/var/lib/postgresql
- ep_bayer_postgres_data:/var/lib/postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s
@ -18,14 +18,14 @@ services:
image: redis:7-alpine
container_name: epredis
volumes:
- ep_redis_data:/data
- ep_bayer_redis_data:/data
biotransformer3:
image: envipath/biotransformer3:1.0
container_name: epbiotransformer3
web:
image: envipath/envipy:1.0
image: envipath/envipy-bayer:1.0
container_name: epdjango
ports:
- "127.0.0.1:8000:8000"
@ -33,18 +33,18 @@ services:
- .env
command: gunicorn envipath.wsgi:application --bind 0.0.0.0:8000 --workers 3
volumes:
- ep_data:/opt/enviPy/
- ep_bayer_data:/opt/enviPy/
celery_worker:
image: envipath/envipy:1.0
image: envipath/envipy-bayer:1.0
container_name: epcelery
env_file:
- .env
command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads
volumes:
- ep_data:/opt/enviPy/
- ep_bayer_data:/opt/enviPy/
volumes:
ep_postgres_data:
ep_redis_data:
ep_data:
ep_bayer_postgres_data:
ep_bayer_redis_data:
ep_bayer_data:

View File

@ -9,7 +9,7 @@ https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
import json
import os
from pathlib import Path
@ -20,7 +20,7 @@ from sklearn.tree import DecisionTreeClassifier
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
ENV_PATH = os.environ.get("ENV_PATH", BASE_DIR / ".env")
ENV_PATH = os.environ.get("ENV_PATH", BASE_DIR / ".env.dev")
print(f"Loading env from {ENV_PATH}")
load_dotenv(ENV_PATH, override=False)
@ -442,3 +442,14 @@ BIOTRANSFORMER_ENABLED = os.environ.get("BIOTRANSFORMER_ENABLED", "False") == "T
FLAGS["BIOTRANSFORMER"] = BIOTRANSFORMER_ENABLED
if BIOTRANSFORMER_ENABLED:
BIOTRANSFORMER_URL = os.environ.get("BIOTRANSFORMER_URL", None)
# PES
PES_API_MAPPING = os.environ.get("PES_API_MAPPING", None)
if PES_API_MAPPING:
import json
PES_API_MAPPING = json.loads(PES_API_MAPPING)
else:
PES_API_MAPPING = {}
# AD Group Mapping

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 GroupManager
from ..pagination import EnhancedPageNumberPagination
from ..schemas import GroupOutSchema
router = Router()
@router.get("/groups/", response=EnhancedPageNumberPagination.Output[GroupOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
)
def list_all_groups(request):
"""
List all groups the user has access to.
"""
user = request.user
return GroupManager.get_groups(user)

View File

@ -15,9 +15,9 @@ router = Router()
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
)
def list_all_pathways(request):
def list_all_settings(request):
"""
List all pathways from reviewed packages.
List all settings the user has access to.
"""
user = request.user
return SettingManager.get_all_settings(user)

View File

@ -1,6 +1,7 @@
from ninja import Router
from ninja.security import SessionAuth
from envipath import settings as s
from .auth import BearerTokenAuth
from .endpoints import (
packages,
@ -13,8 +14,8 @@ from .endpoints import (
structure,
additional_information,
settings,
groups,
)
from envipath import settings as s
# Main router with authentication
router = Router(
@ -35,6 +36,7 @@ router.add_router("", models.router)
router.add_router("", structure.router)
router.add_router("", additional_information.router)
router.add_router("", settings.router)
router.add_router("", groups.router)
if s.IUCLID_EXPORT_ENABLED:
from epiuclid.api import router as iuclid_router

View File

@ -126,3 +126,10 @@ class SettingOutSchema(Schema):
url: str = ""
name: str
description: str
class GroupOutSchema(Schema):
uuid: UUID
url: str = ""
name: str
description: str

View File

@ -0,0 +1,50 @@
# Generated by Django 6.0.3 on 2026-04-14 19:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epdb', '0022_alter_classifierpluginmodel_data_packages_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='compoundstructure',
options={},
),
migrations.AlterModelOptions(
name='epmodel',
options={},
),
migrations.AlterModelOptions(
name='parallelrule',
options={},
),
migrations.AlterModelOptions(
name='rule',
options={},
),
migrations.AlterModelOptions(
name='sequentialrule',
options={},
),
migrations.AlterModelOptions(
name='simpleambitrule',
options={},
),
migrations.AlterModelOptions(
name='simplerdkitrule',
options={},
),
migrations.AlterModelOptions(
name='simplerule',
options={},
),
migrations.AddField(
model_name='group',
name='secret',
field=models.BooleanField(default=False, verbose_name='Secret Group'),
),
]

View File

@ -203,6 +203,7 @@ class Group(TimeStampedModel):
name = models.TextField(blank=False, null=False, verbose_name="Group name")
owner = models.ForeignKey("User", verbose_name="Group Owner", on_delete=models.CASCADE)
public = models.BooleanField(verbose_name="Public Group", default=False)
secret = models.BooleanField(verbose_name="Secret Group", default=False)
description = models.TextField(
blank=False, null=False, verbose_name="Descriptions", default="no description"
)

View File

@ -587,10 +587,38 @@ def packages(request):
"package-description", s.DEFAULT_VALUES["description"]
)
# EDIT START
data_pool = None
package_classification = request.POST.get("package-classification")
classification = Package.Classification(int(package_classification))
# For SECRET we'll need a data pool which will be an additional perm check later
if classification == Package.Classification.SECRET:
package_data_pool = request.POST.get("package-data-pool")
if package_data_pool is None:
return error(request, "Invalid data pool.", "Data Pool is required!")
data_pool = GroupManager.get_group_by_url(current_user, package_data_pool)
if data_pool is None:
return error(request, "Invalid data pool.", "Data Pool does not exist or no access!")
if not data_pool.secret:
return error(request, "Invalid data pool.", "Data Pool is not a secret group!")
created_package = PackageManager.create_package(
current_user, package_name, package_description
)
created_package.classification_level = classification
# Set previously determined data pool
if classification == Package.Classification.SECRET:
created_package.data_pool = data_pool
created_package.save()
# EDIT END
return redirect(created_package.url)
elif request.method == "OPTIONS":
@ -2846,9 +2874,15 @@ def groups(request):
{"Group": s.SERVER_URL + "/group"},
]
context["objects"] = Group.objects.all()
# Context for paginated template
context["entity_type"] = "group"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/groups/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "groups"
context["list_mode"] = "combined"
return render(request, "collections/groups_paginated.html", context)
return render(request, "collections/objects_list.html", context)
elif request.method == "POST":
group_name = request.POST.get("group-name")
group_description = request.POST.get("group-description", s.DEFAULT_VALUES["description"])

View File

@ -1,8 +0,0 @@
<li>
<a
role="button"
onclick="document.getElementById('new_group_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Group</a
>
</li>

View File

@ -0,0 +1,21 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Groups{% endblock %}
{% block action_button %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_group_modal').showModal(); return false;"
>
New Group
</button>
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_group_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>Users can team up in groups to share packages.</p>
{% endblock description %}