mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-29 05:25:30 -05:00
feat: Query Filter Builder for Cookbooks and Meal Plans (#4346)
This commit is contained in:
@@ -1,12 +1,5 @@
|
||||
# This file is auto-generated by gen_schema_exports.py
|
||||
from .about import (
|
||||
AdminAboutInfo,
|
||||
AppInfo,
|
||||
AppStartupInfo,
|
||||
AppStatistics,
|
||||
AppTheme,
|
||||
CheckAppConfig,
|
||||
)
|
||||
from .about import AdminAboutInfo, AppInfo, AppStartupInfo, AppStatistics, AppTheme, CheckAppConfig
|
||||
from .backup import AllBackups, BackupFile, BackupOptions, CreateBackup, ImportJob
|
||||
from .debug import DebugResponse
|
||||
from .email import EmailReady, EmailSuccess, EmailTest
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import UUID4, ConfigDict, Field, field_validator
|
||||
import sqlalchemy as sa
|
||||
from pydantic import UUID4, ConfigDict, Field, ValidationInfo, field_validator
|
||||
from slugify import slugify
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.models.recipe import RecipeModel
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema.recipe.recipe import RecipeSummary, RecipeTool
|
||||
from mealie.schema.recipe.recipe import RecipeSummary
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON
|
||||
|
||||
from ...db.models.household import CookBook
|
||||
from ..recipe.recipe_category import CategoryBase, TagBase
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class CreateCookBook(MealieModel):
|
||||
@@ -19,12 +20,7 @@ class CreateCookBook(MealieModel):
|
||||
slug: Annotated[str | None, Field(validate_default=True)] = None
|
||||
position: int = 1
|
||||
public: Annotated[bool, Field(validate_default=True)] = False
|
||||
categories: list[CategoryBase] = []
|
||||
tags: list[TagBase] = []
|
||||
tools: list[RecipeTool] = []
|
||||
require_all_categories: bool = True
|
||||
require_all_tags: bool = True
|
||||
require_all_tools: bool = True
|
||||
query_filter_string: str = ""
|
||||
|
||||
@field_validator("public", mode="before")
|
||||
def validate_public(public: bool | None) -> bool:
|
||||
@@ -42,6 +38,19 @@ class CreateCookBook(MealieModel):
|
||||
|
||||
return name
|
||||
|
||||
@field_validator("query_filter_string")
|
||||
def validate_query_filter_string(value: str) -> str:
|
||||
# The query filter builder does additional validations while building the
|
||||
# database query, so we make sure constructing the query is successful
|
||||
builder = QueryFilterBuilder(value)
|
||||
|
||||
try:
|
||||
builder.filter_query(sa.select(RecipeModel), RecipeModel)
|
||||
except Exception as e:
|
||||
raise ValueError("Invalid query filter string") from e
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class SaveCookBook(CreateCookBook):
|
||||
group_id: UUID4
|
||||
@@ -53,14 +62,24 @@ class UpdateCookBook(SaveCookBook):
|
||||
|
||||
|
||||
class ReadCookBook(UpdateCookBook):
|
||||
group_id: UUID4
|
||||
household_id: UUID4
|
||||
categories: list[CategoryBase] = []
|
||||
query_filter: Annotated[QueryFilterJSON, Field(validate_default=True)] = None # type: ignore
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [joinedload(CookBook.categories), joinedload(CookBook.tags), joinedload(CookBook.tools)]
|
||||
@field_validator("query_filter_string")
|
||||
def validate_query_filter_string(value: str) -> str:
|
||||
# Skip validation since we are not updating the query filter string
|
||||
return value
|
||||
|
||||
@field_validator("query_filter", mode="before")
|
||||
def validate_query_filter(cls, _, info: ValidationInfo) -> QueryFilterJSON:
|
||||
try:
|
||||
query_filter_string: str = info.data.get("query_filter_string") or ""
|
||||
builder = QueryFilterBuilder(query_filter_string)
|
||||
return builder.as_json_model()
|
||||
except Exception:
|
||||
logger.exception(f"Invalid query filter string: {query_filter_string}")
|
||||
return QueryFilterJSON()
|
||||
|
||||
|
||||
class CookBookPagination(PaginationBase):
|
||||
|
||||
@@ -8,18 +8,7 @@ from .new_meal import (
|
||||
SavePlanEntry,
|
||||
UpdatePlanEntry,
|
||||
)
|
||||
from .plan_rules import (
|
||||
BasePlanRuleFilter,
|
||||
PlanCategory,
|
||||
PlanHousehold,
|
||||
PlanRulesCreate,
|
||||
PlanRulesDay,
|
||||
PlanRulesOut,
|
||||
PlanRulesPagination,
|
||||
PlanRulesSave,
|
||||
PlanRulesType,
|
||||
PlanTag,
|
||||
)
|
||||
from .plan_rules import PlanRulesCreate, PlanRulesDay, PlanRulesOut, PlanRulesPagination, PlanRulesSave, PlanRulesType
|
||||
from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut
|
||||
|
||||
__all__ = [
|
||||
@@ -33,14 +22,10 @@ __all__ = [
|
||||
"ReadPlanEntry",
|
||||
"SavePlanEntry",
|
||||
"UpdatePlanEntry",
|
||||
"BasePlanRuleFilter",
|
||||
"PlanCategory",
|
||||
"PlanHousehold",
|
||||
"PlanRulesCreate",
|
||||
"PlanRulesDay",
|
||||
"PlanRulesOut",
|
||||
"PlanRulesPagination",
|
||||
"PlanRulesSave",
|
||||
"PlanRulesType",
|
||||
"PlanTag",
|
||||
]
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
import datetime
|
||||
from enum import Enum
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import UUID4, ConfigDict
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
import sqlalchemy as sa
|
||||
from pydantic import UUID4, ConfigDict, Field, ValidationInfo, field_validator
|
||||
|
||||
from mealie.db.models.household import GroupMealPlanRules, Household
|
||||
from mealie.db.models.recipe import Category, Tag
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.models.recipe import RecipeModel
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON
|
||||
|
||||
|
||||
class BasePlanRuleFilter(MealieModel):
|
||||
id: UUID4
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
class PlanCategory(BasePlanRuleFilter):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PlanTag(BasePlanRuleFilter):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PlanHousehold(BasePlanRuleFilter):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class PlanRulesDay(str, Enum):
|
||||
@@ -59,9 +44,20 @@ class PlanRulesType(str, Enum):
|
||||
class PlanRulesCreate(MealieModel):
|
||||
day: PlanRulesDay = PlanRulesDay.unset
|
||||
entry_type: PlanRulesType = PlanRulesType.unset
|
||||
categories: list[PlanCategory] = []
|
||||
tags: list[PlanTag] = []
|
||||
households: list[PlanHousehold] = []
|
||||
query_filter_string: str = ""
|
||||
|
||||
@field_validator("query_filter_string")
|
||||
def validate_query_filter_string(cls, value: str) -> str:
|
||||
# The query filter builder does additional validations while building the
|
||||
# database query, so we make sure constructing the query is successful
|
||||
builder = QueryFilterBuilder(value)
|
||||
|
||||
try:
|
||||
builder.filter_query(sa.select(RecipeModel), RecipeModel)
|
||||
except Exception as e:
|
||||
raise ValueError("Invalid query filter string") from e
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class PlanRulesSave(PlanRulesCreate):
|
||||
@@ -71,27 +67,24 @@ class PlanRulesSave(PlanRulesCreate):
|
||||
|
||||
class PlanRulesOut(PlanRulesSave):
|
||||
id: UUID4
|
||||
query_filter: Annotated[QueryFilterJSON, Field(validate_default=True)] = None # type: ignore
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
joinedload(GroupMealPlanRules.categories).load_only(
|
||||
Category.id,
|
||||
Category.name,
|
||||
Category.slug,
|
||||
),
|
||||
joinedload(GroupMealPlanRules.tags).load_only(
|
||||
Tag.id,
|
||||
Tag.name,
|
||||
Tag.slug,
|
||||
),
|
||||
joinedload(GroupMealPlanRules.households).load_only(
|
||||
Household.id,
|
||||
Household.name,
|
||||
Household.slug,
|
||||
),
|
||||
]
|
||||
@field_validator("query_filter_string")
|
||||
def validate_query_filter_string(value: str) -> str:
|
||||
# Skip validation since we are not updating the query filter string
|
||||
return value
|
||||
|
||||
@field_validator("query_filter", mode="before")
|
||||
def validate_query_filter(cls, _, info: ValidationInfo) -> QueryFilterJSON:
|
||||
try:
|
||||
query_filter_string: str = info.data.get("query_filter_string") or ""
|
||||
builder = QueryFilterBuilder(query_filter_string)
|
||||
return builder.as_json_model()
|
||||
except Exception:
|
||||
logger.exception(f"Invalid query filter string: {query_filter_string}")
|
||||
return QueryFilterJSON()
|
||||
|
||||
|
||||
class PlanRulesPagination(PaginationBase):
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
# This file is auto-generated by gen_schema_exports.py
|
||||
from .pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery, RecipeSearchQuery
|
||||
from .query_filter import LogicalOperator, QueryFilter, QueryFilterComponent, RelationalKeyword, RelationalOperator
|
||||
from .query_filter import (
|
||||
LogicalOperator,
|
||||
QueryFilterBuilder,
|
||||
QueryFilterBuilderComponent,
|
||||
QueryFilterJSON,
|
||||
QueryFilterJSONPart,
|
||||
RelationalKeyword,
|
||||
RelationalOperator,
|
||||
)
|
||||
from .query_search import SearchFilter
|
||||
from .responses import ErrorResponse, FileTokenResponse, SuccessResponse
|
||||
from .validation import ValidationResponse
|
||||
|
||||
__all__ = [
|
||||
"LogicalOperator",
|
||||
"QueryFilter",
|
||||
"QueryFilterComponent",
|
||||
"QueryFilterBuilder",
|
||||
"QueryFilterBuilderComponent",
|
||||
"QueryFilterJSON",
|
||||
"QueryFilterJSONPart",
|
||||
"RelationalKeyword",
|
||||
"RelationalOperator",
|
||||
"ValidationResponse",
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy.sql import sqltypes
|
||||
from mealie.db.models._model_base import SqlAlchemyBase
|
||||
from mealie.db.models._model_utils.datetime import NaiveDateTime
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
from mealie.schema._mealie.mealie_model import MealieModel
|
||||
|
||||
Model = TypeVar("Model", bound=SqlAlchemyBase)
|
||||
|
||||
@@ -104,7 +105,21 @@ class LogicalOperator(Enum):
|
||||
OR = "OR"
|
||||
|
||||
|
||||
class QueryFilterComponent:
|
||||
class QueryFilterJSONPart(MealieModel):
|
||||
left_parenthesis: str | None = None
|
||||
right_parenthesis: str | None = None
|
||||
logical_operator: LogicalOperator | None = None
|
||||
|
||||
attribute_name: str | None = None
|
||||
relational_operator: RelationalKeyword | RelationalOperator | None = None
|
||||
value: str | list[str] | None = None
|
||||
|
||||
|
||||
class QueryFilterJSON(MealieModel):
|
||||
parts: list[QueryFilterJSONPart] = []
|
||||
|
||||
|
||||
class QueryFilterBuilderComponent:
|
||||
"""A single relational statement"""
|
||||
|
||||
@staticmethod
|
||||
@@ -135,7 +150,7 @@ class QueryFilterComponent:
|
||||
] and not isinstance(value, list):
|
||||
raise ValueError(
|
||||
f"invalid query string: {relationship.value} must be given a list of values"
|
||||
f"enclosed by {QueryFilter.l_list_sep} and {QueryFilter.r_list_sep}"
|
||||
f"enclosed by {QueryFilterBuilder.l_list_sep} and {QueryFilterBuilder.r_list_sep}"
|
||||
)
|
||||
|
||||
if relationship is RelationalKeyword.IS or relationship is RelationalKeyword.IS_NOT:
|
||||
@@ -193,8 +208,18 @@ class QueryFilterComponent:
|
||||
|
||||
return sanitized_values if isinstance(self.value, list) else sanitized_values[0]
|
||||
|
||||
def as_json_model(self) -> QueryFilterJSONPart:
|
||||
return QueryFilterJSONPart(
|
||||
left_parenthesis=None,
|
||||
right_parenthesis=None,
|
||||
logical_operator=None,
|
||||
attribute_name=self.attribute_name,
|
||||
relational_operator=self.relationship,
|
||||
value=self.value,
|
||||
)
|
||||
|
||||
class QueryFilter:
|
||||
|
||||
class QueryFilterBuilder:
|
||||
l_group_sep: str = "("
|
||||
r_group_sep: str = ")"
|
||||
group_seps: set[str] = {l_group_sep, r_group_sep}
|
||||
@@ -205,13 +230,15 @@ class QueryFilter:
|
||||
|
||||
def __init__(self, filter_string: str) -> None:
|
||||
# parse filter string
|
||||
components = QueryFilter._break_filter_string_into_components(filter_string)
|
||||
base_components = QueryFilter._break_components_into_base_components(components)
|
||||
if base_components.count(QueryFilter.l_group_sep) != base_components.count(QueryFilter.r_group_sep):
|
||||
components = QueryFilterBuilder._break_filter_string_into_components(filter_string)
|
||||
base_components = QueryFilterBuilder._break_components_into_base_components(components)
|
||||
if base_components.count(QueryFilterBuilder.l_group_sep) != base_components.count(
|
||||
QueryFilterBuilder.r_group_sep
|
||||
):
|
||||
raise ValueError("invalid query string: parenthesis are unbalanced")
|
||||
|
||||
# parse base components into a filter group
|
||||
self.filter_components = QueryFilter._parse_base_components_into_filter_components(base_components)
|
||||
self.filter_components = QueryFilterBuilder._parse_base_components_into_filter_components(base_components)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
joined = " ".join(
|
||||
@@ -308,7 +335,7 @@ class QueryFilter:
|
||||
attr_model_map: dict[int, Any] = {}
|
||||
model_attr: InstrumentedAttribute
|
||||
for i, component in enumerate(self.filter_components):
|
||||
if not isinstance(component, QueryFilterComponent):
|
||||
if not isinstance(component, QueryFilterBuilderComponent):
|
||||
continue
|
||||
|
||||
nested_model, model_attr, query = self.get_model_and_model_attr_from_attr_string(
|
||||
@@ -337,7 +364,7 @@ class QueryFilter:
|
||||
logical_operator_stack.append(component)
|
||||
|
||||
else:
|
||||
component = cast(QueryFilterComponent, component)
|
||||
component = cast(QueryFilterBuilderComponent, component)
|
||||
model_attr = getattr(attr_model_map[i], component.attribute_name.split(".")[-1])
|
||||
|
||||
# Keywords
|
||||
@@ -395,7 +422,7 @@ class QueryFilter:
|
||||
subcomponents = []
|
||||
for component in components:
|
||||
# don't parse components comprised of only a separator
|
||||
if component in QueryFilter.group_seps:
|
||||
if component in QueryFilterBuilder.group_seps:
|
||||
subcomponents.append(component)
|
||||
continue
|
||||
|
||||
@@ -406,7 +433,7 @@ class QueryFilter:
|
||||
if c == '"':
|
||||
in_quotes = not in_quotes
|
||||
|
||||
if c in QueryFilter.group_seps and not in_quotes:
|
||||
if c in QueryFilterBuilder.group_seps and not in_quotes:
|
||||
if new_component:
|
||||
subcomponents.append(new_component)
|
||||
|
||||
@@ -437,17 +464,17 @@ class QueryFilter:
|
||||
list_value_components = []
|
||||
for component in components:
|
||||
# parse out lists as their own singular sub component
|
||||
subcomponents = component.split(QueryFilter.l_list_sep)
|
||||
subcomponents = component.split(QueryFilterBuilder.l_list_sep)
|
||||
for i, subcomponent in enumerate(subcomponents):
|
||||
if not i:
|
||||
continue
|
||||
|
||||
for j, list_value_string in enumerate(subcomponent.split(QueryFilter.r_list_sep)):
|
||||
for j, list_value_string in enumerate(subcomponent.split(QueryFilterBuilder.r_list_sep)):
|
||||
if j % 2:
|
||||
continue
|
||||
|
||||
list_value_components.append(
|
||||
[val.strip() for val in list_value_string.split(QueryFilter.list_item_sep)]
|
||||
[val.strip() for val in list_value_string.split(QueryFilterBuilder.list_item_sep)]
|
||||
)
|
||||
|
||||
quote_offset = 0
|
||||
@@ -455,16 +482,16 @@ class QueryFilter:
|
||||
for i, subcomponent in enumerate(subcomponents):
|
||||
# we are in a list subcomponent, which is already handled
|
||||
if in_list:
|
||||
if QueryFilter.r_list_sep in subcomponent:
|
||||
if QueryFilterBuilder.r_list_sep in subcomponent:
|
||||
# filter out the remainder of the list subcomponent and continue parsing
|
||||
base_components.append(list_value_components.pop(0))
|
||||
subcomponent = subcomponent.split(QueryFilter.r_list_sep, maxsplit=1)[-1].strip()
|
||||
subcomponent = subcomponent.split(QueryFilterBuilder.r_list_sep, maxsplit=1)[-1].strip()
|
||||
in_list = False
|
||||
else:
|
||||
continue
|
||||
|
||||
# don't parse components comprised of only a separator
|
||||
if subcomponent in QueryFilter.group_seps:
|
||||
if subcomponent in QueryFilterBuilder.group_seps:
|
||||
quote_offset += 1
|
||||
base_components.append(subcomponent)
|
||||
continue
|
||||
@@ -479,8 +506,8 @@ class QueryFilter:
|
||||
continue
|
||||
|
||||
# continue parsing this subcomponent up to the list, then skip over subsequent subcomponents
|
||||
if not in_list and QueryFilter.l_list_sep in subcomponent:
|
||||
subcomponent, _new_sub_component = subcomponent.split(QueryFilter.l_list_sep, maxsplit=1)
|
||||
if not in_list and QueryFilterBuilder.l_list_sep in subcomponent:
|
||||
subcomponent, _new_sub_component = subcomponent.split(QueryFilterBuilder.l_list_sep, maxsplit=1)
|
||||
subcomponent = subcomponent.strip()
|
||||
subcomponents.insert(i + 1, _new_sub_component)
|
||||
quote_offset += 1
|
||||
@@ -516,19 +543,19 @@ class QueryFilter:
|
||||
@staticmethod
|
||||
def _parse_base_components_into_filter_components(
|
||||
base_components: list[str | list[str]],
|
||||
) -> list[str | QueryFilterComponent | LogicalOperator]:
|
||||
) -> list[str | QueryFilterBuilderComponent | LogicalOperator]:
|
||||
"""Walk through base components and construct filter collections"""
|
||||
relational_keywords = [kw.value for kw in RelationalKeyword]
|
||||
relational_operators = [op.value for op in RelationalOperator]
|
||||
logical_operators = [op.value for op in LogicalOperator]
|
||||
|
||||
# parse QueryFilterComponents and logical operators
|
||||
components: list[str | QueryFilterComponent | LogicalOperator] = []
|
||||
components: list[str | QueryFilterBuilderComponent | LogicalOperator] = []
|
||||
for i, base_component in enumerate(base_components):
|
||||
if isinstance(base_component, list):
|
||||
continue
|
||||
|
||||
if base_component in QueryFilter.group_seps:
|
||||
if base_component in QueryFilterBuilder.group_seps:
|
||||
components.append(base_component)
|
||||
|
||||
elif base_component in relational_keywords or base_component in relational_operators:
|
||||
@@ -539,7 +566,7 @@ class QueryFilter:
|
||||
relationship = RelationalOperator(base_components[i])
|
||||
|
||||
components.append(
|
||||
QueryFilterComponent(
|
||||
QueryFilterBuilderComponent(
|
||||
attribute_name=base_components[i - 1], # type: ignore
|
||||
relationship=relationship,
|
||||
value=base_components[i + 1],
|
||||
@@ -550,3 +577,47 @@ class QueryFilter:
|
||||
components.append(LogicalOperator(base_component.upper()))
|
||||
|
||||
return components
|
||||
|
||||
def as_json_model(self) -> QueryFilterJSON:
|
||||
parts: list[QueryFilterJSONPart] = []
|
||||
|
||||
current_part: QueryFilterJSONPart | None = None
|
||||
left_parens: list[str] = []
|
||||
right_parens: list[str] = []
|
||||
last_logical_operator: LogicalOperator | None = None
|
||||
|
||||
def add_part():
|
||||
nonlocal current_part, left_parens, right_parens, last_logical_operator
|
||||
if not current_part:
|
||||
return
|
||||
|
||||
current_part.left_parenthesis = "".join(left_parens) or None
|
||||
current_part.right_parenthesis = "".join(right_parens) or None
|
||||
current_part.logical_operator = last_logical_operator
|
||||
|
||||
parts.append(current_part)
|
||||
current_part = None
|
||||
left_parens.clear()
|
||||
right_parens.clear()
|
||||
last_logical_operator = None
|
||||
|
||||
for component in self.filter_components:
|
||||
if isinstance(component, QueryFilterBuilderComponent):
|
||||
if current_part:
|
||||
add_part()
|
||||
current_part = component.as_json_model()
|
||||
|
||||
elif isinstance(component, LogicalOperator):
|
||||
if current_part:
|
||||
add_part()
|
||||
last_logical_operator = component
|
||||
|
||||
elif isinstance(component, str):
|
||||
if component == QueryFilterBuilder.l_group_sep:
|
||||
left_parens.append(component)
|
||||
elif component == QueryFilterBuilder.r_group_sep:
|
||||
right_parens.append(component)
|
||||
|
||||
# add last part, if any
|
||||
add_part()
|
||||
return QueryFilterJSON(parts=parts)
|
||||
|
||||
@@ -37,18 +37,18 @@ from .user_passwords import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CreateUserRegistration",
|
||||
"CredentialsRequest",
|
||||
"CredentialsRequestForm",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"UnlockResults",
|
||||
"ForgotPassword",
|
||||
"PasswordResetToken",
|
||||
"PrivatePasswordResetToken",
|
||||
"ResetPassword",
|
||||
"SavePasswordResetToken",
|
||||
"ValidateResetToken",
|
||||
"CredentialsRequest",
|
||||
"CredentialsRequestForm",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"UnlockResults",
|
||||
"CreateUserRegistration",
|
||||
"ChangePassword",
|
||||
"CreateToken",
|
||||
"DeleteTokenResponse",
|
||||
|
||||
Reference in New Issue
Block a user