Made few tweaks with the home page + remove useless functions
This commit is contained in:
parent
55e76b2803
commit
3a5d14952d
|
@ -49,12 +49,6 @@ from src.User import User
|
|||
|
||||
from utils import (
|
||||
check_settings,
|
||||
env_to_summary_class,
|
||||
form_plugin_gen,
|
||||
form_service_gen,
|
||||
form_service_gen_multiple,
|
||||
form_service_gen_multiple_values,
|
||||
gen_folders_tree_html,
|
||||
get_variables,
|
||||
path_to_dict,
|
||||
)
|
||||
|
@ -138,6 +132,8 @@ elif integration == "Kubernetes":
|
|||
kubernetes_client = kube_client.CoreV1Api()
|
||||
|
||||
db = Database(logger)
|
||||
with open("/usr/share/bunkerweb/VERSION", "r") as f:
|
||||
bw_version = f.read().strip()
|
||||
|
||||
try:
|
||||
app.config.update(
|
||||
|
@ -164,14 +160,6 @@ except FileNotFoundError as e:
|
|||
sys_exit(1)
|
||||
|
||||
# Declare functions for jinja2
|
||||
app.jinja_env.globals.update(env_to_summary_class=env_to_summary_class)
|
||||
app.jinja_env.globals.update(form_plugin_gen=form_plugin_gen)
|
||||
app.jinja_env.globals.update(form_service_gen=form_service_gen)
|
||||
app.jinja_env.globals.update(form_service_gen_multiple=form_service_gen_multiple)
|
||||
app.jinja_env.globals.update(
|
||||
form_service_gen_multiple_values=form_service_gen_multiple_values
|
||||
)
|
||||
app.jinja_env.globals.update(gen_folders_tree_html=gen_folders_tree_html)
|
||||
app.jinja_env.globals.update(check_settings=check_settings)
|
||||
|
||||
|
||||
|
@ -273,27 +261,30 @@ def home():
|
|||
posts: a list of posts
|
||||
"""
|
||||
|
||||
r = get(
|
||||
"https://raw.githubusercontent.com/bunkerity/bunkerweb/master/VERSION",
|
||||
)
|
||||
try:
|
||||
r = get(
|
||||
"https://raw.githubusercontent.com/bunkerity/bunkerweb/master/VERSION",
|
||||
)
|
||||
except BaseException:
|
||||
r = None
|
||||
remote_version = None
|
||||
|
||||
if r.status_code == 200:
|
||||
if r and r.status_code == 200:
|
||||
remote_version = r.text.strip()
|
||||
|
||||
with open("/usr/share/bunkerweb/VERSION", "r") as f:
|
||||
version = f.read().strip()
|
||||
|
||||
headers = default_headers()
|
||||
headers.update({"User-Agent": "bunkerweb-ui"})
|
||||
|
||||
r = get(
|
||||
"https://www.bunkerity.com/wp-json/wp/v2/posts",
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
r = get(
|
||||
"https://www.bunkerity.com/wp-json/wp/v2/posts",
|
||||
headers=headers,
|
||||
)
|
||||
except BaseException:
|
||||
r = None
|
||||
|
||||
formatted_posts = None
|
||||
if r.status_code == 200:
|
||||
if r and r.status_code == 200:
|
||||
posts = r.json()
|
||||
formatted_posts = []
|
||||
|
||||
|
@ -325,12 +316,13 @@ def home():
|
|||
|
||||
return render_template(
|
||||
"home.html",
|
||||
check_version=not remote_version or version == remote_version,
|
||||
check_version=not remote_version or bw_version == remote_version,
|
||||
remote_version=remote_version,
|
||||
version=version,
|
||||
version=bw_version,
|
||||
instances_number=instances_number,
|
||||
services_number=services_number,
|
||||
posts=formatted_posts,
|
||||
plugins_errors=db.get_plugins_errors(),
|
||||
dark_mode=app.config["DARK_MODE"],
|
||||
)
|
||||
|
||||
|
@ -1019,7 +1011,6 @@ def plugins():
|
|||
|
||||
return render_template(
|
||||
"plugins.html",
|
||||
plugins=app.config["CONFIG"].get_plugins(),
|
||||
dark_mode=app.config["DARK_MODE"],
|
||||
)
|
||||
|
||||
|
@ -1434,12 +1425,6 @@ def darkmode():
|
|||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.route("/plugins_errors", methods=["GET"])
|
||||
@login_required
|
||||
def plugins_errors():
|
||||
return jsonify({"status": "ok", "plugins_errors": db.get_plugins_errors()})
|
||||
|
||||
|
||||
@app.route("/check_reloading")
|
||||
@login_required
|
||||
def check_reloading():
|
||||
|
|
554
src/ui/utils.py
554
src/ui/utils.py
|
@ -1,10 +1,8 @@
|
|||
from datetime import datetime
|
||||
from typing import List, Union
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from typing import List
|
||||
from bs4 import Tag
|
||||
import magic
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def get_variables():
|
||||
|
@ -28,301 +26,6 @@ def log(event):
|
|||
f.write("[" + str(datetime.now().replace(microsecond=0)) + "] " + event + "\n")
|
||||
|
||||
|
||||
def env_to_summary_class(var, value):
|
||||
if type(var) is list and type(value) is list:
|
||||
for i in range(0, len(var)):
|
||||
if not isinstance(var[i], str):
|
||||
continue
|
||||
if re.search(value[i], var[i]):
|
||||
return "check text-success"
|
||||
return "times text-danger"
|
||||
if not isinstance(var, str):
|
||||
return "times text-danger"
|
||||
if re.search(value, var):
|
||||
return "check text-success"
|
||||
return "times text-danger"
|
||||
|
||||
|
||||
def form_service_gen(
|
||||
_id,
|
||||
_help,
|
||||
label,
|
||||
_type,
|
||||
value,
|
||||
name,
|
||||
default,
|
||||
selects,
|
||||
regex,
|
||||
from_html: bool = True,
|
||||
root: Tag = None,
|
||||
) -> str:
|
||||
if root is None:
|
||||
root = BeautifulSoup()
|
||||
|
||||
tooltip = Tag(
|
||||
name="div",
|
||||
attrs={
|
||||
"class": "px-2 d-sm-inline",
|
||||
"data-bs-toggle": "tooltip",
|
||||
"data-bs-placement": "bottom",
|
||||
"title": _help,
|
||||
},
|
||||
)
|
||||
tooltip.append(Tag(name="i", attrs={"class": "fas fa-question-circle"}))
|
||||
root.append(tooltip)
|
||||
pt = ""
|
||||
invalid_div = None
|
||||
|
||||
if _type in ("text", "number"):
|
||||
edit = Tag(
|
||||
name="input",
|
||||
attrs={
|
||||
"type": _type,
|
||||
"class": "form-control",
|
||||
"id": _id,
|
||||
"name": name,
|
||||
"value": value if value else default,
|
||||
"pattern": regex,
|
||||
},
|
||||
)
|
||||
|
||||
invalid_div = Tag(name="div", attrs={"class": "invalid-feedback"})
|
||||
invalid_div.append(
|
||||
BeautifulSoup(
|
||||
f"{label} is invalid and must match this pattern: <i><b>{regex}</b></i>",
|
||||
"html.parser",
|
||||
)
|
||||
)
|
||||
elif _type == "check":
|
||||
edit = Tag(
|
||||
name="div",
|
||||
attrs={"class": "form-check form-switch"},
|
||||
)
|
||||
edit.append(
|
||||
BeautifulSoup(
|
||||
f"<input type='checkbox' role='switch' class='form-check-input' id='{_id}' name='{name}' {'checked' if value and value == 'yes' or not value and default and default == 'yes' else ''}>",
|
||||
"html.parser",
|
||||
)
|
||||
)
|
||||
edit.append(
|
||||
Tag(
|
||||
name="input",
|
||||
attrs={
|
||||
"type": "hidden",
|
||||
"id": f"{_id}-hidden",
|
||||
"name": name,
|
||||
"value": "off",
|
||||
},
|
||||
)
|
||||
)
|
||||
pt = "pt-0"
|
||||
elif _type == "select":
|
||||
edit = Tag(
|
||||
name="select",
|
||||
attrs={
|
||||
"type": "form-select",
|
||||
"class": "form-control form-select",
|
||||
"id": _id,
|
||||
"name": name,
|
||||
},
|
||||
)
|
||||
|
||||
for select in selects:
|
||||
edit.append(
|
||||
BeautifulSoup(
|
||||
f"<option value='{select}' {'selected' if value and select == value or not value and select == default else ''}>{select}</option>",
|
||||
"html.parser",
|
||||
)
|
||||
)
|
||||
|
||||
label_tag = Tag(
|
||||
name="label", attrs={"for": _id, "class": f"flex-grow-1 d-sm-inline {pt}"}
|
||||
)
|
||||
label_tag.append(label)
|
||||
root.append(label_tag)
|
||||
|
||||
div = Tag(name="div", attrs={"class": "d-sm-inline"})
|
||||
div.append(edit)
|
||||
|
||||
if invalid_div:
|
||||
div.append(invalid_div)
|
||||
|
||||
root.append(div)
|
||||
|
||||
if from_html:
|
||||
return root.prettify()
|
||||
else:
|
||||
return root
|
||||
|
||||
|
||||
def form_service_gen_multiple(_id, label, params, root) -> Tag:
|
||||
label_tag = Tag(
|
||||
name="label",
|
||||
attrs={"for": f"{_id}-btn", "class": "flex-grow-1 d-sm-inline"},
|
||||
)
|
||||
label_tag.append(label)
|
||||
root.append(label_tag)
|
||||
|
||||
buttons = Tag(name="div", attrs={"class": "d-sm-inline", "id": _id})
|
||||
|
||||
add_button = Tag(
|
||||
name="button",
|
||||
attrs={
|
||||
"class": "btn btn-outline-success",
|
||||
"type": "button",
|
||||
"onClick": f"addMultiple('{_id}', '{json.dumps(params)}');",
|
||||
},
|
||||
)
|
||||
add_button.append(Tag(name="i", attrs={"class": "fas fa-plus"}))
|
||||
add_button.append(" Add")
|
||||
buttons.append(add_button)
|
||||
|
||||
del_button = Tag(
|
||||
name="button",
|
||||
attrs={
|
||||
"class": "btn btn-outline-danger",
|
||||
"type": "button",
|
||||
"onClick": f"delMultiple('{_id}', '{json.dumps(params)}');",
|
||||
},
|
||||
)
|
||||
del_button.append(Tag(name="i", attrs={"class": "fas fa-trash"}))
|
||||
del_button.append(" Delete")
|
||||
buttons.append(del_button)
|
||||
|
||||
root.append(buttons)
|
||||
return root
|
||||
|
||||
|
||||
def form_service_gen_multiple_values(_id, params, service) -> Union[Tag, str]:
|
||||
script = Tag(name="script", attrs={"defer": True})
|
||||
values = []
|
||||
for env in sorted(
|
||||
service,
|
||||
reverse=True,
|
||||
key=lambda x: int(re.search(r"\d+$", x).group()) if x[-1].isdigit() else 0,
|
||||
):
|
||||
for param, param_value in params.items():
|
||||
suffix = env.replace(param, "")
|
||||
if env.startswith(param):
|
||||
values.append(
|
||||
{
|
||||
"default": service.get(
|
||||
f"{param}{suffix}", param_value["default"]
|
||||
),
|
||||
"env": param,
|
||||
"help": param_value["help"],
|
||||
"id": param_value["id"],
|
||||
"label": param_value["label"],
|
||||
"selects": param_value.get("selects", []),
|
||||
"type": param_value["type"],
|
||||
}
|
||||
)
|
||||
|
||||
if len(values) >= len(params):
|
||||
script.append(f"addMultiple('{_id}', '{json.dumps(values)}');")
|
||||
values = []
|
||||
|
||||
return script if script.children else ""
|
||||
|
||||
|
||||
def form_plugin_gen(
|
||||
service: dict,
|
||||
plugin: dict,
|
||||
action: str,
|
||||
id_server_name: str = None,
|
||||
*,
|
||||
context: str = "multisite",
|
||||
) -> str:
|
||||
soup = BeautifulSoup()
|
||||
soup.append(
|
||||
Tag(
|
||||
name="div",
|
||||
attrs={
|
||||
"class": "tab-pane fade",
|
||||
"id": f"{action}-{plugin['id']}"
|
||||
+ (f"-{id_server_name}" if id_server_name else ""),
|
||||
"role": "tabpanel",
|
||||
"aria-labelledby": f"{action}-{plugin['id']}"
|
||||
+ (f"-{id_server_name}" if id_server_name else "")
|
||||
+ "-tab",
|
||||
},
|
||||
)
|
||||
)
|
||||
multiple_plugins = {}
|
||||
multiple = None
|
||||
|
||||
for setting, value in plugin["settings"].items():
|
||||
if value["context"] == context:
|
||||
if "multiple" in value:
|
||||
if multiple != value["multiple"]:
|
||||
multiple = value["multiple"]
|
||||
multiple_plugins[multiple] = {}
|
||||
multiple_plugins[multiple][setting] = value
|
||||
multiple_plugins[multiple][setting]["env"] = setting
|
||||
else:
|
||||
div = Tag(
|
||||
name="div",
|
||||
attrs={
|
||||
"class": "d-flex flex-row justify-content-between align-items-center mb-3",
|
||||
"id": f"form-{action}"
|
||||
+ (f"-{id_server_name}" if id_server_name else "")
|
||||
+ f"-{value['id']}",
|
||||
},
|
||||
)
|
||||
|
||||
div = form_service_gen(
|
||||
f"form-{action}"
|
||||
+ (f"-{id_server_name}" if id_server_name else "")
|
||||
+ f"-{value['id']}",
|
||||
value["help"],
|
||||
value["label"],
|
||||
value["type"],
|
||||
service.get(setting, value["default"]),
|
||||
setting,
|
||||
value["default"],
|
||||
value.get("select", None),
|
||||
value["regex"],
|
||||
False,
|
||||
div,
|
||||
)
|
||||
|
||||
soup.div.append(div)
|
||||
|
||||
for multiple_plugin in multiple_plugins:
|
||||
div = Tag(
|
||||
name="div",
|
||||
attrs={
|
||||
"class": "d-flex flex-row justify-content-between align-items-center mb-3",
|
||||
"id": f"form-{action}"
|
||||
+ (f"-{id_server_name}" if id_server_name else "")
|
||||
+ f"-{multiple_plugin}",
|
||||
},
|
||||
)
|
||||
|
||||
div = form_service_gen_multiple(
|
||||
f"form-{action}"
|
||||
+ (f"-{id_server_name}" if id_server_name else "")
|
||||
+ f"-{multiple_plugin}",
|
||||
plugin["name"],
|
||||
multiple_plugins[multiple_plugin],
|
||||
div,
|
||||
)
|
||||
if service:
|
||||
div.append(
|
||||
form_service_gen_multiple_values(
|
||||
f"form-{action}"
|
||||
+ (f"-{id_server_name}" if id_server_name else "")
|
||||
+ f"-{multiple_plugin}",
|
||||
multiple_plugins[multiple_plugin],
|
||||
service,
|
||||
)
|
||||
)
|
||||
|
||||
soup.div.append(div)
|
||||
|
||||
return soup.prettify()
|
||||
|
||||
|
||||
def path_to_dict(
|
||||
path,
|
||||
*,
|
||||
|
@ -468,256 +171,5 @@ def path_to_dict(
|
|||
return d
|
||||
|
||||
|
||||
options = {
|
||||
"can_edit": {
|
||||
"button_style": "btn-outline-primary",
|
||||
"data-bs-target": "#modal-edit-new-folder",
|
||||
"icon": "fa-solid fa-pen-to-square",
|
||||
},
|
||||
"can_delete": {
|
||||
"button_style": "btn-outline-danger",
|
||||
"data-bs-target": "#modal-delete",
|
||||
"icon": "fa-solid fa-trash-can",
|
||||
},
|
||||
"can_create_folders": {
|
||||
"button_style": "btn-outline-success",
|
||||
"data-bs-target": "#modal-edit-new-folder",
|
||||
"icon": "fa-solid fa-folder",
|
||||
},
|
||||
"can_create_files": {
|
||||
"button_style": "btn-outline-success",
|
||||
"data-bs-target": "#modal-edit-new-file",
|
||||
"icon": "fa-solid fa-file",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def gen_folders_tree_html(data: list) -> str:
|
||||
html = "<ul class='folder-container'>"
|
||||
|
||||
for _dict in sorted(data, key=lambda x: x["type"] == "file", reverse=True):
|
||||
path_id = _dict["path"].replace("/", "-")[1:]
|
||||
root_li = Tag(
|
||||
name="li",
|
||||
attrs={
|
||||
"class": f"{_dict['type']}-item d-flex align-items-center",
|
||||
},
|
||||
)
|
||||
|
||||
if _dict["type"] == "folder":
|
||||
root_div = Tag(
|
||||
name="div",
|
||||
attrs={
|
||||
"class": "collapse-div",
|
||||
"data-bs-toggle": "collapse",
|
||||
"href": f"#{path_id}",
|
||||
"role": "button",
|
||||
"aria-expanded": "true",
|
||||
"aria-controls": path_id,
|
||||
},
|
||||
)
|
||||
|
||||
h6 = Tag(name="span", attrs={"class": "mb-0 pl-2"})
|
||||
h6.append(_dict["name"])
|
||||
|
||||
if _dict.get("children", False):
|
||||
root_div.append(
|
||||
Tag(
|
||||
name="span",
|
||||
attrs={"class": f"fa-solid fa-play fa-xs rotate-icon down"},
|
||||
)
|
||||
)
|
||||
|
||||
root_div.append(h6)
|
||||
|
||||
root_li.append(root_div)
|
||||
|
||||
if any(
|
||||
[
|
||||
_dict["can_create_files"],
|
||||
_dict["can_create_folders"],
|
||||
_dict["can_edit"],
|
||||
_dict["can_delete"],
|
||||
]
|
||||
):
|
||||
div = Tag(
|
||||
name="div", attrs={"class": "ms-2 d-sm-flex align-items-between"}
|
||||
)
|
||||
dropdown = None
|
||||
|
||||
if any(
|
||||
[
|
||||
_dict["can_create_files"],
|
||||
_dict["can_create_folders"],
|
||||
]
|
||||
):
|
||||
dropdown = Tag(name="div", attrs={"class": "badge dropdown"})
|
||||
button = Tag(
|
||||
name="button",
|
||||
attrs={
|
||||
"type": "button",
|
||||
"class": "btn btn-outline-success",
|
||||
"id": "dropdownSettingsButton",
|
||||
"data-bs-toggle": "dropdown",
|
||||
"aria-expanded": "false",
|
||||
},
|
||||
)
|
||||
button.append(Tag(name="i", attrs={"class": "fa-solid fa-plus"}))
|
||||
dropdown.append(button)
|
||||
ul = Tag(
|
||||
name="ul",
|
||||
attrs={
|
||||
"class": "dropdown-menu",
|
||||
"aria-labelledby": "dropdownSettingsButton",
|
||||
},
|
||||
)
|
||||
|
||||
for option in options:
|
||||
if _dict[option]:
|
||||
if option in ("can_create_files", "can_create_folders"):
|
||||
li = Tag(name="li")
|
||||
|
||||
button = Tag(
|
||||
name="button",
|
||||
attrs={
|
||||
"class": "dropdown-item",
|
||||
"data-bs-toggle": "modal",
|
||||
"data-bs-target": options[option]["data-bs-target"],
|
||||
"data-path": _dict["path"],
|
||||
"data-action": "new",
|
||||
},
|
||||
)
|
||||
button.append(
|
||||
Tag(
|
||||
name="i",
|
||||
attrs={
|
||||
"class": options[option]["icon"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
span = Tag(
|
||||
name="span",
|
||||
attrs={"class": "pl-2"},
|
||||
)
|
||||
span.append(option.split("_")[-1][0:-1].title())
|
||||
button.append(span)
|
||||
|
||||
li.append(button)
|
||||
ul.append(li)
|
||||
else:
|
||||
badge = Tag(name="div", attrs={"class": "badge px-1"})
|
||||
action = option.split("_")[1]
|
||||
button = Tag(
|
||||
name="button",
|
||||
attrs={
|
||||
"class": f"btn {options[option]['button_style']}",
|
||||
"data-bs-toggle": "modal",
|
||||
"data-bs-target": options[option]["data-bs-target"],
|
||||
"data-path": _dict["path"],
|
||||
}
|
||||
| (
|
||||
{"data-action": action}
|
||||
if action != "delete"
|
||||
else {}
|
||||
),
|
||||
)
|
||||
button.append(
|
||||
Tag(name="i", attrs={"class": options[option]["icon"]})
|
||||
)
|
||||
|
||||
badge.append(button)
|
||||
div.append(badge)
|
||||
|
||||
if dropdown:
|
||||
dropdown.append(ul)
|
||||
div.append(dropdown)
|
||||
|
||||
root_li.append(div)
|
||||
|
||||
html += root_li.prettify()
|
||||
html += (
|
||||
f"<li class='collapse folder-wrapper show' id={path_id}>"
|
||||
if _dict.get("children", False)
|
||||
else "<li class='folder-wrapper'>"
|
||||
)
|
||||
|
||||
if _dict.get("children", False):
|
||||
html += gen_folders_tree_html(_dict["children"])
|
||||
html += "</ul></li>"
|
||||
else:
|
||||
h6 = Tag(name="h6", attrs={"class": "mb-0"})
|
||||
h6.append(_dict["name"])
|
||||
root_li.append(h6)
|
||||
|
||||
div = Tag(name="div", attrs={"class": "ms-2 d-sm-flex align-items-between"})
|
||||
|
||||
if _dict["can_edit"]:
|
||||
badge = Tag(name="div", attrs={"class": "badge px-1"})
|
||||
button = Tag(
|
||||
name="button",
|
||||
attrs={
|
||||
"class": f"btn btn-outline-primary",
|
||||
"data-bs-toggle": "modal",
|
||||
"data-bs-target": "#modal-edit-new-file",
|
||||
"data-path": _dict["path"],
|
||||
"data-content": _dict["content"],
|
||||
"data-action": "edit",
|
||||
},
|
||||
)
|
||||
button.append(
|
||||
Tag(name="i", attrs={"class": "fa-solid fa-pen-to-square"})
|
||||
)
|
||||
badge.append(button)
|
||||
div.append(badge)
|
||||
|
||||
badge = Tag(name="div", attrs={"class": "badge px-1"})
|
||||
button = Tag(
|
||||
name="button",
|
||||
attrs={
|
||||
"class": f"btn btn-outline-danger",
|
||||
"data-bs-toggle": "modal",
|
||||
"data-bs-target": "#modal-delete",
|
||||
"data-path": _dict["path"],
|
||||
},
|
||||
)
|
||||
button.append(Tag(name="i", attrs={"class": "fa-solid fa-trash-can"}))
|
||||
badge.append(button)
|
||||
div.append(badge)
|
||||
elif "content" in _dict:
|
||||
badge = Tag(name="div", attrs={"class": "badge px-1"})
|
||||
button = Tag(
|
||||
name="button",
|
||||
attrs={
|
||||
"class": f"btn btn-outline-secondary",
|
||||
"data-bs-toggle": "modal",
|
||||
"data-bs-target": "#modal-see-file",
|
||||
"data-path": _dict["path"],
|
||||
"data-content": _dict["content"],
|
||||
},
|
||||
)
|
||||
button.append(Tag(name="i", attrs={"class": "fa-solid fa-eye"}))
|
||||
badge.append(button)
|
||||
div.append(badge)
|
||||
|
||||
if _dict["can_download"]:
|
||||
badge = Tag(name="div", attrs={"class": "badge px-1"})
|
||||
button = Tag(
|
||||
name="button",
|
||||
attrs={
|
||||
"class": f"btn btn-outline-primary download-button",
|
||||
"data-path": _dict["path"],
|
||||
},
|
||||
)
|
||||
button.append(Tag(name="i", attrs={"class": "fa-solid fa-download"}))
|
||||
badge.append(button)
|
||||
div.append(badge)
|
||||
|
||||
root_li.append(div)
|
||||
html += root_li.prettify()
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def check_settings(settings: dict, check: str) -> bool:
|
||||
return any(setting["context"] == check for setting in settings.values())
|
||||
return not any(setting["context"] != check for setting in settings.values())
|
||||
|
|
Loading…
Reference in New Issue