Implement query strings for shareable search links
This commit is contained in:
parent
c1071f3d55
commit
648526a6bf
|
@ -141,7 +141,7 @@ def run_main_query(client, user, query, custom_query=False, index=None, size=Non
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def query_results(request, size=None):
|
def query_results(request, query_params, size=None):
|
||||||
"""
|
"""
|
||||||
API helper to alter the OpenSearch return format into something
|
API helper to alter the OpenSearch return format into something
|
||||||
a bit better to parse.
|
a bit better to parse.
|
||||||
|
@ -160,14 +160,14 @@ def query_results(request, size=None):
|
||||||
else:
|
else:
|
||||||
sizes = settings.OPENSEARCH_MAIN_SIZES
|
sizes = settings.OPENSEARCH_MAIN_SIZES
|
||||||
if not size:
|
if not size:
|
||||||
if "size" in request.POST:
|
if "size" in query_params:
|
||||||
size = request.POST["size"]
|
size = query_params["size"]
|
||||||
if size not in sizes:
|
if size not in sizes:
|
||||||
message = "Size is not permitted"
|
message = "Size is not permitted"
|
||||||
message_class = "danger"
|
message_class = "danger"
|
||||||
return {"message": message, "class": message_class}
|
return {"message": message, "class": message_class}
|
||||||
if "source" in request.POST:
|
if "source" in query_params:
|
||||||
source = request.POST["source"]
|
source = query_params["source"]
|
||||||
if source not in settings.OPENSEARCH_MAIN_SOURCES:
|
if source not in settings.OPENSEARCH_MAIN_SOURCES:
|
||||||
message = "Invalid source"
|
message = "Invalid source"
|
||||||
message_class = "danger"
|
message_class = "danger"
|
||||||
|
@ -175,16 +175,13 @@ def query_results(request, size=None):
|
||||||
if source != "all":
|
if source != "all":
|
||||||
add_bool.append({"src": source})
|
add_bool.append({"src": source})
|
||||||
|
|
||||||
if "dates" in request.POST:
|
if set({"from_date", "to_date", "from_time", "to_time"}).issubset(
|
||||||
dates = request.POST["dates"]
|
query_params.keys()
|
||||||
spl = dates.split(" - ")
|
):
|
||||||
if all(spl):
|
from_ts = f"{query_params['from_date']}T{query_params['from_time']}Z"
|
||||||
spl = [f"{x.replace(' ', 'T')}Z" for x in spl]
|
to_ts = f"{query_params['to_date']}T{query_params['to_time']}Z"
|
||||||
if not len(spl) == 2:
|
print("from ts", from_ts)
|
||||||
message = "Invalid dates"
|
print("to_ts", to_ts)
|
||||||
message_class = "danger"
|
|
||||||
return {"message": message, "class": message_class}
|
|
||||||
from_ts, to_ts = spl
|
|
||||||
range_query = {
|
range_query = {
|
||||||
"range": {
|
"range": {
|
||||||
"ts": {
|
"ts": {
|
||||||
|
@ -194,8 +191,8 @@ def query_results(request, size=None):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
add_top.append(range_query)
|
add_top.append(range_query)
|
||||||
if "sorting" in request.POST:
|
if "sorting" in query_params:
|
||||||
sorting = request.POST["sorting"]
|
sorting = query_params["sorting"]
|
||||||
if sorting not in ("asc", "desc", "none"):
|
if sorting not in ("asc", "desc", "none"):
|
||||||
message = "Invalid sort"
|
message = "Invalid sort"
|
||||||
message_class = "danger"
|
message_class = "danger"
|
||||||
|
@ -209,20 +206,20 @@ def query_results(request, size=None):
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if "check-sentiment" in request.POST:
|
if "check_sentiment" in query_params:
|
||||||
if "sentiment-method" not in request.POST:
|
if "sentiment_method" not in query_params:
|
||||||
message = "No sentiment method"
|
message = "No sentiment method"
|
||||||
message_class = "danger"
|
message_class = "danger"
|
||||||
return {"message": message, "class": message_class}
|
return {"message": message, "class": message_class}
|
||||||
if "sentiment" in request.POST:
|
if "sentiment" in query_params:
|
||||||
sentiment = request.POST["sentiment"]
|
sentiment = query_params["sentiment"]
|
||||||
try:
|
try:
|
||||||
sentiment = float(sentiment)
|
sentiment = float(sentiment)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
message = "Sentiment is not a float"
|
message = "Sentiment is not a float"
|
||||||
message_class = "danger"
|
message_class = "danger"
|
||||||
return {"message": message, "class": message_class}
|
return {"message": message, "class": message_class}
|
||||||
sentiment_method = request.POST["sentiment-method"]
|
sentiment_method = query_params["sentiment_method"]
|
||||||
range_query_compare = {"range": {"sentiment": {}}}
|
range_query_compare = {"range": {"sentiment": {}}}
|
||||||
range_query_precise = {
|
range_query_precise = {
|
||||||
"match": {
|
"match": {
|
||||||
|
@ -242,8 +239,8 @@ def query_results(request, size=None):
|
||||||
range_query_precise["match"]["sentiment"] = 0
|
range_query_precise["match"]["sentiment"] = 0
|
||||||
add_top_negative.append(range_query_precise)
|
add_top_negative.append(range_query_precise)
|
||||||
|
|
||||||
if "query" in request.POST:
|
if "query" in query_params:
|
||||||
query = request.POST["query"]
|
query = query_params["query"]
|
||||||
search_query = construct_query(query, size)
|
search_query = construct_query(query, size)
|
||||||
if add_bool:
|
if add_bool:
|
||||||
for item in add_bool:
|
for item in add_bool:
|
||||||
|
|
|
@ -81,7 +81,7 @@
|
||||||
hx-post="{% url 'home' %}"
|
hx-post="{% url 'home' %}"
|
||||||
hx-trigger="keyup changed delay:200ms"
|
hx-trigger="keyup changed delay:200ms"
|
||||||
hx-target="#results"
|
hx-target="#results"
|
||||||
hx-swap="innerHTML" id="query" name="query" class="input" type="text" placeholder="msg: science AND nick: BillNye AND channel: #science">
|
hx-swap="innerHTML" id="query" name="query" value="{{ params.query }}" class="input" type="text" placeholder="msg: science AND nick: BillNye AND channel: #science">
|
||||||
<span class="icon is-small is-left">
|
<span class="icon is-small is-left">
|
||||||
<i class="fas fa-magnifying-glass"></i>
|
<i class="fas fa-magnifying-glass"></i>
|
||||||
</span>
|
</span>
|
||||||
|
@ -122,7 +122,11 @@
|
||||||
<span class="select">
|
<span class="select">
|
||||||
<select name="size">
|
<select name="size">
|
||||||
{% for size in sizes %}
|
{% for size in sizes %}
|
||||||
|
{% if size == params.size %}
|
||||||
|
<option selected value="{{ size }}">{{ size }}</option>
|
||||||
|
{% else %}
|
||||||
<option value="{{ size }}">{{ size }}</option>
|
<option value="{{ size }}">{{ size }}</option>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="icon is-small is-left">
|
<span class="icon is-small is-left">
|
||||||
|
@ -142,9 +146,26 @@
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<span class="select">
|
<span class="select">
|
||||||
<select id="source" name="source">
|
<select id="source" name="source">
|
||||||
<option selected value="all">All</option>
|
{% if params.source == 'irc' %}
|
||||||
|
<option selected value="irc">IRC</option>
|
||||||
|
{% else %}
|
||||||
<option value="irc">IRC</option>
|
<option value="irc">IRC</option>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if params.source == 'dis' %}
|
||||||
|
<option selected value="dis">Discord</option>
|
||||||
|
{% else %}
|
||||||
<option value="dis">Discord</option>
|
<option value="dis">Discord</option>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if params.source == None %}
|
||||||
|
<option selected value="all">All</option>
|
||||||
|
{% elif params.source == 'all' %}
|
||||||
|
<option selected value="all">All</option>
|
||||||
|
{% else %}
|
||||||
|
<option value="all">All</option>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</select>
|
</select>
|
||||||
<span class="icon is-small is-left">
|
<span class="icon is-small is-left">
|
||||||
<i class="fas fa-magnifying-glass"></i>
|
<i class="fas fa-magnifying-glass"></i>
|
||||||
|
@ -162,8 +183,24 @@
|
||||||
<div id="sentiment">
|
<div id="sentiment">
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input disabled="undefined" name="sentiment" id="sliderWithValue" class="slider has-output-tooltip is-fullwidth" min="-1" max="1" value="0" step="0.05" type="range">
|
<input
|
||||||
<output for="sliderWithValue" class="slider-output">0</output>
|
{% if params.check_sentiment != "on" %}
|
||||||
|
disabled="undefined"
|
||||||
|
{% endif %}
|
||||||
|
name="sentiment" id="sliderWithValue" class="slider has-output-tooltip is-fullwidth" min="-1" max="1"
|
||||||
|
{% if params.sentiment == None %}
|
||||||
|
value="0"
|
||||||
|
{% else %}
|
||||||
|
value="{{ params.sentiment }}"
|
||||||
|
{% endif %}
|
||||||
|
step="0.05" type="range">
|
||||||
|
<output for="sliderWithValue" class="slider-output">
|
||||||
|
{% if params.sentiment == None %}
|
||||||
|
0
|
||||||
|
{% else %}
|
||||||
|
{{ params.sentiment }}
|
||||||
|
{% endif %}
|
||||||
|
</output>
|
||||||
<script>bulmaSlider.attach();</script>
|
<script>bulmaSlider.attach();</script>
|
||||||
</div>
|
</div>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
|
@ -174,25 +211,47 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label class="radio button has-text-link">
|
<label class="radio button has-text-link">
|
||||||
<input type="radio" value="below" name="sentiment-method">
|
|
||||||
|
<input type="radio"
|
||||||
|
value="below"
|
||||||
|
{% if params.sentiment_method == 'below' %}
|
||||||
|
checked
|
||||||
|
{% endif %}
|
||||||
|
name="sentiment_method">
|
||||||
<span class="icon" data-tooltip="Below">
|
<span class="icon" data-tooltip="Below">
|
||||||
<i class="fa-solid fa-face-frown"></i>
|
<i class="fa-solid fa-face-frown"></i>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="radio button has-text-link is-hidden">
|
<label class="radio button has-text-link is-hidden">
|
||||||
<input type="radio" value="exact" name="sentiment-method">
|
|
||||||
|
<input type="radio"
|
||||||
|
value="exact"
|
||||||
|
{% if params.sentiment_method == 'exact' %}
|
||||||
|
checked
|
||||||
|
{% endif %}
|
||||||
|
name="sentiment_method">
|
||||||
<span class="icon" data-tooltip="Exact">
|
<span class="icon" data-tooltip="Exact">
|
||||||
<i class="fa-solid fa-face-smile"></i>
|
<i class="fa-solid fa-face-smile"></i>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="radio button has-text-link">
|
<label class="radio button has-text-link">
|
||||||
<input type="radio" value="above" name="sentiment-method">
|
<input type="radio"
|
||||||
|
value="above"
|
||||||
|
{% if params.sentiment_method == 'above' %}
|
||||||
|
checked
|
||||||
|
{% endif %}
|
||||||
|
name="sentiment_method">
|
||||||
<span class="icon" data-tooltip="Above">
|
<span class="icon" data-tooltip="Above">
|
||||||
<i class="fa-solid fa-face-smile"></i>
|
<i class="fa-solid fa-face-smile"></i>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="radio button has-text-link">
|
<label class="radio button has-text-link">
|
||||||
<input type="radio" value="nonzero" name="sentiment-method">
|
<input type="radio"
|
||||||
|
value="nonzero"
|
||||||
|
{% if params.sentiment_method == 'nonzero' %}
|
||||||
|
checked
|
||||||
|
{% endif %}
|
||||||
|
name="sentiment_method">
|
||||||
<span class="icon" data-tooltip="Nonzero">
|
<span class="icon" data-tooltip="Nonzero">
|
||||||
<i class="fa-solid fa-face-meh-blank"></i>
|
<i class="fa-solid fa-face-meh-blank"></i>
|
||||||
</span>
|
</span>
|
||||||
|
@ -201,7 +260,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="check-sentiment"
|
<input type="checkbox"
|
||||||
|
name="check_sentiment"
|
||||||
|
{% if params.check_sentiment == "on" %}
|
||||||
|
checked
|
||||||
|
{% endif %}
|
||||||
data-script="on click toggle @disabled on #sliderWithValue then toggle @disabled on #sentiment">
|
data-script="on click toggle @disabled on #sliderWithValue then toggle @disabled on #sentiment">
|
||||||
Check sentiment
|
Check sentiment
|
||||||
</label>
|
</label>
|
||||||
|
@ -210,7 +273,7 @@
|
||||||
<div id="date">
|
<div id="date">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="date" name="dates">
|
<input type="date" name="dates" value="{{ params.date }}">
|
||||||
<script>
|
<script>
|
||||||
var options = {
|
var options = {
|
||||||
"type": "datetime",
|
"type": "datetime",
|
||||||
|
@ -218,6 +281,10 @@
|
||||||
"color": "info",
|
"color": "info",
|
||||||
"validateLabel": "Save",
|
"validateLabel": "Save",
|
||||||
"dateFormat": "yyyy-MM-dd",
|
"dateFormat": "yyyy-MM-dd",
|
||||||
|
"startDate": "{{ params.from_date|escapejs }}",
|
||||||
|
"startTime": "{{ params.from_time|escapejs }}",
|
||||||
|
"endDate": "{{ params.to_date|escapejs }}",
|
||||||
|
"endTime": "{{ params.to_time|escapejs }}",
|
||||||
};
|
};
|
||||||
// Initialize all input of type date
|
// Initialize all input of type date
|
||||||
var calendars = bulmaCalendar.attach('[type="date"]', options);
|
var calendars = bulmaCalendar.attach('[type="date"]', options);
|
||||||
|
@ -234,19 +301,31 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label class="radio button has-text-link">
|
<label class="radio button has-text-link">
|
||||||
<input type="radio" value="desc" name="sorting" checked>
|
<input type="radio" value="desc" name="sorting"
|
||||||
|
{% if params.sorting == None %}
|
||||||
|
checked
|
||||||
|
{% elif params.sorting == 'desc' %}
|
||||||
|
checked
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
<span class="icon" data-tooltip="Sort descending">
|
<span class="icon" data-tooltip="Sort descending">
|
||||||
<i class="fa-solid fa-sort-down"></i>
|
<i class="fa-solid fa-sort-down"></i>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="radio button">
|
<label class="radio button">
|
||||||
<input type="radio" value="asc" name="sorting">
|
<input type="radio" value="asc" name="sorting"
|
||||||
|
{% if params.sorting == 'asc' %}
|
||||||
|
checked
|
||||||
|
{% endif %}>
|
||||||
<span class="icon" data-tooltip="Sort ascending">
|
<span class="icon" data-tooltip="Sort ascending">
|
||||||
<i class="fa-solid fa-sort-up"></i>
|
<i class="fa-solid fa-sort-up"></i>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="radio button">
|
<label class="radio button">
|
||||||
<input type="radio" value="none" name="sorting">
|
<input type="radio" value="none" name="sorting"
|
||||||
|
{% if params.sorting == 'none' %}
|
||||||
|
checked
|
||||||
|
{% endif %}>
|
||||||
<span class="icon" data-tooltip="No sort">
|
<span class="icon" data-tooltip="No sort">
|
||||||
<i class="fa-solid fa-sort"></i>
|
<i class="fa-solid fa-sort"></i>
|
||||||
</span>
|
</span>
|
||||||
|
@ -264,6 +343,9 @@
|
||||||
<script>
|
<script>
|
||||||
var inputTags = document.getElementById('tags');
|
var inputTags = document.getElementById('tags');
|
||||||
new BulmaTagsInput(inputTags);
|
new BulmaTagsInput(inputTags);
|
||||||
|
{% for tag in tags %}
|
||||||
|
inputTags.BulmaTagsInput().add("{{ tag|escapejs }}");
|
||||||
|
{% endfor %}
|
||||||
inputTags.BulmaTagsInput().on('before.add', function(item) {
|
inputTags.BulmaTagsInput().on('before.add', function(item) {
|
||||||
if (item.includes(": ")) {
|
if (item.includes(": ")) {
|
||||||
var spl = item.split(": ");
|
var spl = item.split(": ");
|
||||||
|
@ -293,6 +375,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<div id="results">
|
<div id="results">
|
||||||
|
{% if results %}
|
||||||
|
{% include 'ui/drilldown/results.html' %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="modals-here">
|
<div id="modals-here">
|
||||||
|
|
|
@ -46,32 +46,71 @@ class DrilldownTableView(View, SingleTableMixin):
|
||||||
return HttpResponse("No results")
|
return HttpResponse("No results")
|
||||||
|
|
||||||
|
|
||||||
class Drilldown(View):
|
def parse_dates(dates):
|
||||||
template_name = "ui/drilldown/drilldown.html"
|
spl = dates.split(" - ")
|
||||||
plan_name = "drilldown"
|
if all(spl):
|
||||||
|
spl = [f"{x.replace(' ', 'T')}" for x in spl]
|
||||||
|
if not len(spl) == 2:
|
||||||
|
message = "Invalid dates"
|
||||||
|
message_class = "danger"
|
||||||
|
return {"message": message, "class": message_class}
|
||||||
|
from_ts, to_ts = spl
|
||||||
|
from_date, from_time = from_ts.split("T")
|
||||||
|
to_date, to_time = to_ts.split("T")
|
||||||
|
|
||||||
def get(self, request):
|
return {
|
||||||
if request.user.is_anonymous:
|
"from_date": from_date,
|
||||||
sizes = settings.OPENSEARCH_MAIN_SIZES_ANON
|
"to_date": to_date,
|
||||||
else:
|
"from_time": from_time,
|
||||||
sizes = settings.OPENSEARCH_MAIN_SIZES
|
"to_time": to_time,
|
||||||
context = {
|
|
||||||
"sizes": sizes,
|
|
||||||
}
|
}
|
||||||
return render(request, self.template_name, context)
|
else:
|
||||||
|
message = "Invalid dates"
|
||||||
|
message_class = "danger"
|
||||||
|
return {"message": message, "class": message_class}
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
|
def create_tags(query):
|
||||||
|
"""
|
||||||
|
Grab the tags out of the query and make a list
|
||||||
|
we can add to the Bulma tags element when the page loads.
|
||||||
|
"""
|
||||||
|
spl = query.split("AND")
|
||||||
|
spl = [x.strip() for x in spl if ":" in x]
|
||||||
|
spl = [x.replace('"', "") for x in spl]
|
||||||
|
return spl
|
||||||
|
|
||||||
|
|
||||||
|
def drilldown_search(request):
|
||||||
template_name = "ui/drilldown/results.html"
|
template_name = "ui/drilldown/results.html"
|
||||||
data_args = request.POST.dict()
|
if request.GET:
|
||||||
del data_args["csrfmiddlewaretoken"]
|
query_params = request.GET.dict()
|
||||||
print("rep", repr(data_args["dates"]))
|
elif request.POST:
|
||||||
if data_args["dates"] == " - ":
|
query_params = request.POST.dict()
|
||||||
del data_args["dates"]
|
|
||||||
url_params = urllib.parse.urlencode(data_args)
|
# Parse the dates
|
||||||
print("url_params", url_params)
|
if "dates" in query_params:
|
||||||
context = query_results(request)
|
dates = parse_dates(query_params["dates"])
|
||||||
|
del query_params["dates"]
|
||||||
|
if "message" in dates:
|
||||||
|
return render(request, template_name, dates)
|
||||||
|
query_params["from_date"] = dates["from_date"]
|
||||||
|
query_params["to_date"] = dates["to_date"]
|
||||||
|
query_params["from_time"] = dates["from_time"]
|
||||||
|
query_params["to_time"] = dates["to_time"]
|
||||||
|
|
||||||
|
if request.GET:
|
||||||
|
context = query_results(request, query_params)
|
||||||
|
elif request.POST:
|
||||||
|
context = query_results(request, query_params)
|
||||||
|
|
||||||
|
# Turn the query into tags for populating the taglist
|
||||||
|
tags = create_tags(query_params["query"])
|
||||||
|
context["tags"] = tags
|
||||||
|
|
||||||
|
context["params"] = query_params
|
||||||
if "message" in context:
|
if "message" in context:
|
||||||
return render(request, self.template_name, context)
|
return render(request, template_name, context)
|
||||||
|
|
||||||
context["data"] = json.dumps(
|
context["data"] = json.dumps(
|
||||||
[
|
[
|
||||||
|
@ -86,12 +125,37 @@ class Drilldown(View):
|
||||||
)
|
)
|
||||||
if context:
|
if context:
|
||||||
response = render(request, template_name, context)
|
response = render(request, template_name, context)
|
||||||
|
if request.GET:
|
||||||
|
return context
|
||||||
|
elif request.POST:
|
||||||
|
del query_params["csrfmiddlewaretoken"]
|
||||||
|
url_params = urllib.parse.urlencode(query_params)
|
||||||
response["HX-Push"] = reverse("home") + "?" + url_params
|
response["HX-Push"] = reverse("home") + "?" + url_params
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
return HttpResponse("No results")
|
return HttpResponse("No results")
|
||||||
|
|
||||||
|
|
||||||
|
class Drilldown(View):
|
||||||
|
template_name = "ui/drilldown/drilldown.html"
|
||||||
|
plan_name = "drilldown"
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
if request.user.is_anonymous:
|
||||||
|
sizes = settings.OPENSEARCH_MAIN_SIZES_ANON
|
||||||
|
else:
|
||||||
|
sizes = settings.OPENSEARCH_MAIN_SIZES
|
||||||
|
|
||||||
|
context = {}
|
||||||
|
if request.GET:
|
||||||
|
context = drilldown_search(request)
|
||||||
|
context["sizes"] = sizes
|
||||||
|
return render(request, self.template_name, context)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
return drilldown_search(request)
|
||||||
|
|
||||||
|
|
||||||
class ThresholdInfoModal(APIView):
|
class ThresholdInfoModal(APIView):
|
||||||
parser_classes = [FormParser]
|
parser_classes = [FormParser]
|
||||||
plan_name = "drilldown"
|
plan_name = "drilldown"
|
||||||
|
|
Loading…
Reference in New Issue