forked from enviPath/enviPy
[Feature] Search for Permissions, Prep Compound / Structure to be extended, Prep Template overwrites (#347)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch> Reviewed-on: enviPath/enviPy#347
This commit is contained in:
@ -92,10 +92,19 @@ if os.environ.get("REGISTRATION_MANDATORY", False) == "True":
|
|||||||
|
|
||||||
ROOT_URLCONF = "envipath.urls"
|
ROOT_URLCONF = "envipath.urls"
|
||||||
|
|
||||||
|
TEMPLATE_DIRS = [
|
||||||
|
os.path.join(BASE_DIR, "templates"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# If we have a non-public tenant, we might need to overwrite some templates
|
||||||
|
# search TENANT folder first...
|
||||||
|
if TENANT != "public":
|
||||||
|
TEMPLATE_DIRS.insert(0, os.path.join(BASE_DIR, TENANT, "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": [
|
||||||
|
|||||||
@ -60,7 +60,7 @@ class ScenarioCreationAPITests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertIn("Package not found", response.json()["detail"])
|
self.assertIn(f"Package with UUID {fake_uuid} not found", response.json()["detail"])
|
||||||
|
|
||||||
def test_create_scenario_insufficient_permissions(self):
|
def test_create_scenario_insufficient_permissions(self):
|
||||||
"""Test that unauthorized access returns 403."""
|
"""Test that unauthorized access returns 403."""
|
||||||
|
|||||||
@ -41,6 +41,24 @@ def get_package_for_read(user, package_uuid: UUID):
|
|||||||
return package
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def get_package_for_write(user, package_uuid: UUID):
|
||||||
|
"""
|
||||||
|
Get package by UUID with permission check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# FIXME: update package manager with custom exceptions to avoid manual checks here
|
||||||
|
try:
|
||||||
|
package = Package.objects.get(uuid=package_uuid)
|
||||||
|
except Package.DoesNotExist:
|
||||||
|
raise EPAPINotFoundError(f"Package with UUID {package_uuid} not found")
|
||||||
|
|
||||||
|
# FIXME: optimize package manager to exclusively work with UUIDs
|
||||||
|
if not user or user.is_anonymous or not PackageManager.writable(user, package):
|
||||||
|
raise EPAPIPermissionDeniedError("Insufficient permissions to access this package.")
|
||||||
|
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
def get_scenario_for_read(user, scenario_uuid: UUID):
|
def get_scenario_for_read(user, scenario_uuid: UUID):
|
||||||
"""Get scenario by UUID with read permission check."""
|
"""Get scenario by UUID with read permission check."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import logging
|
|||||||
import json
|
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 epdb.views import _anonymous_or_real
|
||||||
from ..pagination import EnhancedPageNumberPagination
|
from ..pagination import EnhancedPageNumberPagination
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
@ -17,7 +16,7 @@ from ..schemas import (
|
|||||||
ScenarioOutSchema,
|
ScenarioOutSchema,
|
||||||
ScenarioCreateSchema,
|
ScenarioCreateSchema,
|
||||||
)
|
)
|
||||||
from ..dal import get_user_entities_for_read, get_package_entities_for_read
|
from ..dal import get_user_entities_for_read, get_package_entities_for_read, get_package_for_write
|
||||||
from envipy_additional_information import registry
|
from envipy_additional_information import registry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -58,7 +57,7 @@ def create_scenario(request, package_uuid: UUID, payload: ScenarioCreateSchema =
|
|||||||
user = _anonymous_or_real(request)
|
user = _anonymous_or_real(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_package = PackageManager.get_package_by_id(user, package_uuid)
|
current_package = get_package_for_write(user, package_uuid)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "does not exist" in error_msg:
|
if "does not exist" in error_msg:
|
||||||
|
|||||||
@ -1392,7 +1392,7 @@ def create_package_scenario(request, package_uuid):
|
|||||||
study_type = request.POST.get("type")
|
study_type = request.POST.get("type")
|
||||||
|
|
||||||
ais = []
|
ais = []
|
||||||
types = request.POST.getlist("adInfoTypes[]")
|
types = request.POST.get("adInfoTypes[]", "").split(",")
|
||||||
for t in types:
|
for t in types:
|
||||||
ais.append(build_additional_information_from_request(request, t))
|
ais.append(build_additional_information_from_request(request, t))
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,65 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-03-09 10:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def populate_polymorphic_ctype(apps, schema_editor):
|
||||||
|
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||||
|
Compound = apps.get_model("epdb", "Compound")
|
||||||
|
CompoundStructure = apps.get_model("epdb", "CompoundStructure")
|
||||||
|
|
||||||
|
# Update Compound records
|
||||||
|
compound_ct = ContentType.objects.get_for_model(Compound)
|
||||||
|
Compound.objects.filter(polymorphic_ctype__isnull=True).update(polymorphic_ctype=compound_ct)
|
||||||
|
|
||||||
|
# Update CompoundStructure records
|
||||||
|
compound_structure_ct = ContentType.objects.get_for_model(CompoundStructure)
|
||||||
|
CompoundStructure.objects.filter(polymorphic_ctype__isnull=True).update(
|
||||||
|
polymorphic_ctype=compound_structure_ct
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_populate_polymorphic_ctype(apps, schema_editor):
|
||||||
|
Compound = apps.get_model("epdb", "Compound")
|
||||||
|
CompoundStructure = apps.get_model("epdb", "CompoundStructure")
|
||||||
|
|
||||||
|
Compound.objects.all().update(polymorphic_ctype=None)
|
||||||
|
CompoundStructure.objects.all().update(polymorphic_ctype=None)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
|
("epdb", "0019_remove_scenario_additional_information_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="compoundstructure",
|
||||||
|
options={"base_manager_name": "objects"},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="compound",
|
||||||
|
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.AddField(
|
||||||
|
model_name="compoundstructure",
|
||||||
|
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.RunPython(populate_polymorphic_ctype, reverse_populate_polymorphic_ctype),
|
||||||
|
]
|
||||||
@ -765,7 +765,12 @@ class Package(EnviPathModel):
|
|||||||
|
|
||||||
|
|
||||||
class Compound(
|
class Compound(
|
||||||
EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin, AdditionalInformationMixin
|
PolymorphicModel,
|
||||||
|
EnviPathModel,
|
||||||
|
AliasMixin,
|
||||||
|
ScenarioMixin,
|
||||||
|
ChemicalIdentifierMixin,
|
||||||
|
AdditionalInformationMixin,
|
||||||
):
|
):
|
||||||
package = models.ForeignKey(
|
package = models.ForeignKey(
|
||||||
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||||
@ -1095,7 +1100,12 @@ class Compound(
|
|||||||
|
|
||||||
|
|
||||||
class CompoundStructure(
|
class CompoundStructure(
|
||||||
EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin, AdditionalInformationMixin
|
PolymorphicModel,
|
||||||
|
EnviPathModel,
|
||||||
|
AliasMixin,
|
||||||
|
ScenarioMixin,
|
||||||
|
ChemicalIdentifierMixin,
|
||||||
|
AdditionalInformationMixin,
|
||||||
):
|
):
|
||||||
compound = models.ForeignKey("epdb.Compound", on_delete=models.CASCADE, db_index=True)
|
compound = models.ForeignKey("epdb.Compound", on_delete=models.CASCADE, db_index=True)
|
||||||
smiles = models.TextField(blank=False, null=False, verbose_name="SMILES")
|
smiles = models.TextField(blank=False, null=False, verbose_name="SMILES")
|
||||||
@ -4138,7 +4148,7 @@ class Scenario(EnviPathModel):
|
|||||||
ais = AdditionalInformation.objects.filter(scenario=self)
|
ais = AdditionalInformation.objects.filter(scenario=self)
|
||||||
|
|
||||||
if direct_only:
|
if direct_only:
|
||||||
return ais.filter(content_object__isnull=True)
|
return ais.filter(object_id__isnull=True)
|
||||||
else:
|
else:
|
||||||
return ais
|
return ais
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -71,24 +71,129 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">User or Group</span>
|
<span class="label-text">User or Group</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<div
|
||||||
id="select_grantee"
|
class="relative"
|
||||||
name="grantee"
|
x-data="{
|
||||||
class="select select-bordered w-full select-sm"
|
searchQuery: '',
|
||||||
required
|
selectedItem: null,
|
||||||
>
|
showResults: false,
|
||||||
<optgroup label="Users">
|
filteredResults: [],
|
||||||
|
allItems: [
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
<option value="{{ u.url }}">{{ u.username }}</option>
|
{ type: 'user', name: '{{ u.username }}', url: '{{ u.url }}',
|
||||||
|
display: '{{ u.username }}' },
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Groups">
|
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
<option value="{{ g.url }}">{{ g.name|safe }}</option>
|
{ type: 'group', name: '{{ g.name|safe }}', url: '{{ g.url }}',
|
||||||
|
display: '{{ g.name|safe }}' },
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</optgroup>
|
],
|
||||||
</select>
|
init() {
|
||||||
|
this.filteredResults = this.allItems;
|
||||||
|
},
|
||||||
|
search() {
|
||||||
|
if (this.searchQuery.length === 0) {
|
||||||
|
this.filteredResults = this.allItems;
|
||||||
|
} else {
|
||||||
|
this.filteredResults = this.allItems.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(this.searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.showResults = true;
|
||||||
|
},
|
||||||
|
selectItem(item) {
|
||||||
|
this.selectedItem = item;
|
||||||
|
this.searchQuery = item.display;
|
||||||
|
this.showResults = false;
|
||||||
|
},
|
||||||
|
clearSelection() {
|
||||||
|
this.selectedItem = null;
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.showResults = false;
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@click.away="showResults = false"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="searchQuery"
|
||||||
|
@input="search()"
|
||||||
|
@focus="showResults = true; search()"
|
||||||
|
@keydown.escape="showResults = false"
|
||||||
|
@keydown.arrow-down.prevent="$refs.resultsList?.children[0]?.focus()"
|
||||||
|
class="input input-bordered w-full input-sm"
|
||||||
|
placeholder="Search users or groups..."
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Clear button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-show="searchQuery.length > 0"
|
||||||
|
@click="clearSelection()"
|
||||||
|
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Hidden input for form submission -->
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="grantee"
|
||||||
|
x-bind:value="selectedItem?.url || ''"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Search results dropdown -->
|
||||||
|
<div
|
||||||
|
x-show="showResults && filteredResults.length > 0"
|
||||||
|
x-transition
|
||||||
|
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<ul x-ref="resultsList" id="resultsList" class="py-1">
|
||||||
|
<template
|
||||||
|
x-for="(item, index) in filteredResults"
|
||||||
|
:key="item.url"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="selectItem(item)"
|
||||||
|
@keydown.enter="selectItem(item)"
|
||||||
|
@keydown.escape="showResults = false"
|
||||||
|
@keydown.arrow-up.prevent="index > 0 ? $event.target.parentElement.previousElementSibling?.children[0]?.focus() : null"
|
||||||
|
@keydown.arrow-down.prevent="index < filteredResults.length - 1 ? $event.target.parentElement.nextElementSibling?.children[0]?.focus() : null"
|
||||||
|
class="w-full px-4 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
x-text="item.type === 'user' ? '👤' : '👥'"
|
||||||
|
class="text-sm opacity-60"
|
||||||
|
></span>
|
||||||
|
<span x-text="item.display"></span>
|
||||||
|
<span
|
||||||
|
x-text="item.type === 'user' ? '(User)' : '(Group)'"
|
||||||
|
class="text-xs opacity-50 ml-auto"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- No results message -->
|
||||||
|
<div
|
||||||
|
x-show="showResults && filteredResults.length === 0 && searchQuery.length > 0"
|
||||||
|
x-transition
|
||||||
|
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="px-4 py-2 text-gray-500 text-sm">
|
||||||
|
No users or groups found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-center">
|
<div class="col-span-2 text-center">
|
||||||
<label class="label justify-center">
|
<label class="label justify-center">
|
||||||
<span class="label-text">Read</span>
|
<span class="label-text">Read</span>
|
||||||
|
|||||||
@ -20,7 +20,16 @@ class TestPackagePage(EnviPyStaticLiveServerTestCase):
|
|||||||
page.get_by_role("button", name="Actions").click()
|
page.get_by_role("button", name="Actions").click()
|
||||||
page.get_by_role("button", name="Edit Permissions").click()
|
page.get_by_role("button", name="Edit Permissions").click()
|
||||||
# Add read and write permission to enviPath Users group
|
# Add read and write permission to enviPath Users group
|
||||||
page.locator("#select_grantee").select_option(label="enviPath Users")
|
search_input = page.locator('input[placeholder="Search users or groups..."]')
|
||||||
|
search_input.fill("enviPath")
|
||||||
|
|
||||||
|
# Wait for the results list to appear and be populated
|
||||||
|
page.wait_for_selector("#resultsList", state="visible")
|
||||||
|
|
||||||
|
# Click the first button in the results list
|
||||||
|
first_button = page.locator("#resultsList button").first
|
||||||
|
first_button.click()
|
||||||
|
|
||||||
page.locator("#read_new").check()
|
page.locator("#read_new").check()
|
||||||
page.locator("#write_new").check()
|
page.locator("#write_new").check()
|
||||||
page.get_by_role("button", name="+", exact=True).click()
|
page.get_by_role("button", name="+", exact=True).click()
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@ -841,7 +841,7 @@ provides-extras = ["ms-login", "dev", "pepper-plugin"]
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "envipy-additional-information"
|
name = "envipy-additional-information"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#40459366648a03b01432998b32fdabd5556a1bae" }
|
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#04f6a01b8c5cd1342464e004e0cfaec9abc13ac5" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user