mirror of
https://github.com/apache/superset.git
synced 2026-04-16 06:34:52 +00:00
[panoramix] -> [dashed]
This commit is contained in:
588
dashed/forms.py
Normal file
588
dashed/forms.py
Normal file
@@ -0,0 +1,588 @@
|
||||
from wtforms import (
|
||||
Form, SelectMultipleField, SelectField, TextField, TextAreaField,
|
||||
BooleanField, IntegerField, HiddenField)
|
||||
from wtforms import validators, widgets
|
||||
from copy import copy
|
||||
from dashed import app
|
||||
from collections import OrderedDict
|
||||
config = app.config
|
||||
|
||||
|
||||
class BetterBooleanField(BooleanField):
|
||||
|
||||
"""
|
||||
Fixes behavior of html forms omitting non checked <input>
|
||||
(which doesn't distinguish False from NULL/missing )
|
||||
If value is unchecked, this hidden <input> fills in False value
|
||||
"""
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
html = super(BetterBooleanField, self).__call__(**kwargs)
|
||||
html += u'<input type="hidden" name="{}" value="false">'.format(self.name)
|
||||
return widgets.HTMLString(html)
|
||||
|
||||
|
||||
class SelectMultipleSortableField(SelectMultipleField):
|
||||
|
||||
"""Works along with select2sortable to preserves the sort order"""
|
||||
|
||||
def iter_choices(self):
|
||||
d = OrderedDict()
|
||||
for value, label in self.choices:
|
||||
selected = self.data is not None and self.coerce(value) in self.data
|
||||
d[value] = (value, label, selected)
|
||||
if self.data:
|
||||
for value in self.data:
|
||||
if value:
|
||||
yield d.pop(value)
|
||||
while d:
|
||||
yield d.pop(d.keys()[0])
|
||||
|
||||
|
||||
class FreeFormSelect(widgets.Select):
|
||||
|
||||
"""A WTF widget that allows for free form entry"""
|
||||
|
||||
def __call__(self, field, **kwargs):
|
||||
kwargs.setdefault('id', field.id)
|
||||
if self.multiple:
|
||||
kwargs['multiple'] = True
|
||||
html = ['<select %s>' % widgets.html_params(name=field.name, **kwargs)]
|
||||
found = False
|
||||
for val, label, selected in field.iter_choices():
|
||||
html.append(self.render_option(val, label, selected))
|
||||
if field.data and val == field.data:
|
||||
found = True
|
||||
if not found:
|
||||
html.insert(1, self.render_option(field.data, field.data, True))
|
||||
html.append('</select>')
|
||||
return widgets.HTMLString(''.join(html))
|
||||
|
||||
|
||||
class FreeFormSelectField(SelectField):
|
||||
|
||||
""" A WTF SelectField that allows for free form input """
|
||||
|
||||
widget = FreeFormSelect()
|
||||
def pre_validate(self, form):
|
||||
return
|
||||
|
||||
|
||||
class OmgWtForm(Form):
|
||||
|
||||
"""Dashedification of the WTForm Form object"""
|
||||
|
||||
fieldsets = {}
|
||||
css_classes = dict()
|
||||
|
||||
def get_field(self, fieldname):
|
||||
return getattr(self, fieldname)
|
||||
|
||||
def field_css_classes(self, fieldname):
|
||||
if fieldname in self.css_classes:
|
||||
return " ".join(self.css_classes[fieldname])
|
||||
return ""
|
||||
|
||||
|
||||
class FormFactory(object):
|
||||
"""Used to create the forms in the explore view dynamically"""
|
||||
series_limits = [0, 5, 10, 25, 50, 100, 500]
|
||||
fieltype_class = {
|
||||
SelectField: 'select2',
|
||||
SelectMultipleField: 'select2',
|
||||
FreeFormSelectField: 'select2_freeform',
|
||||
SelectMultipleSortableField: 'select2Sortable',
|
||||
}
|
||||
|
||||
def __init__(self, viz):
|
||||
self.viz = viz
|
||||
from dashed.viz import viz_types
|
||||
viz = self.viz
|
||||
datasource = viz.datasource
|
||||
default_metric = datasource.metrics_combo[0][0]
|
||||
default_groupby = datasource.groupby_column_names[0]
|
||||
group_by_choices = [(s, s) for s in datasource.groupby_column_names]
|
||||
# Pool of all the fields that can be used in Dashed
|
||||
self.field_dict = {
|
||||
'viz_type': SelectField(
|
||||
'Viz',
|
||||
default='table',
|
||||
choices=[(k, v.verbose_name) for k, v in viz_types.items()],
|
||||
description="The type of visualization to display"),
|
||||
'metrics': SelectMultipleSortableField(
|
||||
'Metrics', choices=datasource.metrics_combo,
|
||||
default=[default_metric],
|
||||
description="One or many metrics to display"),
|
||||
'metric': SelectField(
|
||||
'Metric', choices=datasource.metrics_combo,
|
||||
default=default_metric,
|
||||
description="Chose the metric"),
|
||||
'stacked_style': SelectField(
|
||||
'Chart Style', choices=self.choicify(
|
||||
['stack', 'stream', 'expand']),
|
||||
default='stack',
|
||||
description=""),
|
||||
'linear_color_scheme': SelectField(
|
||||
'Color Scheme', choices=self.choicify([
|
||||
'fire', 'blue_white_yellow', 'white_black',
|
||||
'black_white']),
|
||||
default='fire',
|
||||
description=""),
|
||||
'normalize_across': SelectField(
|
||||
'Normalize Across', choices=self.choicify([
|
||||
'heatmap', 'x', 'y']),
|
||||
default='heatmap',
|
||||
description=(
|
||||
"Color will be rendered based on a ratio "
|
||||
"of the cell against the sum of across this "
|
||||
"criteria")),
|
||||
'canvas_image_rendering': SelectField(
|
||||
'Rendering', choices=(
|
||||
('pixelated', 'pixelated (Sharp)'),
|
||||
('auto', 'auto (Smooth)'),
|
||||
),
|
||||
default='pixelated',
|
||||
description=(
|
||||
"image-rendering CSS attribute of the canvas object that "
|
||||
"defines how the browser scales up the image")),
|
||||
'xscale_interval': SelectField(
|
||||
'XScale Interval', choices=self.choicify(range(1, 50)),
|
||||
default='1',
|
||||
description=(
|
||||
"Number of step to take between ticks when "
|
||||
"printing the x scale")),
|
||||
'yscale_interval': SelectField(
|
||||
'YScale Interval', choices=self.choicify(range(1, 50)),
|
||||
default='1',
|
||||
description=(
|
||||
"Number of step to take between ticks when "
|
||||
"printing the y scale")),
|
||||
'bar_stacked': BetterBooleanField(
|
||||
'Stacked Bars',
|
||||
default=False,
|
||||
description=""),
|
||||
'secondary_metric': SelectField(
|
||||
'Color Metric', choices=datasource.metrics_combo,
|
||||
default=default_metric,
|
||||
description="A metric to use for color"),
|
||||
'country_fieldtype': SelectField(
|
||||
'Country Field Type',
|
||||
default='cca2',
|
||||
choices=(
|
||||
('name', 'Full name'),
|
||||
('cioc', 'code International Olympic Committee (cioc)'),
|
||||
('cca2', 'code ISO 3166-1 alpha-2 (cca2)'),
|
||||
('cca3', 'code ISO 3166-1 alpha-3 (cca3)'),
|
||||
),
|
||||
description=(
|
||||
"The country code standard that Dashed should expect "
|
||||
"to find in the [country] column")),
|
||||
'groupby': SelectMultipleSortableField(
|
||||
'Group by',
|
||||
choices=self.choicify(datasource.groupby_column_names),
|
||||
description="One or many fields to group by"),
|
||||
'columns': SelectMultipleSortableField(
|
||||
'Columns',
|
||||
choices=self.choicify(datasource.groupby_column_names),
|
||||
description="One or many fields to pivot as columns"),
|
||||
'all_columns': SelectMultipleSortableField(
|
||||
'Columns',
|
||||
choices=self.choicify(datasource.column_names),
|
||||
description="Columns to display"),
|
||||
'all_columns_x': SelectField(
|
||||
'X',
|
||||
choices=self.choicify(datasource.column_names),
|
||||
description="Columns to display"),
|
||||
'all_columns_y': SelectField(
|
||||
'Y',
|
||||
choices=self.choicify(datasource.column_names),
|
||||
description="Columns to display"),
|
||||
'granularity': FreeFormSelectField(
|
||||
'Time Granularity', default="one day",
|
||||
choices=self.choicify([
|
||||
'all',
|
||||
'5 seconds',
|
||||
'30 seconds',
|
||||
'1 minute',
|
||||
'5 minutes',
|
||||
'1 hour',
|
||||
'6 hour',
|
||||
'1 day',
|
||||
'7 days',
|
||||
]),
|
||||
description=(
|
||||
"The time granularity for the visualization. Note that you "
|
||||
"can type and use simple natural language as in '10 seconds', "
|
||||
"'1 day' or '56 weeks'")),
|
||||
'link_length': FreeFormSelectField(
|
||||
'Link Length', default="200",
|
||||
choices=self.choicify([
|
||||
'10',
|
||||
'25',
|
||||
'50',
|
||||
'75',
|
||||
'100',
|
||||
'150',
|
||||
'200',
|
||||
'250',
|
||||
]),
|
||||
description="Link length in the force layout"),
|
||||
'charge': FreeFormSelectField(
|
||||
'Charge', default="-500",
|
||||
choices=self.choicify([
|
||||
'-50',
|
||||
'-75',
|
||||
'-100',
|
||||
'-150',
|
||||
'-200',
|
||||
'-250',
|
||||
'-500',
|
||||
'-1000',
|
||||
'-2500',
|
||||
'-5000',
|
||||
]),
|
||||
description="Charge in the force layout"),
|
||||
'granularity_sqla': SelectField(
|
||||
'Time Column',
|
||||
default=datasource.main_dttm_col or datasource.any_dttm_col,
|
||||
choices=self.choicify(datasource.dttm_cols),
|
||||
description=(
|
||||
"The time column for the visualization. Note that you "
|
||||
"can define arbitrary expression that return a DATETIME "
|
||||
"column in the table editor. Also note that the "
|
||||
"filter bellow is applied against this column or "
|
||||
"expression")),
|
||||
'resample_rule': FreeFormSelectField(
|
||||
'Resample Rule', default='',
|
||||
choices=self.choicify(('1T', '1H', '1D', '7D', '1M', '1AS')),
|
||||
description=("Pandas resample rule")),
|
||||
'resample_how': FreeFormSelectField(
|
||||
'Resample How', default='',
|
||||
choices=self.choicify(('', 'mean', 'sum', 'median')),
|
||||
description=("Pandas resample how")),
|
||||
'resample_fillmethod': FreeFormSelectField(
|
||||
'Resample Fill Method', default='',
|
||||
choices=self.choicify(('', 'ffill', 'bfill')),
|
||||
description=("Pandas resample fill method")),
|
||||
'since': FreeFormSelectField(
|
||||
'Since', default="7 days ago",
|
||||
choices=self.choicify([
|
||||
'1 hour ago',
|
||||
'12 hours ago',
|
||||
'1 day ago',
|
||||
'7 days ago',
|
||||
'28 days ago',
|
||||
'90 days ago',
|
||||
'1 year ago'
|
||||
]),
|
||||
description=(
|
||||
"Timestamp from filter. This supports free form typing and "
|
||||
"natural language as in '1 day ago', '28 days' or '3 years'")),
|
||||
'until': FreeFormSelectField('Until', default="now",
|
||||
choices=self.choicify([
|
||||
'now',
|
||||
'1 day ago',
|
||||
'7 days ago',
|
||||
'28 days ago',
|
||||
'90 days ago',
|
||||
'1 year ago'])
|
||||
),
|
||||
'max_bubble_size': FreeFormSelectField(
|
||||
'Max Bubble Size', default="25",
|
||||
choices=self.choicify([
|
||||
'5',
|
||||
'10',
|
||||
'15',
|
||||
'25',
|
||||
'50',
|
||||
'75',
|
||||
'100',
|
||||
])
|
||||
),
|
||||
'row_limit':
|
||||
FreeFormSelectField(
|
||||
'Row limit',
|
||||
default=config.get("ROW_LIMIT"),
|
||||
choices=self.choicify(
|
||||
[10, 50, 100, 250, 500, 1000, 5000, 10000, 50000])),
|
||||
'limit':
|
||||
FreeFormSelectField(
|
||||
'Series limit',
|
||||
choices=self.choicify(self.series_limits),
|
||||
default=50,
|
||||
description=(
|
||||
"Limits the number of time series that get displayed")),
|
||||
'rolling_type': SelectField(
|
||||
'Rolling',
|
||||
default='None',
|
||||
choices=[(s, s) for s in ['None', 'mean', 'sum', 'std', 'cumsum']],
|
||||
description=(
|
||||
"Defines a rolling window function to apply, works along "
|
||||
"with the [Periods] text box")),
|
||||
'rolling_periods': IntegerField(
|
||||
'Periods',
|
||||
validators=[validators.optional()],
|
||||
description=(
|
||||
"Defines the size of the rolling window function, "
|
||||
"relative to the time granularity selected")),
|
||||
'series': SelectField(
|
||||
'Series', choices=group_by_choices,
|
||||
default=default_groupby,
|
||||
description=(
|
||||
"Defines the grouping of entities. "
|
||||
"Each serie is shown as a specific color on the chart and "
|
||||
"has a legend toggle")),
|
||||
'entity': SelectField('Entity', choices=group_by_choices,
|
||||
default=default_groupby,
|
||||
description="This define the element to be plotted on the chart"),
|
||||
'x': SelectField(
|
||||
'X Axis', choices=datasource.metrics_combo,
|
||||
default=default_metric,
|
||||
description="Metric assigned to the [X] axis"),
|
||||
'y': SelectField('Y Axis', choices=datasource.metrics_combo,
|
||||
default=default_metric,
|
||||
description="Metric assigned to the [Y] axis"),
|
||||
'size': SelectField(
|
||||
'Bubble Size',
|
||||
default=default_metric,
|
||||
choices=datasource.metrics_combo),
|
||||
'url': TextField(
|
||||
'URL', default='www.airbnb.com',),
|
||||
'where': TextField(
|
||||
'Custom WHERE clause', default='',
|
||||
description=(
|
||||
"The text in this box gets included in your query's WHERE "
|
||||
"clause, as an AND to other criteria. You can include "
|
||||
"complex expression, parenthesis and anything else "
|
||||
"supported by the backend it is directed towards.")),
|
||||
'having': TextField('Custom HAVING clause', default='',
|
||||
description=(
|
||||
"The text in this box gets included in your query's HAVING"
|
||||
" clause, as an AND to other criteria. You can include "
|
||||
"complex expression, parenthesis and anything else "
|
||||
"supported by the backend it is directed towards.")),
|
||||
'compare_lag': TextField('Comparison Period Lag',
|
||||
description=(
|
||||
"Based on granularity, number of time periods to "
|
||||
"compare against")),
|
||||
'compare_suffix': TextField('Comparison suffix',
|
||||
description="Suffix to apply after the percentage display"),
|
||||
'x_axis_format': FreeFormSelectField('X axis format',
|
||||
default='smart_date',
|
||||
choices=[
|
||||
('smart_date', 'Adaptative formating'),
|
||||
("%m/%d/%Y", '"%m/%d/%Y" | 01/14/2019'),
|
||||
("%Y-%m-%d", '"%Y-%m-%d" | 2019-01-14'),
|
||||
("%Y-%m-%d %H:%M:%S",
|
||||
'"%Y-%m-%d %H:%M:%S" | 2019-01-14 01:32:10'),
|
||||
("%H:%M:%S", '"%H:%M:%S" | 01:32:10'),
|
||||
],
|
||||
description="D3 format syntax for y axis "
|
||||
"https://github.com/mbostock/\n"
|
||||
"d3/wiki/Formatting"),
|
||||
'y_axis_format': FreeFormSelectField('Y axis format',
|
||||
default='.3s',
|
||||
choices=[
|
||||
('.3s', '".3s" | 12.3k'),
|
||||
('.3%', '".3%" | 1234543.210%'),
|
||||
('.4r', '".4r" | 12350'),
|
||||
('.3f', '".3f" | 12345.432'),
|
||||
('+,', '"+," | +12,345.4321'),
|
||||
('$,.2f', '"$,.2f" | $12,345.43'),
|
||||
],
|
||||
description="D3 format syntax for y axis "
|
||||
"https://github.com/mbostock/\n"
|
||||
"d3/wiki/Formatting"),
|
||||
'markup_type': SelectField(
|
||||
"Markup Type",
|
||||
choices=self.choicify(['markdown', 'html']),
|
||||
default="markdown",
|
||||
description="Pick your favorite markup language"),
|
||||
'rotation': SelectField(
|
||||
"Rotation",
|
||||
choices=[(s, s) for s in ['random', 'flat', 'square']],
|
||||
default="random",
|
||||
description="Rotation to apply to words in the cloud"),
|
||||
'line_interpolation': SelectField(
|
||||
"Line Style",
|
||||
choices=self.choicify([
|
||||
'linear', 'basis', 'cardinal', 'monotone',
|
||||
'step-before', 'step-after']),
|
||||
default='linear',
|
||||
description="Line interpolation as defined by d3.js"),
|
||||
'code': TextAreaField(
|
||||
"Code", description="Put your code here", default=''),
|
||||
'pandas_aggfunc': SelectField(
|
||||
"Aggregation function",
|
||||
choices=self.choicify([
|
||||
'sum', 'mean', 'min', 'max', 'median', 'stdev', 'var']),
|
||||
default='sum',
|
||||
description=(
|
||||
"Aggregate function to apply when pivoting and "
|
||||
"computing the total rows and columns")),
|
||||
'size_from': TextField(
|
||||
"Font Size From",
|
||||
default="20",
|
||||
description="Font size for the smallest value in the list"),
|
||||
'size_to': TextField(
|
||||
"Font Size To",
|
||||
default="150",
|
||||
description="Font size for the biggest value in the list"),
|
||||
'show_brush': BetterBooleanField(
|
||||
"Range Filter", default=False,
|
||||
description=(
|
||||
"Whether to display the time range interactive selector")),
|
||||
'show_datatable': BetterBooleanField(
|
||||
"Data Table", default=False,
|
||||
description="Whether to display the interactive data table"),
|
||||
'include_search': BetterBooleanField(
|
||||
"Search Box", default=False,
|
||||
description=(
|
||||
"Whether to include a client side search box")),
|
||||
'show_bubbles': BetterBooleanField(
|
||||
"Show Bubbles", default=False,
|
||||
description=(
|
||||
"Whether to display bubbles on top of countries")),
|
||||
'show_legend': BetterBooleanField(
|
||||
"Legend", default=True,
|
||||
description="Whether to display the legend (toggles)"),
|
||||
'x_axis_showminmax': BetterBooleanField(
|
||||
"X bounds", default=True,
|
||||
description=(
|
||||
"Whether to display the min and max values of the X axis")),
|
||||
'rich_tooltip': BetterBooleanField(
|
||||
"Rich Tooltip", default=True,
|
||||
description=(
|
||||
"The rich tooltip shows a list of all series for that"
|
||||
" point in time")),
|
||||
'y_axis_zero': BetterBooleanField(
|
||||
"Y Axis Zero", default=False,
|
||||
description=(
|
||||
"Force the Y axis to start at 0 instead of the minimum "
|
||||
"value")),
|
||||
'y_log_scale': BetterBooleanField(
|
||||
"Y Log", default=False,
|
||||
description="Use a log scale for the Y axis"),
|
||||
'x_log_scale': BetterBooleanField(
|
||||
"X Log", default=False,
|
||||
description="Use a log scale for the X axis"),
|
||||
'donut': BetterBooleanField(
|
||||
"Donut", default=False,
|
||||
description="Do you want a donut or a pie?"),
|
||||
'contribution': BetterBooleanField(
|
||||
"Contribution", default=False,
|
||||
description="Compute the contribution to the total"),
|
||||
'num_period_compare': IntegerField(
|
||||
"Period Ratio", default=None,
|
||||
validators=[validators.optional()],
|
||||
description=(
|
||||
"[integer] Number of period to compare against, "
|
||||
"this is relative to the granularity selected")),
|
||||
'time_compare': TextField(
|
||||
"Time Shift",
|
||||
default="",
|
||||
description=(
|
||||
"Overlay a timeseries from a "
|
||||
"relative time period. Expects relative time delta "
|
||||
"in natural language (example: 24 hours, 7 days, "
|
||||
"56 weeks, 365 days")),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def choicify(l):
|
||||
return [("{}".format(obj), "{}".format(obj)) for obj in l]
|
||||
|
||||
def get_form(self):
|
||||
"""Returns a form object based on the viz/datasource/context"""
|
||||
viz = self.viz
|
||||
field_css_classes = {}
|
||||
for name, obj in self.field_dict.items():
|
||||
field_css_classes[name] = ['form-control']
|
||||
s = self.fieltype_class.get(obj.field_class)
|
||||
if s:
|
||||
field_css_classes[name] += [s]
|
||||
|
||||
for field in ('show_brush', 'show_legend', 'rich_tooltip'):
|
||||
field_css_classes[field] += ['input-sm']
|
||||
|
||||
class QueryForm(OmgWtForm):
|
||||
fieldsets = copy(viz.fieldsets)
|
||||
css_classes = field_css_classes
|
||||
standalone = HiddenField()
|
||||
async = HiddenField()
|
||||
extra_filters = HiddenField()
|
||||
json = HiddenField()
|
||||
slice_id = HiddenField()
|
||||
slice_name = HiddenField()
|
||||
previous_viz_type = HiddenField(default=viz.viz_type)
|
||||
collapsed_fieldsets = HiddenField()
|
||||
viz_type = self.field_dict.get('viz_type')
|
||||
|
||||
filter_cols = viz.datasource.filterable_column_names or ['']
|
||||
for i in range(10):
|
||||
setattr(QueryForm, 'flt_col_' + str(i), SelectField(
|
||||
'Filter 1',
|
||||
default=filter_cols[0],
|
||||
choices=self.choicify(filter_cols)))
|
||||
setattr(QueryForm, 'flt_op_' + str(i), SelectField(
|
||||
'Filter 1',
|
||||
default='in',
|
||||
choices=self.choicify(['in', 'not in'])))
|
||||
setattr(
|
||||
QueryForm, 'flt_eq_' + str(i),
|
||||
TextField("Super", default=''))
|
||||
|
||||
for field in viz.flat_form_fields():
|
||||
setattr(QueryForm, field, self.field_dict[field])
|
||||
|
||||
def add_to_form(attrs):
|
||||
for attr in attrs:
|
||||
setattr(QueryForm, attr, self.field_dict[attr])
|
||||
|
||||
# datasource type specific form elements
|
||||
if viz.datasource.__class__.__name__ == 'SqlaTable':
|
||||
QueryForm.fieldsets += ({
|
||||
'label': 'SQL',
|
||||
'fields': ['where', 'having'],
|
||||
'description': (
|
||||
"This section exposes ways to include snippets of "
|
||||
"SQL in your query"),
|
||||
},)
|
||||
add_to_form(('where', 'having'))
|
||||
grains = viz.datasource.database.grains()
|
||||
|
||||
if not viz.datasource.any_dttm_col:
|
||||
return QueryForm
|
||||
if grains:
|
||||
time_fields = ('granularity_sqla', 'time_grain_sqla')
|
||||
self.field_dict['time_grain_sqla'] = SelectField(
|
||||
'Time Grain',
|
||||
choices=self.choicify((grain.name for grain in grains)),
|
||||
default="Time Column",
|
||||
description=(
|
||||
"The time granularity for the visualization. This "
|
||||
"applies a date transformation to alter "
|
||||
"your time column and defines a new time granularity."
|
||||
"The options here are defined on a per database "
|
||||
"engine basis in the Dashed source code"))
|
||||
add_to_form(time_fields)
|
||||
field_css_classes['time_grain_sqla'] = ['form-control', 'select2']
|
||||
field_css_classes['granularity_sqla'] = ['form-control', 'select2']
|
||||
else:
|
||||
time_fields = 'granularity_sqla'
|
||||
add_to_form((time_fields, ))
|
||||
else:
|
||||
time_fields = 'granularity'
|
||||
add_to_form(('granularity',))
|
||||
field_css_classes['granularity'] = ['form-control', 'select2']
|
||||
add_to_form(('since', 'until'))
|
||||
|
||||
QueryForm.fieldsets = ({
|
||||
'label': 'Time',
|
||||
'fields': (
|
||||
time_fields,
|
||||
('since', 'until'),
|
||||
),
|
||||
'description': "Time related form attributes",
|
||||
},) + tuple(QueryForm.fieldsets)
|
||||
return QueryForm
|
||||
Reference in New Issue
Block a user