mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-15 22:16:11 +00:00
Compare commits
9 Commits
proxy-url-
...
UI-browser
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a56fe73f | ||
|
|
9b39b2853b | ||
|
|
892d38ba42 | ||
|
|
b170e191d4 | ||
|
|
edb78efcca | ||
|
|
383f90b70c | ||
|
|
6948418865 | ||
|
|
cd80e317f3 | ||
|
|
8c26210804 |
113
changedetectionio/conditions/__init__.py
Normal file
113
changedetectionio/conditions/__init__.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from json_logic import jsonLogic
|
||||||
|
from json_logic.builtins import BUILTINS
|
||||||
|
from .pluggy_interface import plugin_manager # Import the pluggy plugin manager
|
||||||
|
from . import default_plugin
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# List of all supported JSON Logic operators
|
||||||
|
operator_choices = [
|
||||||
|
(">", "Greater Than"),
|
||||||
|
("<", "Less Than"),
|
||||||
|
(">=", "Greater Than or Equal To"),
|
||||||
|
("<=", "Less Than or Equal To"),
|
||||||
|
("==", "Equals"),
|
||||||
|
("!=", "Not Equals"),
|
||||||
|
("in", "Contains"),
|
||||||
|
("!in", "Does Not Contain"),
|
||||||
|
("contains_regex", "Text Matches Regex"),
|
||||||
|
("!contains_regex", "Text Does NOT Match Regex"),
|
||||||
|
("changed > minutes", "Changed more than X minutes ago"),
|
||||||
|
# ("watch_uuid_unviewed_change", "Watch UUID had an unviewed change"), #('if'? )
|
||||||
|
# ("watch_uuid_not_unviewed_change", "Watch UUID NOT had an unviewed change") #('if'? )
|
||||||
|
# ("watch_uuid_changed", "Watch UUID had unviewed change"),
|
||||||
|
# ("watch_uuid_not_changed", "Watch UUID did NOT have unviewed change"),
|
||||||
|
# ("!!", "Is Truthy"),
|
||||||
|
# ("!", "Is Falsy"),
|
||||||
|
# ("and", "All Conditions Must Be True"),
|
||||||
|
# ("or", "At Least One Condition Must Be True"),
|
||||||
|
# ("max", "Maximum of Values"),
|
||||||
|
# ("min", "Minimum of Values"),
|
||||||
|
# ("+", "Addition"),
|
||||||
|
# ("-", "Subtraction"),
|
||||||
|
# ("*", "Multiplication"),
|
||||||
|
# ("/", "Division"),
|
||||||
|
# ("%", "Modulo"),
|
||||||
|
# ("log", "Logarithm"),
|
||||||
|
# ("if", "Conditional If-Else")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fields available in the rules
|
||||||
|
field_choices = [
|
||||||
|
("extracted_number", "Extracted Number"),
|
||||||
|
("page_filtered_text", "Page text After Filters"),
|
||||||
|
("page_title", "Page Title"), # actual page title <title>
|
||||||
|
("watch_uuid", "Watch UUID"),
|
||||||
|
#("watch_history_length", "History Length"), # Would never equate
|
||||||
|
("watch_history", "All Watch Text History"),
|
||||||
|
("watch_check_count", "Watch Check Count"),
|
||||||
|
("watch_uuid", "Other Watch by UUID"), # (Maybe this is 'if' ??)
|
||||||
|
#("requests_get", "Web GET requests (https://..)")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ Custom function for case-insensitive regex matching
|
||||||
|
def contains_regex(_, text, pattern):
|
||||||
|
"""Returns True if `text` contains `pattern` (case-insensitive regex match)."""
|
||||||
|
return bool(re.search(pattern, text, re.IGNORECASE))
|
||||||
|
|
||||||
|
# ✅ Custom function for NOT matching case-insensitive regex
|
||||||
|
def not_contains_regex(_, text, pattern):
|
||||||
|
"""Returns True if `text` does NOT contain `pattern` (case-insensitive regex match)."""
|
||||||
|
return not bool(re.search(pattern, text, re.IGNORECASE))
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ Custom function to check if "watch_uuid" has changed
|
||||||
|
def watch_uuid_changed(_, previous_uuid, current_uuid):
|
||||||
|
"""Returns True if the watch UUID has changed."""
|
||||||
|
return previous_uuid != current_uuid
|
||||||
|
|
||||||
|
# ✅ Custom function to check if "watch_uuid" has NOT changed
|
||||||
|
def watch_uuid_not_changed(_, previous_uuid, current_uuid):
|
||||||
|
"""Returns True if the watch UUID has NOT changed."""
|
||||||
|
return previous_uuid == current_uuid
|
||||||
|
|
||||||
|
# Define the extended operations dictionary
|
||||||
|
CUSTOM_OPERATIONS = {
|
||||||
|
**BUILTINS, # Include all standard operators
|
||||||
|
"watch_uuid_changed": watch_uuid_changed,
|
||||||
|
"watch_uuid_not_changed": watch_uuid_not_changed,
|
||||||
|
"contains_regex": contains_regex,
|
||||||
|
"!contains_regex": not_contains_regex
|
||||||
|
}
|
||||||
|
|
||||||
|
# ✅ Load plugins dynamically
|
||||||
|
for plugin in plugin_manager.get_plugins():
|
||||||
|
new_ops = plugin.register_operators()
|
||||||
|
if isinstance(new_ops, dict):
|
||||||
|
CUSTOM_OPERATIONS.update(new_ops)
|
||||||
|
|
||||||
|
new_operator_choices = plugin.register_operator_choices()
|
||||||
|
if isinstance(new_operator_choices, list):
|
||||||
|
operator_choices.extend(new_operator_choices)
|
||||||
|
|
||||||
|
new_field_choices = plugin.register_field_choices()
|
||||||
|
if isinstance(new_field_choices, list):
|
||||||
|
field_choices.extend(new_field_choices)
|
||||||
|
|
||||||
|
def run(ruleset, data):
|
||||||
|
"""
|
||||||
|
Execute a JSON Logic rule against given data.
|
||||||
|
|
||||||
|
:param ruleset: JSON Logic rule dictionary.
|
||||||
|
:param data: Dictionary containing the facts.
|
||||||
|
:return: Boolean result of rule evaluation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
return jsonLogic(ruleset, data, CUSTOM_OPERATIONS)
|
||||||
|
except Exception as e:
|
||||||
|
# raise some custom nice handler
|
||||||
|
print(f"❌ Error evaluating JSON Logic: {e}")
|
||||||
|
return False
|
||||||
30
changedetectionio/conditions/default_plugin.py
Normal file
30
changedetectionio/conditions/default_plugin.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import pluggy
|
||||||
|
|
||||||
|
hookimpl = pluggy.HookimplMarker("conditions")
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def register_operators():
|
||||||
|
def starts_with(_, text, prefix):
|
||||||
|
return text.lower().startswith(prefix.lower())
|
||||||
|
|
||||||
|
def ends_with(_, text, suffix):
|
||||||
|
return text.lower().endswith(suffix.lower())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"starts_with": starts_with,
|
||||||
|
"ends_with": ends_with
|
||||||
|
}
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def register_operator_choices():
|
||||||
|
return [
|
||||||
|
("starts_with", "Text Starts With"),
|
||||||
|
("ends_with", "Text Ends With"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def register_field_choices():
|
||||||
|
return [
|
||||||
|
("meta_description", "Meta Description"),
|
||||||
|
("meta_keywords", "Meta Keywords"),
|
||||||
|
]
|
||||||
32
changedetectionio/conditions/pluggy_interface.py
Normal file
32
changedetectionio/conditions/pluggy_interface.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import pluggy
|
||||||
|
|
||||||
|
# Define `pluggy` hookspecs (Specifications for Plugins)
|
||||||
|
hookspec = pluggy.HookspecMarker("conditions")
|
||||||
|
hookimpl = pluggy.HookimplMarker("conditions")
|
||||||
|
|
||||||
|
|
||||||
|
class ConditionsSpec:
|
||||||
|
"""Hook specifications for extending JSON Logic conditions."""
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def register_operators():
|
||||||
|
"""Return a dictionary of new JSON Logic operators."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def register_operator_choices():
|
||||||
|
"""Return a list of new operator choices."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def register_field_choices():
|
||||||
|
"""Return a list of new field choices."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ Set up `pluggy` Plugin Manager
|
||||||
|
plugin_manager = pluggy.PluginManager("conditions")
|
||||||
|
plugin_manager.add_hookspecs(ConditionsSpec)
|
||||||
|
|
||||||
|
# Discover installed plugins
|
||||||
|
plugin_manager.load_setuptools_entrypoints("conditions")
|
||||||
@@ -758,6 +758,25 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
for p in datastore.proxy_list:
|
for p in datastore.proxy_list:
|
||||||
form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label'])))
|
form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label'])))
|
||||||
|
|
||||||
|
# Example JSON Rule
|
||||||
|
DEFAULT_RULE = {
|
||||||
|
"and": [
|
||||||
|
{">": [{"var": "extracted_number"}, 5000]},
|
||||||
|
{"<": [{"var": "extracted_number"}, 80000]},
|
||||||
|
{"in": ["rock", {"var": "page_text"}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
form.conditions.pop_entry() # Remove the default empty row
|
||||||
|
for condition in DEFAULT_RULE["and"]:
|
||||||
|
operator, values = list(condition.items())[0]
|
||||||
|
field = values[0]["var"] if isinstance(values[0], dict) else values[1]["var"]
|
||||||
|
value = values[1] if isinstance(values[1], (str, int)) else values[0]
|
||||||
|
|
||||||
|
form.conditions.append_entry({
|
||||||
|
"operator": operator,
|
||||||
|
"field": field,
|
||||||
|
"value": value
|
||||||
|
})
|
||||||
|
|
||||||
if request.method == 'POST' and form.validate():
|
if request.method == 'POST' and form.validate():
|
||||||
|
|
||||||
@@ -793,6 +812,14 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
extra_update_obj['filter_text_replaced'] = True
|
extra_update_obj['filter_text_replaced'] = True
|
||||||
extra_update_obj['filter_text_removed'] = True
|
extra_update_obj['filter_text_removed'] = True
|
||||||
|
|
||||||
|
# Convert form input into JSON Logic format
|
||||||
|
extra_update_obj["conditions"] = {
|
||||||
|
"and": [
|
||||||
|
{c.operator.data: [{"var": c.field.data}, c.value.data]}
|
||||||
|
for c in getattr(form, "conditions", []) or []
|
||||||
|
]
|
||||||
|
} if getattr(form, "conditions", None) else {}
|
||||||
|
|
||||||
# Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs
|
# Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs
|
||||||
tag_uuids = []
|
tag_uuids = []
|
||||||
if form.data.get('tags'):
|
if form.data.get('tags'):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from loguru import logger
|
|||||||
from wtforms.widgets.core import TimeInput
|
from wtforms.widgets.core import TimeInput
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
|
||||||
from wtforms import (
|
from wtforms import (
|
||||||
BooleanField,
|
BooleanField,
|
||||||
@@ -306,7 +307,6 @@ class ValidateAppRiseServers(object):
|
|||||||
import apprise
|
import apprise
|
||||||
apobj = apprise.Apprise()
|
apobj = apprise.Apprise()
|
||||||
# so that the custom endpoints are registered
|
# so that the custom endpoints are registered
|
||||||
from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
|
|
||||||
for server_url in field.data:
|
for server_url in field.data:
|
||||||
url = server_url.strip()
|
url = server_url.strip()
|
||||||
if url.startswith("#"):
|
if url.startswith("#"):
|
||||||
@@ -509,6 +509,23 @@ class quickWatchForm(Form):
|
|||||||
edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"})
|
edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Condition Rule Form (for each rule row)
|
||||||
|
class ConditionForm(FlaskForm):
|
||||||
|
from changedetectionio.conditions import operator_choices, field_choices
|
||||||
|
|
||||||
|
operator = SelectField(
|
||||||
|
"Operator",
|
||||||
|
choices=operator_choices,
|
||||||
|
validators=[validators.Optional()]
|
||||||
|
)
|
||||||
|
field = SelectField(
|
||||||
|
"Field",
|
||||||
|
choices=field_choices,
|
||||||
|
validators=[validators.Optional()]
|
||||||
|
)
|
||||||
|
value = StringField("Value", validators=[validators.Optional()])
|
||||||
|
|
||||||
# Common to a single watch and the global settings
|
# Common to a single watch and the global settings
|
||||||
class commonSettingsForm(Form):
|
class commonSettingsForm(Form):
|
||||||
from . import processors
|
from . import processors
|
||||||
@@ -596,6 +613,9 @@ class processor_text_json_diff_form(commonSettingsForm):
|
|||||||
notification_muted = BooleanField('Notifications Muted / Off', default=False)
|
notification_muted = BooleanField('Notifications Muted / Off', default=False)
|
||||||
notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False)
|
notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False)
|
||||||
|
|
||||||
|
conditions = FieldList(FormField(ConditionForm), min_entries=1) # Add rule logic here
|
||||||
|
|
||||||
|
|
||||||
def extra_tab_content(self):
|
def extra_tab_content(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,39 @@
|
|||||||
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='scheduler.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='scheduler.js')}}" defer></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function addRuleRow() {
|
||||||
|
let rulesContainer = document.getElementById("rules-container");
|
||||||
|
let lastRule = document.querySelector(".rule-row:last-child");
|
||||||
|
let newRule = lastRule.cloneNode(true);
|
||||||
|
|
||||||
|
// Get the new unique index for the added row
|
||||||
|
let ruleCount = document.querySelectorAll(".rule-row").length;
|
||||||
|
|
||||||
|
// Update all IDs, names, and labels to have the correct index
|
||||||
|
newRule.querySelectorAll("select, input, label").forEach(element => {
|
||||||
|
if (element.id) {
|
||||||
|
element.id = element.id.replace(/\d+/, ruleCount);
|
||||||
|
}
|
||||||
|
if (element.name) {
|
||||||
|
element.name = element.name.replace(/\d+/, ruleCount);
|
||||||
|
}
|
||||||
|
if (element.hasAttribute("for")) {
|
||||||
|
element.setAttribute("for", element.getAttribute("for").replace(/\d+/, ruleCount));
|
||||||
|
}
|
||||||
|
if (element.tagName === "INPUT") {
|
||||||
|
element.value = ""; // Clear input field value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append the new rule to the grid container
|
||||||
|
rulesContainer.appendChild(newRule);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');
|
const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');
|
||||||
const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
|
const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
|
||||||
@@ -45,12 +78,12 @@
|
|||||||
{% if extra_tab_content %}
|
{% if extra_tab_content %}
|
||||||
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
|
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if playwright_enabled %}
|
|
||||||
<li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li>
|
<li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li>
|
||||||
{% endif %}
|
<!-- should goto extra forms? -->
|
||||||
{% if watch['processor'] == 'text_json_diff' %}
|
{% if watch['processor'] == 'text_json_diff' %}
|
||||||
<li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
|
<li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
|
||||||
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters & Triggers</a></li>
|
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters & Triggers</a></li>
|
||||||
|
<li class="tab" id="conditions-tab"><a href="#conditions">Conditions</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="tab"><a href="#notifications">Notifications</a></li>
|
<li class="tab"><a href="#notifications">Notifications</a></li>
|
||||||
<li class="tab"><a href="#stats">Stats</a></li>
|
<li class="tab"><a href="#stats">Stats</a></li>
|
||||||
@@ -199,8 +232,15 @@ Math: {{ 1 + 1 }}") }}
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
{% if playwright_enabled %}
|
|
||||||
<div class="tab-pane-inner" id="browser-steps">
|
<div class="tab-pane-inner" id="browser-steps">
|
||||||
|
<span class="pure-form-message-inline">
|
||||||
|
<p>Sorry, this functionality only works with Playwright/Chrome enabled watches.</p>
|
||||||
|
<p>Enable the Playwright Chrome fetcher, or alternatively try our <a href="https://lemonade.changedetection.io/start">very affordable subscription based service</a>.</p>
|
||||||
|
<p>This is because Selenium/WebDriver can not extract full page screenshots reliably.</p>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if playwright_enabled %}
|
||||||
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">
|
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
@@ -240,8 +280,9 @@ Math: {{ 1 + 1 }}") }}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="tab-pane-inner" id="notifications">
|
<div class="tab-pane-inner" id="notifications">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -271,6 +312,36 @@ Math: {{ 1 + 1 }}") }}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if watch['processor'] == 'text_json_diff' %}
|
{% if watch['processor'] == 'text_json_diff' %}
|
||||||
|
|
||||||
|
<div class="tab-pane-inner" id="conditions">
|
||||||
|
<!-- Grid Header -->
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<td>In Value</td>
|
||||||
|
<td>Operator</td>
|
||||||
|
<td>Value</td>
|
||||||
|
<td></td>
|
||||||
|
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Rule Rows (Dynamic Content) -->
|
||||||
|
|
||||||
|
{% for rule in form.conditions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ rule.field() }}</td>
|
||||||
|
|
||||||
|
<td>{{ rule.operator() }}</td>
|
||||||
|
<td>{{ rule.value() }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<button type="button" onclick="addRuleRow()">AND +</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
<div class="tab-pane-inner" id="filters-and-triggers">
|
<div class="tab-pane-inner" id="filters-and-triggers">
|
||||||
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">Activate preview</span>
|
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">Activate preview</span>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -98,5 +98,14 @@ greenlet >= 3.0.3
|
|||||||
# Pinned or it causes problems with flask_expects_json which seems unmaintained
|
# Pinned or it causes problems with flask_expects_json which seems unmaintained
|
||||||
referencing==0.35.1
|
referencing==0.35.1
|
||||||
|
|
||||||
|
panzi-json-logic
|
||||||
|
|
||||||
# Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !)
|
# Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !)
|
||||||
tzdata
|
tzdata
|
||||||
|
|
||||||
|
#typing_extensions ==4.8.0
|
||||||
|
|
||||||
|
pluggy ~= 1.5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user