forked from enviPath/enviPy
minor
This commit is contained in:
@ -30,12 +30,13 @@ RUN mkdir -p -m 0700 /root/.ssh \
|
|||||||
&& ssh-keyscan git.envipath.com >> /root/.ssh/known_hosts
|
&& ssh-keyscan git.envipath.com >> /root/.ssh/known_hosts
|
||||||
|
|
||||||
# We'll need access to private repos, let docker make use of host ssh agent and use it like:
|
# We'll need access to private repos, let docker make use of host ssh agent and use it like:
|
||||||
# docker build --ssh default -t envipath/envipy:1.0 .
|
# docker build --ssh default -t envipath/envipy-bayer:1.0 .
|
||||||
RUN --mount=type=ssh \
|
RUN --mount=type=ssh \
|
||||||
uv sync --locked --extra ms-login --extra pepper-plugin
|
uv sync --locked --extra ms-login --extra pepper-plugin
|
||||||
|
|
||||||
# Now copy source and do a final sync to install the project itself
|
# Now copy source and do a final sync to install the project itself
|
||||||
# Ensure .dockerignore is reasonable
|
# Ensure .dockerignore is reasonable
|
||||||
|
COPY bb4g bb4g
|
||||||
COPY biotransformer biotransformer
|
COPY biotransformer biotransformer
|
||||||
COPY bayer bayer
|
COPY bayer bayer
|
||||||
COPY bridge bridge
|
COPY bridge bridge
|
||||||
|
|||||||
@ -103,20 +103,15 @@ def create_pes_node(request, package_uuid, pathway_uuid):
|
|||||||
|
|
||||||
|
|
||||||
def fetch_pes(request, pes_url) -> dict:
|
def fetch_pes(request, pes_url) -> dict:
|
||||||
proxies = {
|
|
||||||
"http": "http://10.185.190.100:8080",
|
|
||||||
"https": "http://10.185.190.100:8080",
|
|
||||||
}
|
|
||||||
|
|
||||||
from epauth.views import get_access_token_from_request
|
from epauth.views import get_access_token_from_request
|
||||||
token = get_access_token_from_request(request)
|
token = get_access_token_from_request(request)
|
||||||
|
|
||||||
if token or True:
|
if token:
|
||||||
for k, v in s.PES_API_MAPPING.items():
|
for k, v in s.PES_API_MAPPING.items():
|
||||||
if pes_url.startswith(k):
|
if pes_url.startswith(k):
|
||||||
pes_id = pes_url.split('/')[-1]
|
pes_id = pes_url.split('/')[-1]
|
||||||
|
|
||||||
if pes_id == 'dummy' or True:
|
if pes_id == 'dummy':
|
||||||
import json
|
import json
|
||||||
res_data = json.load(open(s.BASE_DIR / "fixtures/pes.json"))
|
res_data = json.load(open(s.BASE_DIR / "fixtures/pes.json"))
|
||||||
res_data["pes_url"] = pes_url
|
res_data["pes_url"] = pes_url
|
||||||
@ -125,7 +120,7 @@ def fetch_pes(request, pes_url) -> dict:
|
|||||||
headers = {"Authorization": f"Bearer {token['access_token']}"}
|
headers = {"Authorization": f"Bearer {token['access_token']}"}
|
||||||
params = {"pes_reg_entity_corporate_id": pes_id}
|
params = {"pes_reg_entity_corporate_id": pes_id}
|
||||||
|
|
||||||
res = requests.get(v, headers=headers, params=params, proxies=proxies)
|
res = requests.get(v, headers=headers, params=params, proxies=s.PROXIES or None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import json
|
|||||||
import math
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
import enum
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
|
from envipy_additional_information import EnviPyModel, UIConfig, WidgetType
|
||||||
|
from envipy_additional_information import register
|
||||||
|
|
||||||
from bridge.contracts import Classifier # noqa: I001
|
from bridge.contracts import Classifier # noqa: I001
|
||||||
from bridge.dto import (
|
from bridge.dto import (
|
||||||
@ -15,12 +17,36 @@ from bridge.dto import (
|
|||||||
TransformationProductPrediction,
|
TransformationProductPrediction,
|
||||||
) # noqa: I001
|
) # noqa: I001
|
||||||
|
|
||||||
|
class SamplingAlgorithm(enum.Enum):
|
||||||
|
EXACT = "exact"
|
||||||
|
|
||||||
|
|
||||||
|
@register("bb4gconfig")
|
||||||
|
class BB4GConfig(EnviPyModel):
|
||||||
|
sampling_algorithm: SamplingAlgorithm = SamplingAlgorithm.EXACT
|
||||||
|
cutoff: int = -5
|
||||||
|
|
||||||
|
class UI:
|
||||||
|
title = "BB4G Configuration"
|
||||||
|
sampling_algorithm = UIConfig(
|
||||||
|
widget=WidgetType.SELECT,
|
||||||
|
label="BB4G Sampling Algorithm",
|
||||||
|
order=1,
|
||||||
|
placeholder="If unset defaults to 'exact'"
|
||||||
|
)
|
||||||
|
cutoff = UIConfig(
|
||||||
|
widget=WidgetType.NUMBER,
|
||||||
|
label="BB4G Cutoff",
|
||||||
|
order=2,
|
||||||
|
placeholder="If unset defaults to -5"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Once stable these will be exposed by enviPy-plugins lib
|
# Once stable these will be exposed by enviPy-plugins lib
|
||||||
class BB4G(Classifier):
|
class BB4G(Classifier):
|
||||||
Config = None
|
Config = BB4GConfig
|
||||||
|
|
||||||
def __init__(self, config=None):
|
def __init__(self, config: BB4GConfig | None = None):
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.url = f"{s.BB4G_URL}"
|
self.url = f"{s.BB4G_URL}"
|
||||||
|
|
||||||
@ -29,10 +55,6 @@ class BB4G(Classifier):
|
|||||||
"Authorization": f"Bearer {self.token}",
|
"Authorization": f"Bearer {self.token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
self.proxies = {
|
|
||||||
"http": s.HTTP_PROXY,
|
|
||||||
"https": s.HTTPS_PROXY,
|
|
||||||
}
|
|
||||||
|
|
||||||
def acquire_token(self):
|
def acquire_token(self):
|
||||||
BB4G_TENANT_ID = s.BB4G_TENANT_ID
|
BB4G_TENANT_ID = s.BB4G_TENANT_ID
|
||||||
@ -65,7 +87,7 @@ class BB4G(Classifier):
|
|||||||
started = False
|
started = False
|
||||||
retries = 0
|
retries = 0
|
||||||
while not started and retries < 5:
|
while not started and retries < 5:
|
||||||
res = requests.post(f"{self.url}/start", headers=header, data={}, proxies=self.proxies)
|
res = requests.post(f"{self.url}/start", headers=header, data={}, proxies=s.PROXIES or None)
|
||||||
|
|
||||||
if res.status_code == 200:
|
if res.status_code == 200:
|
||||||
started = True
|
started = True
|
||||||
@ -140,11 +162,11 @@ class BB4G(Classifier):
|
|||||||
for smi in smiles:
|
for smi in smiles:
|
||||||
data = {
|
data = {
|
||||||
"smiles": smi,
|
"smiles": smi,
|
||||||
"sampling_alg": "exact",
|
"sampling_alg": self.config.sampling_algorithm.value,
|
||||||
"cutoff": -5,
|
"cutoff": self.config.cutoff,
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data), proxies=self.proxies)
|
resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data), proxies=s.PROXIES or None)
|
||||||
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|||||||
@ -254,7 +254,15 @@ class Classifier(Plugin):
|
|||||||
def parse_config(cls, data: dict | None = None) -> EnviPyModel | None:
|
def parse_config(cls, data: dict | None = None) -> EnviPyModel | None:
|
||||||
if cls.Config is None:
|
if cls.Config is None:
|
||||||
return None
|
return None
|
||||||
return cls.Config(**(data or {}))
|
|
||||||
|
# remove empty strings a.k.a unset params to not overwrite defaults
|
||||||
|
cpy = {}
|
||||||
|
if data is not None:
|
||||||
|
for k, v in data.items():
|
||||||
|
if v != "":
|
||||||
|
cpy[k] = v
|
||||||
|
|
||||||
|
return cls.Config(**cpy)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, data: dict | None = None):
|
def create(cls, data: dict | None = None):
|
||||||
|
|||||||
@ -143,6 +143,12 @@ if os.environ.get("USE_TEMPLATE_DB", False) == "True":
|
|||||||
"TEMPLATE": os.environ["TEMPLATE_DB"],
|
"TEMPLATE": os.environ["TEMPLATE_DB"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||||
|
"LOCATION": "unique-snowflake",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
||||||
@ -474,3 +480,14 @@ if DATA_POOL_MAPPING:
|
|||||||
else:
|
else:
|
||||||
DATA_POOL_MAPPING = {}
|
DATA_POOL_MAPPING = {}
|
||||||
|
|
||||||
|
PROXIES = {}
|
||||||
|
if os.environ.get("HTTP_PROXY"):
|
||||||
|
PROXIES["http"] = os.environ.get("HTTP_PROXY")
|
||||||
|
PROXIES["https"] = os.environ.get("HTTPS_PROXY")
|
||||||
|
|
||||||
|
# BB4g
|
||||||
|
BB4G_URL = os.environ.get("BB4G_URL")
|
||||||
|
BB4G_TENANT_ID = os.environ.get("BB4G_TENANT_ID")
|
||||||
|
BB4G_CLIENT_ID = os.environ.get("BB4G_CLIENT_ID")
|
||||||
|
BB4G_CLIENT_SECRET = os.environ.get("BB4G_CLIENT_SECRET")
|
||||||
|
BB4G_SCOPE = os.environ.get("BB4G_SCOPE")
|
||||||
|
|||||||
@ -5,4 +5,5 @@ from . import views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("entra/login/", views.entra_login, name="entra_login"),
|
path("entra/login/", views.entra_login, name="entra_login"),
|
||||||
path("auth/redirect/", views.entra_callback, name="entra_callback"),
|
path("auth/redirect/", views.entra_callback, name="entra_callback"),
|
||||||
|
path("auth/token/", views.get_token, name="get_token"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import msal
|
|||||||
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.contrib.auth import login
|
from django.contrib.auth import login
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
|
|
||||||
from epdb.logic import UserManager
|
from epdb.logic import UserManager, GroupManager
|
||||||
|
from epdb.models import Group
|
||||||
|
|
||||||
|
|
||||||
def get_msal_app_with_cache(request):
|
def get_msal_app_with_cache(request):
|
||||||
@ -81,10 +83,12 @@ def entra_callback(request):
|
|||||||
login(request, u)
|
login(request, u)
|
||||||
|
|
||||||
# EDIT START
|
# EDIT START
|
||||||
|
|
||||||
# Ensure groups exists in eP
|
# Ensure groups exists in eP
|
||||||
for id, name in s.ENTRA_SECRET_GROUPS.items():
|
for id, name in s.ENTRA_SECRET_GROUPS.items():
|
||||||
if not Group.objects.filter(uuid=id).exists():
|
if not Group.objects.filter(uuid=id).exists():
|
||||||
g = GroupManager.create_group(User.objects.get(username="admin"), name, f"Synced Entra Group {name} ", uuid=id)
|
g = GroupManager.create_group(User.objects.get(username="admin"), name, f"Synced Entra Group {name} ",
|
||||||
|
uuid=id)
|
||||||
else:
|
else:
|
||||||
g = Group.objects.get(uuid=id)
|
g = Group.objects.get(uuid=id)
|
||||||
# Ensure its secret
|
# Ensure its secret
|
||||||
@ -93,7 +97,8 @@ def entra_callback(request):
|
|||||||
|
|
||||||
for id, name in s.ENTRA_GROUPS.items():
|
for id, name in s.ENTRA_GROUPS.items():
|
||||||
if not Group.objects.filter(uuid=id).exists():
|
if not Group.objects.filter(uuid=id).exists():
|
||||||
g = GroupManager.create_group(User.objects.get(username="admin"), name, f"Synced Entra Group {name} ", uuid=id)
|
g = GroupManager.create_group(User.objects.get(username="admin"), name, f"Synced Entra Group {name} ",
|
||||||
|
uuid=id)
|
||||||
else:
|
else:
|
||||||
g = Group.objects.get(uuid=id)
|
g = Group.objects.get(uuid=id)
|
||||||
|
|
||||||
@ -152,3 +157,9 @@ def get_access_token_from_request(request, scopes=None):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_token(request):
|
||||||
|
token = get_access_token_from_request(request)
|
||||||
|
msg = f"{token}"
|
||||||
|
return HttpResponse(msg, content_type='text/plain')
|
||||||
|
|||||||
@ -1,19 +1,17 @@
|
|||||||
import hashlib
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import requests
|
|
||||||
|
|
||||||
import nh3
|
import nh3
|
||||||
|
import requests
|
||||||
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.cache import cache
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
|
from jwt import InvalidIssuerError
|
||||||
from ninja import Field, Form, Query, Router, Schema
|
from ninja import Field, Form, Query, Router, Schema
|
||||||
from ninja.errors import HttpError
|
|
||||||
from ninja.security import HttpBearer
|
from ninja.security import HttpBearer
|
||||||
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
|
||||||
@ -51,6 +49,26 @@ from .models import (
|
|||||||
Package = s.GET_PACKAGE_MODEL()
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_jwks(tenant_id: str, force=False) -> Dict:
|
||||||
|
"""Get JWKS using Django cache"""
|
||||||
|
cache_key = f"jwks_{tenant_id}"
|
||||||
|
|
||||||
|
jwks = cache.get(cache_key)
|
||||||
|
|
||||||
|
if jwks is None or force:
|
||||||
|
# Cache miss, fetch new keys
|
||||||
|
jwks_uri = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
|
||||||
|
response = requests.get(jwks_uri)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
jwks = response.json()
|
||||||
|
|
||||||
|
# Cache for 1 hour (3600 seconds)
|
||||||
|
cache.set(cache_key, jwks, 3600)
|
||||||
|
|
||||||
|
return jwks
|
||||||
|
|
||||||
|
|
||||||
def get_package_for_write(user, package_uuid):
|
def get_package_for_write(user, package_uuid):
|
||||||
p = PackageManager.get_package_by_id(user, package_uuid)
|
p = PackageManager.get_package_by_id(user, package_uuid)
|
||||||
if not PackageManager.writable(user, p):
|
if not PackageManager.writable(user, p):
|
||||||
@ -68,9 +86,7 @@ def validate_token(token: str) -> dict:
|
|||||||
TENANT_ID = s.MS_ENTRA_TENANT_ID
|
TENANT_ID = s.MS_ENTRA_TENANT_ID
|
||||||
CLIENT_ID = s.MS_ENTRA_CLIENT_ID
|
CLIENT_ID = s.MS_ENTRA_CLIENT_ID
|
||||||
|
|
||||||
# Fetch Microsoft's public keys
|
jwks = get_cached_jwks(TENANT_ID)
|
||||||
jwks_uri = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys"
|
|
||||||
jwks = requests.get(jwks_uri).json()
|
|
||||||
|
|
||||||
header = jwt.get_unverified_header(token)
|
header = jwt.get_unverified_header(token)
|
||||||
|
|
||||||
@ -78,13 +94,21 @@ def validate_token(token: str) -> dict:
|
|||||||
next(k for k in jwks["keys"] if k["kid"] == header["kid"])
|
next(k for k in jwks["keys"] if k["kid"] == header["kid"])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Handle V1 and V2 tokens
|
||||||
|
try:
|
||||||
claims = jwt.decode(
|
claims = jwt.decode(
|
||||||
token,
|
token,
|
||||||
public_key,
|
public_key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
audience=[CLIENT_ID, f"api://{CLIENT_ID}"],
|
audience=[CLIENT_ID, f"api://{CLIENT_ID}"],
|
||||||
issuer=f"https://sts.windows.net/{TENANT_ID}/",
|
issuer=[
|
||||||
|
f"https://sts.windows.net/{TENANT_ID}/",
|
||||||
|
f"https://login.microsoftonline.com/{TENANT_ID}/v2.0"
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Token verification failed! - {e}")
|
||||||
|
|
||||||
return claims
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -937,12 +937,14 @@ def package_models(request, package_uuid):
|
|||||||
"requires_rule_packages": True,
|
"requires_rule_packages": True,
|
||||||
"requires_data_packages": True,
|
"requires_data_packages": True,
|
||||||
},
|
},
|
||||||
"EnviFormer": {
|
}
|
||||||
|
|
||||||
|
if s.ENVIFORMER_PRESENT:
|
||||||
|
context["model_types"]["EnviFormer"] = {
|
||||||
"type": "enviformer",
|
"type": "enviformer",
|
||||||
"requires_rule_packages": False,
|
"requires_rule_packages": False,
|
||||||
"requires_data_packages": True,
|
"requires_data_packages": True,
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
|
||||||
if s.FLAGS.get("PLUGINS", False):
|
if s.FLAGS.get("PLUGINS", False):
|
||||||
for k, v in s.CLASSIFIER_PLUGINS.items():
|
for k, v in s.CLASSIFIER_PLUGINS.items():
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
{% block page_title %}Models{% endblock %}
|
{% block page_title %}Models{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
|
{% if meta.enabled_features.MODEL_BUILDING %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
@ -12,6 +13,7 @@
|
|||||||
New Model
|
New Model
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endblock action_button %}
|
{% endblock action_button %}
|
||||||
|
|
||||||
{% block action_modals %}
|
{% block action_modals %}
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
{% block page_title %}Packages{% endblock %}
|
{% block page_title %}Packages{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -71,7 +70,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% endblock action_button %}
|
{% endblock action_button %}
|
||||||
|
|
||||||
{% block action_modals %}
|
{% block action_modals %}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
{% block page_title %}Pathways{% endblock %}
|
{% block page_title %}Pathways{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a
|
<a
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
{% block page_title %}Reactions{% endblock %}
|
{% block page_title %}Reactions{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
{% block page_title %}Rules{% endblock %}
|
{% block page_title %}Rules{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
{% endblock action_modals %}
|
{% endblock action_modals %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
{% block page_title %}{{ page_title|default:"Structures" }}{% endblock %}
|
{% block page_title %}{{ page_title|default:"Structures" }}{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user