mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-11-14 13:21:05 +00:00
[Feature] openbb-platform-api - Create Entry In widgets.json For Forms (#7055)
* define pattern for automating widget entry for forms * Add screenshot * linters * linters * linters * too many locals * linters * linters * difference between date and _date * more to caps strings * catch exclude in params config * skip finding columnsDefs for multi file viewer * catch edge case * fix tests --------- Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
This commit is contained in:
106
openbb_platform/extensions/platform_api/README.md
vendored
106
openbb_platform/extensions/platform_api/README.md
vendored
@@ -418,6 +418,112 @@ async def hello_chart() -> dict:
|
||||
return fig.to_plotly_json()
|
||||
```
|
||||
|
||||
### Form Submit Widget
|
||||
|
||||
When submitted, Workspace makes a POST request to the endpoint.
|
||||
|
||||
If the POST function returns a 200 status code, the widget associated with the GET function is refreshed.
|
||||
|
||||
The results of the GET function does not have to correspond with the parameters and results of the POST function.
|
||||
|
||||
For example, the response to submitting a form can be a Markdown widget with a custom message.
|
||||
|
||||
The entry in `widgets.json` will be automatically created if the conditions below are met:
|
||||
|
||||
- GET and POST methods must share the same API route.
|
||||
- POST method takes 1 positional argument, a sub-class of Pydantic BaseModel.
|
||||
- Create a model, like annotated table fields, defining all inputs to the form.
|
||||
|
||||
#### Example
|
||||
|
||||
The code below creates a widget with a form as the input, and an output table of all submitted forms, as processed through the `IntakeForm` model.
|
||||
|
||||
```python
|
||||
import uuid
|
||||
from datetime import date as dateType
|
||||
from typing import Literal, Union
|
||||
|
||||
# from fastapi import FastAPI
|
||||
from openbb_platform_api.query_models import FormData
|
||||
from openbb_platform_api.response_models import Data
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
# app = FastAPI()
|
||||
|
||||
AccountTypes = Literal["General Fund", "Separately Managed", "Private Equity", "Family Office"]
|
||||
|
||||
class GeneralIntake(FormData):
|
||||
"""Submit a form via POST request."""
|
||||
|
||||
date_created: dateType = Field(
|
||||
title="Created On", default_factory=dateType.today
|
||||
)
|
||||
first_name: str = Field(title="First Name")
|
||||
last_name: str = Field(title="Last Name")
|
||||
email: str = Field(title="Contact Email")
|
||||
dob: dateType = Field(
|
||||
title="Date Of Birth",
|
||||
)
|
||||
account_types: Union[AccountTypes, list[AccountTypes]] = Field(
|
||||
title="Type Of Account",
|
||||
json_schema_extra={
|
||||
"x-widget_config": {"multiSelect": True},
|
||||
},
|
||||
)
|
||||
submit: bool = Field(
|
||||
default=True,
|
||||
title="Submit",
|
||||
type="button", # This creates a button, when pressed the parameter is sent as True
|
||||
)
|
||||
|
||||
|
||||
class IntakeForm(Data):
|
||||
"""Submission Records."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
contacted: bool = Field(
|
||||
title="Contacted",
|
||||
default=False,
|
||||
)
|
||||
date_created: dateType = Field(
|
||||
title="Created On",
|
||||
)
|
||||
first_name: str = Field(title="First Name")
|
||||
last_name: str = Field(title="Last Name")
|
||||
email: str = Field(title="Contact Email")
|
||||
dob: dateType = Field(
|
||||
title="Date Of Birth",
|
||||
)
|
||||
account_types: Union[AccountTypes, list[AccountTypes]] = Field(
|
||||
title="Account Interest",
|
||||
)
|
||||
unique_id: uuid.UUID = Field(
|
||||
title="Unique ID",
|
||||
default_factory=uuid.uuid4,
|
||||
)
|
||||
|
||||
|
||||
INTAKE_FORMS: list[IntakeForm] = []
|
||||
|
||||
|
||||
@app.post("/general_intake")
|
||||
async def general_intake_post(data: GeneralIntake) -> bool:
|
||||
global INTAKE_FORMS
|
||||
try:
|
||||
INTAKE_FORMS.append(IntakeForm(**data.model_dump()))
|
||||
return True
|
||||
except Exception as e:
|
||||
raise e from e
|
||||
|
||||
|
||||
@app.get("/general_intake")
|
||||
async def general_intake() -> list[IntakeForm]:
|
||||
return INTAKE_FORMS
|
||||
```
|
||||
|
||||
<img width="1552" alt="Screenshot 2025-03-09 at 9 51 47 PM" src="https://github.com/user-attachments/assets/16bb3844-ea43-44c8-ae44-67159b0b70e4" />
|
||||
|
||||
## Widget Config
|
||||
|
||||
Any value from the [`widgets.json`](https://docs.openbb.co/terminal/custom-backend/widgets-json-reference) structure can be passed into the `@app` decorator by including an `openapi_extra` dictionary with the key, `"widget_config"`.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""OpenBB Workspace Query Models."""
|
||||
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from pydantic import AliasGenerator, ConfigDict
|
||||
from pydantic.alias_generators import to_snake
|
||||
|
||||
|
||||
class FormData(Data):
|
||||
"""Submit a form via POST request."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
alias_generator=AliasGenerator(to_snake),
|
||||
title="Submit Form",
|
||||
)
|
||||
@@ -55,6 +55,10 @@ TO_CAPS_STRINGS = [
|
||||
"Figi",
|
||||
"Cusip",
|
||||
"Pdf",
|
||||
"Otm",
|
||||
"Atm",
|
||||
"Itm",
|
||||
"Fomc",
|
||||
]
|
||||
|
||||
|
||||
@@ -109,7 +113,7 @@ def set_parameter_type(p: dict, p_schema: dict):
|
||||
):
|
||||
p["type"] = "boolean"
|
||||
|
||||
if "date" in p["parameter_name"]:
|
||||
if p["parameter_name"] == "date" or "_date" in p["parameter_name"]:
|
||||
p["type"] = "date"
|
||||
|
||||
if "timeframe" in p["parameter_name"]:
|
||||
@@ -118,13 +122,15 @@ def set_parameter_type(p: dict, p_schema: dict):
|
||||
if p["parameter_name"] == "limit":
|
||||
p["type"] = "number"
|
||||
|
||||
if p.get("type") in ("array", "list") or isinstance(p.get("type"), list):
|
||||
if p.get("type") in ("array", "list") or isinstance(p.get("type"), (list, dict)):
|
||||
p["type"] = "text"
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def set_parameter_options(p: dict, p_schema: dict, providers: list[str]) -> dict:
|
||||
def set_parameter_options( # pylint: disable=too-many-branches
|
||||
p: dict, p_schema: dict, providers: list[str]
|
||||
) -> dict:
|
||||
"""
|
||||
Set options for the parameter based on the schema.
|
||||
|
||||
@@ -142,7 +148,11 @@ def set_parameter_options(p: dict, p_schema: dict, providers: list[str]) -> dict
|
||||
Dict
|
||||
Updated parameter dictionary with options.
|
||||
"""
|
||||
choices: dict[str, list[dict[str, str]]] = {}
|
||||
choices: dict[str, list[dict[str, str]]] = (
|
||||
p.get("options", {})
|
||||
if p.get("options")
|
||||
else p_schema.get("options", {}) if p_schema.get("options") else {}
|
||||
)
|
||||
widget_configs: dict[str, dict] = {}
|
||||
multiple_items_allowed_dict: dict = {}
|
||||
is_provider_specific = False
|
||||
@@ -164,6 +174,7 @@ def set_parameter_options(p: dict, p_schema: dict, providers: list[str]) -> dict
|
||||
elif len(providers) == 1 and "enum" in p_schema:
|
||||
provider_choices = p_schema["enum"]
|
||||
p_schema.pop("enum")
|
||||
|
||||
if provider_choices:
|
||||
choices[provider] = [
|
||||
{"label": str(c), "value": c} for c in provider_choices
|
||||
@@ -175,7 +186,9 @@ def set_parameter_options(p: dict, p_schema: dict, providers: list[str]) -> dict
|
||||
|
||||
# Check title for provider-specific information
|
||||
if "title" in p_schema:
|
||||
title_providers = set(p_schema["title"].split(","))
|
||||
title_providers = (
|
||||
set(p_schema["title"].split(",")) if p_schema.get("title") else set()
|
||||
)
|
||||
if title_providers.intersection(providers):
|
||||
is_provider_specific = True
|
||||
available_providers.update(title_providers)
|
||||
@@ -235,10 +248,12 @@ def set_parameter_options(p: dict, p_schema: dict, providers: list[str]) -> dict
|
||||
if provider in p_schema and p_schema[provider].get("x-widget_config"):
|
||||
widget_configs[provider] = p_schema[provider].get("x-widget_config")
|
||||
|
||||
p["options"] = choices
|
||||
p["multiple_items_allowed"] = multiple_items_allowed_dict
|
||||
|
||||
if is_provider_specific:
|
||||
if choices:
|
||||
p["options"] = choices
|
||||
|
||||
if is_provider_specific and len(available_providers) > 1:
|
||||
p["available_providers"] = list(available_providers)
|
||||
p["x-widget_config"] = widget_configs
|
||||
|
||||
@@ -273,11 +288,17 @@ def process_parameter(param: dict, providers: list[str]) -> dict:
|
||||
param_name.replace("_", " ").replace("fixedincome", "fixed income").title()
|
||||
)
|
||||
p["description"] = (
|
||||
(param.get("description", param_name).split(" (provider:")[0].strip())
|
||||
.split("Multiple comma separated items allowed")[0]
|
||||
.strip()
|
||||
(
|
||||
(param.get("description", param_name).split(" (provider:")[0].strip())
|
||||
.split("Multiple comma separated items allowed")[0]
|
||||
.strip()
|
||||
)
|
||||
if param.get("description")
|
||||
else ""
|
||||
)
|
||||
p["optional"] = param.get("required", False) is False
|
||||
p["type"] = param.get("type", "text")
|
||||
p["value"] = param.get("value")
|
||||
|
||||
if param_name == "provider":
|
||||
p["type"] = "text"
|
||||
@@ -314,12 +335,31 @@ def process_parameter(param: dict, providers: list[str]) -> dict:
|
||||
|
||||
return p
|
||||
|
||||
p_schema = param.get("schema", {})
|
||||
if x_widget_config := param.get("x-widget_config", {}):
|
||||
p["x-widget_config"] = x_widget_config
|
||||
|
||||
p_schema = param.get("schema", {}) or param
|
||||
p["value"] = p_schema.get("default", None)
|
||||
|
||||
custom = (
|
||||
param.get("options", {}).get("custom", [])
|
||||
if "options" in param and "custom" in param["options"]
|
||||
else []
|
||||
)
|
||||
|
||||
if custom:
|
||||
p["options"] = {"custom": custom}
|
||||
|
||||
p = set_parameter_options(p, p_schema, providers)
|
||||
p = set_parameter_type(p, p_schema)
|
||||
|
||||
if "options" not in p:
|
||||
p["options"] = []
|
||||
if p_schema.get("options", []):
|
||||
p["options"] = p_schema["options"]
|
||||
|
||||
if title := p_schema.get("title", ""):
|
||||
|
||||
p["label"] = (
|
||||
p.get("parameter_name", "").replace("_", " ").title()
|
||||
if ("," in title and title.replace(",", "").islower())
|
||||
@@ -328,6 +368,10 @@ def process_parameter(param: dict, providers: list[str]) -> dict:
|
||||
)
|
||||
|
||||
if _widget_config := p_schema.get("x-widget_config", {}):
|
||||
for provider in providers:
|
||||
if provider in _widget_config:
|
||||
_widget_config = _widget_config[provider]
|
||||
break
|
||||
p.update(_widget_config)
|
||||
|
||||
return p
|
||||
@@ -353,8 +397,7 @@ def get_query_schema_for_widget(
|
||||
"""
|
||||
has_chart = False
|
||||
command = openapi_json["paths"][command_route]
|
||||
schema_method = list(command)[0]
|
||||
command = command[schema_method]
|
||||
command = command.get("get", {})
|
||||
params = command.get("parameters", [])
|
||||
route_params: list[dict] = []
|
||||
providers: list[str] = extract_providers(params)
|
||||
@@ -370,7 +413,9 @@ def get_query_schema_for_widget(
|
||||
p = process_parameter(param, providers)
|
||||
if "show" not in p:
|
||||
p["show"] = True
|
||||
route_params.append(p)
|
||||
|
||||
if not p.get("exclude") and not p.get("x-widget_config", {}).get("exclude"):
|
||||
route_params.append(p)
|
||||
|
||||
return route_params, has_chart
|
||||
|
||||
@@ -416,7 +461,7 @@ def get_data_schema_for_widget(openapi_json, operation_id, route: Optional[str]
|
||||
if isinstance(response_ref, dict) and "type" in response_ref:
|
||||
response_ref = response_ref["type"]
|
||||
|
||||
if response_ref:
|
||||
if response_ref and isinstance(response_ref, str):
|
||||
# Extract the schema name from the reference
|
||||
schema_name = response_ref.split("/")[-1]
|
||||
# Fetch and return the schema from components
|
||||
@@ -586,7 +631,7 @@ def data_schema_to_columns_defs( # noqa: PLR0912 # pylint: disable=too-many-br
|
||||
column_def["headerName"] = " ".join(
|
||||
[
|
||||
(word.upper() if word in TO_CAPS_STRINGS else word)
|
||||
for word in header_name.split(" ")
|
||||
for word in header_name.replace("_", " ").split(" ")
|
||||
]
|
||||
)
|
||||
column_def["headerTooltip"] = prop.get(
|
||||
@@ -663,3 +708,150 @@ def data_schema_to_columns_defs( # noqa: PLR0912 # pylint: disable=too-many-br
|
||||
|
||||
column_defs.append(column_def)
|
||||
return column_defs
|
||||
|
||||
|
||||
def post_query_schema_for_widget(
|
||||
openapi_json,
|
||||
operation_id,
|
||||
route: Optional[str] = None,
|
||||
target_schema: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Get the POST query schema for a widget based on its operationId.
|
||||
|
||||
Args:
|
||||
openapi (dict): The OpenAPI specification as a dictionary.
|
||||
operation_id (str): The operationId of the widget.
|
||||
route (str): The route of the widget, if any.
|
||||
target_schema (str): The target schema to extract, if any.
|
||||
|
||||
Returns:
|
||||
list[dict]: The schema dictionary for the widget's data.
|
||||
"""
|
||||
|
||||
new_params: dict = {}
|
||||
|
||||
def set_param(k, v):
|
||||
"""Set the parameter."""
|
||||
nonlocal new_params
|
||||
|
||||
new_params[k] = {}
|
||||
new_params[k]["name"] = k
|
||||
new_params[k]["type"] = (
|
||||
"text"
|
||||
if v.get("type") == "object"
|
||||
else "date" if "date" in v.get("format", "") else v.get("type", "text")
|
||||
)
|
||||
new_params[k]["title"] = v.get("title")
|
||||
new_params[k]["description"] = v.get("description")
|
||||
new_params[k]["default"] = v.get("default")
|
||||
new_params[k]["x-widget_config"] = v.get("x-widget_config", {})
|
||||
choices: list = (
|
||||
[{"label": c, "value": c} for c in v.get("choices", []) if c]
|
||||
if v.get("choices")
|
||||
else []
|
||||
)
|
||||
|
||||
if isinstance(v, dict) and "anyOf" in v:
|
||||
param_types = []
|
||||
for item in v["anyOf"]:
|
||||
if "type" in item and item.get("type") != "null":
|
||||
param_types.append(item["type"])
|
||||
if "enum" in item:
|
||||
choices.extend({"label": c, "value": c} for c in item["enum"])
|
||||
|
||||
if param_types:
|
||||
new_params[k]["type"] = (
|
||||
"number"
|
||||
if "number" in param_types
|
||||
or "integer" in param_types
|
||||
and "string" not in param_types
|
||||
and "date" not in param_types
|
||||
else (
|
||||
"date"
|
||||
if any(
|
||||
"date" in sub_prop.get("format", "")
|
||||
for sub_prop in v["anyOf"]
|
||||
if isinstance(sub_prop, dict)
|
||||
)
|
||||
else "text"
|
||||
)
|
||||
)
|
||||
else:
|
||||
new_params[k]["type"] = (
|
||||
"text"
|
||||
if v.get("type") == "object"
|
||||
else (
|
||||
"date"
|
||||
if "date" in v.get("format", "")
|
||||
else v.get("type", "text")
|
||||
)
|
||||
)
|
||||
elif isinstance(v, dict) and "enum" in v:
|
||||
choices.extend({"label": c, "value": c} for c in v["enum"] if c)
|
||||
|
||||
if choices:
|
||||
new_params[k]["options"] = {"custom": choices}
|
||||
|
||||
if not route:
|
||||
for path, methods in openapi_json["paths"].items():
|
||||
for _method, details in methods.items():
|
||||
if details.get("operationId") == operation_id:
|
||||
route = path
|
||||
break
|
||||
|
||||
_route = openapi_json["paths"].get(route, {}).get("post", {})
|
||||
|
||||
if (
|
||||
schema := _route.get("requestBody", {})
|
||||
.get("content", {})
|
||||
.get("application/json", {})
|
||||
.get("schema", {})
|
||||
):
|
||||
# Get the reference to the schema for the request body.
|
||||
|
||||
param_ref = (
|
||||
schema["items"].get("$ref")
|
||||
if "items" in schema
|
||||
else schema.get("$ref") or schema
|
||||
)
|
||||
|
||||
if isinstance(param_ref, dict) and "type" in param_ref:
|
||||
param_ref = param_ref["type"]
|
||||
|
||||
if param_ref:
|
||||
# Extract the schema name from the reference
|
||||
schema_name = param_ref.split("/")[-1]
|
||||
schema = openapi_json["components"]["schemas"].get(schema_name, schema_name)
|
||||
props = schema.get("properties", {})
|
||||
|
||||
for k, v in props.items():
|
||||
if target_schema and target_schema != k:
|
||||
continue
|
||||
if nested_schema := v.get("$ref"):
|
||||
nested_schema_name = nested_schema.split("/")[-1]
|
||||
nested_schema = openapi_json["components"]["schemas"].get(
|
||||
nested_schema_name, {}
|
||||
)
|
||||
for nested_k, nested_v in nested_schema.get(
|
||||
"properties", {}
|
||||
).items():
|
||||
set_param(nested_k, nested_v)
|
||||
|
||||
else:
|
||||
set_param(k, v)
|
||||
|
||||
route_params: list[dict] = []
|
||||
providers = ["custom"]
|
||||
|
||||
for param in list(new_params):
|
||||
p = process_parameter(new_params[param], providers)
|
||||
if not p.get("exclude") and not p.get("x-widget_config", {}).get(
|
||||
"exclude"
|
||||
):
|
||||
route_params.append(p)
|
||||
|
||||
return route_params
|
||||
|
||||
# Return None if the schema is not found
|
||||
return None
|
||||
|
||||
@@ -99,7 +99,6 @@ def modify_query_schema(query_schema: list[dict], provider_value: str):
|
||||
_item = deepcopy(item)
|
||||
provider_value_options: dict = {}
|
||||
provider_value_widget_config: dict = {}
|
||||
|
||||
# Exclude provider parameter. Those will be added last.
|
||||
if "parameter_name" in _item and _item["parameter_name"] == "provider":
|
||||
continue
|
||||
@@ -111,17 +110,22 @@ def modify_query_schema(query_schema: list[dict], provider_value: str):
|
||||
):
|
||||
continue
|
||||
|
||||
if provider_value in _item["multiple_items_allowed"] and _item[
|
||||
"multiple_items_allowed"
|
||||
].get(provider_value, False):
|
||||
if (
|
||||
provider_value
|
||||
and isinstance(_item, dict)
|
||||
and provider_value in _item.get("multiple_items_allowed", {})
|
||||
and _item.get("multiple_items_allowed", {}).get(provider_value, False)
|
||||
):
|
||||
_item["description"] = (
|
||||
_item["description"] + " Multiple comma separated items allowed."
|
||||
)
|
||||
_item["type"] = "text"
|
||||
_item["multiSelect"] = True
|
||||
|
||||
if "options" in _item:
|
||||
provider_value_options = _item.pop("options")
|
||||
if "options" in _item and _item.get("options"):
|
||||
provider_value_options = _item.pop("options", None)
|
||||
if isinstance(provider_value_options, list):
|
||||
provider_value_options = {provider_value: provider_value_options}
|
||||
|
||||
if provider_value in provider_value_options and bool(
|
||||
provider_value_options[provider_value]
|
||||
@@ -132,12 +136,12 @@ def modify_query_schema(query_schema: list[dict], provider_value: str):
|
||||
_item["options"] = provider_value_options["other"]
|
||||
_item["type"] = "text"
|
||||
|
||||
_item.pop("multiple_items_allowed")
|
||||
_ = _item.pop("multiple_items_allowed", None)
|
||||
|
||||
if "available_providers" in _item:
|
||||
_item.pop("available_providers")
|
||||
|
||||
_item["paramName"] = _item.pop("parameter_name")
|
||||
_item["paramName"] = _item.pop("parameter_name", None)
|
||||
|
||||
if not _item.get("label") and _item["paramName"] in [
|
||||
"url",
|
||||
@@ -158,8 +162,9 @@ def modify_query_schema(query_schema: list[dict], provider_value: str):
|
||||
)
|
||||
|
||||
if "x-widget_config" in _item:
|
||||
provider_value_widget_config = _item.pop("x-widget_config")
|
||||
_item.update(provider_value_widget_config)
|
||||
provider_value_widget_config[
|
||||
provider_value if provider_value else "custom"
|
||||
] = _item.pop("x-widget_config", {})
|
||||
|
||||
if (
|
||||
provider_value_widget_config
|
||||
@@ -185,7 +190,7 @@ def modify_query_schema(query_schema: list[dict], provider_value: str):
|
||||
return modified_query_schema
|
||||
|
||||
|
||||
def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches
|
||||
def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many-locals
|
||||
openapi: dict, widget_exclude_filter: list
|
||||
):
|
||||
"""Build the widgets.json file."""
|
||||
@@ -194,6 +199,7 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches
|
||||
TO_CAPS_STRINGS,
|
||||
data_schema_to_columns_defs,
|
||||
get_query_schema_for_widget,
|
||||
post_query_schema_for_widget,
|
||||
)
|
||||
|
||||
if not openapi:
|
||||
@@ -212,6 +218,12 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches
|
||||
if openapi["paths"].get(p, {}) and "get" in openapi["paths"][p]
|
||||
]
|
||||
for route in routes:
|
||||
has_form_endpoint = False
|
||||
form_route: dict = {}
|
||||
if "post" in openapi["paths"][route] and "get" in openapi["paths"][route]:
|
||||
has_form_endpoint = True
|
||||
form_route = openapi["paths"][route]["post"]
|
||||
|
||||
skip = False
|
||||
for starred in starred_list:
|
||||
if route.startswith(
|
||||
@@ -228,7 +240,6 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches
|
||||
continue
|
||||
|
||||
route_api = openapi["paths"][route]
|
||||
method = list(route_api)[0]
|
||||
widget_id = (
|
||||
(
|
||||
route[1:].replace("/", "_")
|
||||
@@ -242,7 +253,7 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches
|
||||
if widget_id in widget_exclude_filter:
|
||||
continue
|
||||
|
||||
widget_config_dict = route_api[method].get("widget_config", {})
|
||||
widget_config_dict = route_api.get("get", {}).get("widget_config", {})
|
||||
|
||||
# If the widget is marked as excluded, skip it.
|
||||
if widget_config_dict.get("exclude") is True:
|
||||
@@ -250,10 +261,14 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches
|
||||
|
||||
# Prepare the query schema of the widget
|
||||
query_schema, has_chart = get_query_schema_for_widget(openapi, route)
|
||||
response_schema = route_api[method]["responses"]["200"]["content"][
|
||||
"application/json"
|
||||
].get("schema", {})
|
||||
|
||||
response_schema = (
|
||||
route_api.get("get", {})
|
||||
.get("responses", {})
|
||||
.get("200", {})
|
||||
.get("content", {})
|
||||
.get("application/json", {})
|
||||
.get("schema", {})
|
||||
)
|
||||
# Extract providers from the query schema
|
||||
providers: list = []
|
||||
for item in query_schema:
|
||||
@@ -264,8 +279,12 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches
|
||||
providers = ["custom"]
|
||||
|
||||
for provider in providers:
|
||||
columns_defs = data_schema_to_columns_defs(
|
||||
openapi, widget_id, provider, route
|
||||
|
||||
columns_defs = (
|
||||
data_schema_to_columns_defs(openapi, widget_id, provider, route)
|
||||
if widget_config_dict.get("type")
|
||||
not in ["multi_file_viewer", "pdf", "metric"]
|
||||
else []
|
||||
)
|
||||
_cats = [
|
||||
r
|
||||
@@ -294,8 +313,113 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches
|
||||
for word in name.split()
|
||||
]
|
||||
)
|
||||
|
||||
modified_query_schema = modify_query_schema(query_schema, provider)
|
||||
|
||||
if has_form_endpoint:
|
||||
param_names: list = []
|
||||
var_schema: dict = {}
|
||||
if (
|
||||
_schema := route_api.get("post", {})
|
||||
.get("requestBody", {})
|
||||
.get("content", {})
|
||||
.get("application/json", {})
|
||||
.get("schema", {})
|
||||
):
|
||||
schema_name = _schema.get("$ref", "").split("/")[-1]
|
||||
var_schema = openapi["components"]["schemas"].get(schema_name, {})
|
||||
|
||||
if var_schema:
|
||||
var_props = var_schema.get("properties", {})
|
||||
for k, v in var_props.items():
|
||||
if "$ref" in v:
|
||||
param_names.append(k)
|
||||
|
||||
if param_names:
|
||||
for _param in param_names:
|
||||
post_params = post_query_schema_for_widget(
|
||||
openapi, form_route.get("operationId"), route, _param
|
||||
)
|
||||
modified_post_params = modify_query_schema(
|
||||
post_params, provider
|
||||
)
|
||||
form_params = {
|
||||
"type": "form",
|
||||
"paramName": _param,
|
||||
"label": "Form",
|
||||
"description": "Form Data",
|
||||
"endpoint": (
|
||||
route.replace("/api", "api")
|
||||
if "/api" in route
|
||||
else route[1:] if route[0] == "/" else route
|
||||
),
|
||||
"inputParams": modified_post_params,
|
||||
}
|
||||
|
||||
if post_config := var_schema.get("x-widget_config", {}):
|
||||
form_params = deep_merge_configs(
|
||||
form_params,
|
||||
post_config,
|
||||
)
|
||||
|
||||
modified_query_schema.append(form_params)
|
||||
else:
|
||||
post_params = post_query_schema_for_widget(
|
||||
openapi, form_route.get("operationId"), route
|
||||
)
|
||||
modified_post_params = modify_query_schema(post_params, provider)
|
||||
form_params = {
|
||||
"type": "form",
|
||||
"paramName": "",
|
||||
"label": var_schema.get("title", "Form"),
|
||||
"description": var_schema.get("description", ""),
|
||||
"endpoint": (
|
||||
route.replace("/api", "api")
|
||||
if "/api" in route
|
||||
else route[1:] if route[0] == "/" else route
|
||||
),
|
||||
"inputParams": modified_post_params,
|
||||
}
|
||||
|
||||
# Widget Config at the model level goes first.
|
||||
if post_config := var_schema.get("x-widget_config", {}):
|
||||
form_params = deep_merge_configs(
|
||||
form_params,
|
||||
post_config,
|
||||
)
|
||||
|
||||
var_key: dict = {}
|
||||
|
||||
# Then the widget config at the POST endpoint level takes priority.
|
||||
if post_config := form_route.get("widget_config", {}):
|
||||
for key, value in post_config.copy().items():
|
||||
if key.startswith("$."):
|
||||
var_key[key] = value
|
||||
|
||||
form_params = deep_merge_configs(
|
||||
form_params,
|
||||
{
|
||||
k: v
|
||||
for k, v in post_config.items()
|
||||
if not k.startswith("$.")
|
||||
},
|
||||
)
|
||||
|
||||
modified_query_schema.append(form_params)
|
||||
|
||||
if var_key:
|
||||
for key, value in var_key.items():
|
||||
if (
|
||||
key.replace("$.", "") in widget_config_dict
|
||||
and "params" not in key
|
||||
and "inputParams" not in key
|
||||
):
|
||||
widget_config_dict.update(
|
||||
{key.replace("$.", ""): value}
|
||||
)
|
||||
else:
|
||||
widget_config_dict[key.replace("$.", "")] = value
|
||||
|
||||
provider_map = {
|
||||
"tmx": "TMX",
|
||||
"ecb": "ECB",
|
||||
@@ -374,22 +498,22 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches
|
||||
if columns_defs:
|
||||
widget_config["data"]["table"]["columnsDefs"] = columns_defs
|
||||
|
||||
var_key: dict = {}
|
||||
data_var_key: dict = {}
|
||||
|
||||
if data_config := data_schema_to_columns_defs(
|
||||
openapi, widget_id, provider, route, True
|
||||
):
|
||||
for key, value in data_config.copy().items():
|
||||
if key.startswith("$."):
|
||||
var_key[key] = value
|
||||
data_var_key[key] = value
|
||||
|
||||
widget_config["data"] = deep_merge_configs(
|
||||
widget_config["data"],
|
||||
{k: v for k, v in data_config.items() if not k.startswith("$.")},
|
||||
)
|
||||
|
||||
if var_key:
|
||||
for key, value in var_key.items():
|
||||
if data_var_key:
|
||||
for key, value in data_var_key.items():
|
||||
if (
|
||||
key.replace("$.", "") in widget_config_dict
|
||||
and key != "$.data"
|
||||
|
||||
@@ -3500,6 +3500,87 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/form_submit": {
|
||||
"post": {
|
||||
"summary": "Form Submit",
|
||||
"operationId": "form_submit_form_submit_post",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Form"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"title": "Response Form Submit Form Submit Post"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"summary": "All Forms",
|
||||
"operationId": "all_forms_form_submit_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "some_param",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"title": "Some Param"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
},
|
||||
"title": "Response All Forms Form Submit Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -6290,6 +6371,90 @@
|
||||
"title": "SecSymbolMapData",
|
||||
"description": "SEC symbol map Data."
|
||||
},
|
||||
"Form": {
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"title": "Record Opened Date"
|
||||
},
|
||||
"record_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"title": "Record ID"
|
||||
},
|
||||
"first_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "First Name"
|
||||
},
|
||||
"last_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Last Name"
|
||||
},
|
||||
"symptoms": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"title": "Symptoms",
|
||||
"choices": [
|
||||
"dizzy",
|
||||
"hungry",
|
||||
"sleepy"
|
||||
],
|
||||
"x-widget_config": {
|
||||
"multiSelect": true
|
||||
}
|
||||
},
|
||||
"diagnosis": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Diagnosis"
|
||||
},
|
||||
"add_record": {
|
||||
"type": "button",
|
||||
"title": "Add Record",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
"type": "object",
|
||||
"title": "Submit Form",
|
||||
"description": "Submit a form via POST request."
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"loc": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ from openbb_platform_api.utils.openapi import (
|
||||
data_schema_to_columns_defs,
|
||||
get_data_schema_for_widget,
|
||||
get_query_schema_for_widget,
|
||||
post_query_schema_for_widget,
|
||||
)
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
@@ -32,6 +33,7 @@ def mock_openapi_json():
|
||||
"description": "Source of the data.",
|
||||
"optional": True,
|
||||
"type": "text",
|
||||
"value": None,
|
||||
"available_providers": ["fred"],
|
||||
"show": True,
|
||||
},
|
||||
@@ -40,7 +42,9 @@ def mock_openapi_json():
|
||||
"label": "Country",
|
||||
"description": "The country to get data. Enter as a 3-letter ISO country code, default is USA.",
|
||||
"optional": True,
|
||||
"type": "text",
|
||||
"value": "united_states",
|
||||
"multiple_items_allowed": {},
|
||||
"options": {
|
||||
"fred": [
|
||||
{"label": "argentina", "value": "argentina"},
|
||||
@@ -93,37 +97,32 @@ def mock_openapi_json():
|
||||
{"label": "g20", "value": "g20"},
|
||||
]
|
||||
},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["fred"],
|
||||
"type": "text",
|
||||
"show": True,
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"parameter_name": "start_date",
|
||||
"label": "Start Date",
|
||||
"description": "Start date of the data, in YYYY-MM-DD format.",
|
||||
"optional": True,
|
||||
"value": None,
|
||||
"options": {"fred": []},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["fred"],
|
||||
"type": "date",
|
||||
"show": True,
|
||||
"value": None,
|
||||
"multiple_items_allowed": {},
|
||||
"options": {"fred": []},
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"parameter_name": "end_date",
|
||||
"label": "End Date",
|
||||
"description": "End date of the data, in YYYY-MM-DD format.",
|
||||
"optional": True,
|
||||
"value": None,
|
||||
"options": {"fred": []},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["fred"],
|
||||
"type": "date",
|
||||
"show": True,
|
||||
"value": None,
|
||||
"multiple_items_allowed": {},
|
||||
"options": {"fred": []},
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
],
|
||||
False,
|
||||
@@ -138,6 +137,7 @@ def mock_openapi_json():
|
||||
"description": "Source of the data.",
|
||||
"optional": True,
|
||||
"type": "text",
|
||||
"value": None,
|
||||
"available_providers": ["fred"],
|
||||
"show": True,
|
||||
},
|
||||
@@ -147,6 +147,7 @@ def mock_openapi_json():
|
||||
"description": "Symbol to get data for.",
|
||||
"optional": False,
|
||||
"type": "text",
|
||||
"value": None,
|
||||
"multiple_items_allowed": {"fred": True},
|
||||
"show": True,
|
||||
},
|
||||
@@ -155,71 +156,45 @@ def mock_openapi_json():
|
||||
"label": "Start Date",
|
||||
"description": "Start date of the data, in YYYY-MM-DD format.",
|
||||
"optional": True,
|
||||
"value": None,
|
||||
"options": {"fred": []},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["fred"],
|
||||
"type": "date",
|
||||
"show": True,
|
||||
"value": None,
|
||||
"multiple_items_allowed": {},
|
||||
"options": {"fred": []},
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"parameter_name": "end_date",
|
||||
"label": "End Date",
|
||||
"description": "End date of the data, in YYYY-MM-DD format.",
|
||||
"optional": True,
|
||||
"value": None,
|
||||
"options": {"fred": []},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["fred"],
|
||||
"type": "date",
|
||||
"show": True,
|
||||
"value": None,
|
||||
"multiple_items_allowed": {},
|
||||
"options": {"fred": []},
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"parameter_name": "limit",
|
||||
"label": "Limit",
|
||||
"description": "The number of data entries to return.",
|
||||
"optional": True,
|
||||
"value": 100000,
|
||||
"options": {"fred": []},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["fred"],
|
||||
"type": "number",
|
||||
"show": True,
|
||||
"value": 100000,
|
||||
"multiple_items_allowed": {},
|
||||
"options": {"fred": []},
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"parameter_name": "frequency",
|
||||
"label": "Frequency",
|
||||
"description": "Frequency aggregation to convert high frequency data to lower frequency."
|
||||
+ "\n "
|
||||
+ "\n None = No change"
|
||||
+ "\n "
|
||||
+ "\n a = Annual\n "
|
||||
+ "\n q = Quarterly\n "
|
||||
+ "\n m = Monthly\n "
|
||||
+ "\n w = Weekly\n "
|
||||
+ "\n d = Daily\n "
|
||||
+ "\n wef = Weekly, Ending Friday"
|
||||
+ "\n "
|
||||
+ "\n weth = Weekly, Ending Thursday"
|
||||
+ "\n "
|
||||
+ "\n wew = Weekly, Ending Wednesday"
|
||||
+ "\n "
|
||||
+ "\n wetu = Weekly, Ending Tuesday"
|
||||
+ "\n "
|
||||
+ "\n wem = Weekly, Ending Monday"
|
||||
+ "\n "
|
||||
+ "\n wesu = Weekly, Ending Sunday"
|
||||
+ "\n "
|
||||
+ "\n wesa = Weekly, Ending Saturday"
|
||||
+ "\n "
|
||||
+ "\n bwew = Biweekly, Ending Wednesday"
|
||||
+ "\n "
|
||||
+ "\n bwem = Biweekly, Ending Monday",
|
||||
"description": "Frequency aggregation to convert high frequency data to lower frequency.\n \n None = No change\n \n a = Annual\n \n q = Quarterly\n \n m = Monthly\n \n w = Weekly\n \n d = Daily\n \n wef = Weekly, Ending Friday\n \n weth = Weekly, Ending Thursday\n \n wew = Weekly, Ending Wednesday\n \n wetu = Weekly, Ending Tuesday\n \n wem = Weekly, Ending Monday\n \n wesu = Weekly, Ending Sunday\n \n wesa = Weekly, Ending Saturday\n \n bwew = Biweekly, Ending Wednesday\n \n bwem = Biweekly, Ending Monday",
|
||||
"optional": True,
|
||||
"type": "text",
|
||||
"value": None,
|
||||
"multiple_items_allowed": {},
|
||||
"options": {
|
||||
"fred": [
|
||||
{"label": "a", "value": "a"},
|
||||
@@ -238,24 +213,17 @@ def mock_openapi_json():
|
||||
{"label": "bwem", "value": "bwem"},
|
||||
]
|
||||
},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["fred"],
|
||||
"show": True,
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"parameter_name": "aggregation_method",
|
||||
"label": "Aggregation Method",
|
||||
"description": "A key that indicates the aggregation method used for frequency aggregation."
|
||||
+ "\n This parameter has no affect if the frequency parameter is not set."
|
||||
+ "\n "
|
||||
+ "\n avg = Average"
|
||||
+ "\n "
|
||||
+ "\n sum = Sum"
|
||||
+ "\n "
|
||||
+ "\n eop = End of Period",
|
||||
"description": "A key that indicates the aggregation method used for frequency aggregation.\n This parameter has no affect if the frequency parameter is not set.\n \n avg = Average\n \n sum = Sum\n \n eop = End of Period",
|
||||
"optional": True,
|
||||
"type": "text",
|
||||
"value": "eop",
|
||||
"multiple_items_allowed": {},
|
||||
"options": {
|
||||
"fred": [
|
||||
{"label": "avg", "value": "avg"},
|
||||
@@ -263,35 +231,17 @@ def mock_openapi_json():
|
||||
{"label": "eop", "value": "eop"},
|
||||
]
|
||||
},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["fred"],
|
||||
"show": True,
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"parameter_name": "transform",
|
||||
"label": "Transform",
|
||||
"description": "Transformation type"
|
||||
+ "\n "
|
||||
+ "\n None = No transformation"
|
||||
+ "\n "
|
||||
+ "\n chg = Change"
|
||||
+ "\n "
|
||||
+ "\n ch1 = Change from Year Ago"
|
||||
+ "\n "
|
||||
+ "\n pch = Percent Change"
|
||||
+ "\n "
|
||||
+ "\n pc1 = Percent Change from Year Ago"
|
||||
+ "\n "
|
||||
+ "\n pca = Compounded Annual Rate of Change"
|
||||
+ "\n "
|
||||
+ "\n cch = Continuously Compounded Rate of Change"
|
||||
+ "\n "
|
||||
+ "\n cca = Continuously Compounded Annual Rate of Change"
|
||||
+ "\n "
|
||||
+ "\n log = Natural Log",
|
||||
"description": "Transformation type\n \n None = No transformation\n \n chg = Change\n \n ch1 = Change from Year Ago\n \n pch = Percent Change\n \n pc1 = Percent Change from Year Ago\n \n pca = Compounded Annual Rate of Change\n \n cch = Continuously Compounded Rate of Change\n \n cca = Continuously Compounded Annual Rate of Change\n \n log = Natural Log",
|
||||
"optional": True,
|
||||
"type": "text",
|
||||
"value": None,
|
||||
"multiple_items_allowed": {},
|
||||
"options": {
|
||||
"fred": [
|
||||
{"label": "chg", "value": "chg"},
|
||||
@@ -304,10 +254,8 @@ def mock_openapi_json():
|
||||
{"label": "log", "value": "log"},
|
||||
]
|
||||
},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["fred"],
|
||||
"show": True,
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
],
|
||||
True,
|
||||
@@ -322,6 +270,7 @@ def mock_openapi_json():
|
||||
"description": "Source of the data.",
|
||||
"optional": True,
|
||||
"type": "text",
|
||||
"value": None,
|
||||
"available_providers": ["sec"],
|
||||
"show": True,
|
||||
},
|
||||
@@ -330,24 +279,22 @@ def mock_openapi_json():
|
||||
"label": "Query",
|
||||
"description": "Search query.",
|
||||
"optional": True,
|
||||
"value": "",
|
||||
"options": {"sec": []},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["sec"],
|
||||
"type": "text",
|
||||
"show": True,
|
||||
"value": "",
|
||||
"multiple_items_allowed": {},
|
||||
"options": {"sec": []},
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"parameter_name": "use_cache",
|
||||
"label": "Use Cache",
|
||||
"description": "Whether or not to use cache.",
|
||||
"optional": True,
|
||||
"value": True,
|
||||
"options": {"sec": []},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["sec"],
|
||||
"type": "boolean",
|
||||
"value": True,
|
||||
"multiple_items_allowed": {},
|
||||
"options": {"sec": []},
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
@@ -356,10 +303,10 @@ def mock_openapi_json():
|
||||
"label": "Url",
|
||||
"description": "Enter an optional URL path to fetch the next level.",
|
||||
"optional": True,
|
||||
"type": "text",
|
||||
"value": None,
|
||||
"options": {"sec": []},
|
||||
"multiple_items_allowed": {},
|
||||
"available_providers": ["sec"],
|
||||
"options": {"sec": []},
|
||||
"x-widget_config": {},
|
||||
"show": True,
|
||||
},
|
||||
@@ -390,6 +337,17 @@ def test_get_data_schema_for_widget(mock_openapi_json, openapi_operation_id):
|
||||
assert schema is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"openapi_operation_id",
|
||||
[
|
||||
"form_submit_form_submit_post",
|
||||
],
|
||||
)
|
||||
def test_post_query_schema_for_widget(mock_openapi_json, openapi_operation_id):
|
||||
schema = post_query_schema_for_widget(mock_openapi_json, openapi_operation_id)
|
||||
assert schema
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"openapi_operation_id",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user