mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-28 20:33:22 +00:00
Compare commits
1 Commits
individual
...
3241-brows
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d0362d01 |
@@ -71,7 +71,6 @@ jobs:
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_update_watch_deep_merge'
|
||||
|
||||
- name: Test built container with Pytest (generally as requests/plaintext fetching)
|
||||
run: |
|
||||
@@ -180,26 +179,6 @@ jobs:
|
||||
|
||||
docker kill test-changedetectionio
|
||||
|
||||
- name: Test HTTPS SSL mode
|
||||
run: |
|
||||
openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
|
||||
docker run --name test-changedetectionio-ssl --rm -e SSL_CERT_FILE=cert.pem -e SSL_PRIVKEY_FILE=privkey.pem -p 5000:5000 -v ./cert.pem:/app/cert.pem -v ./privkey.pem:/app/privkey.pem -d test-changedetectionio
|
||||
sleep 3
|
||||
# Should return 0 (no error) when grep finds it
|
||||
# -k because its self-signed
|
||||
curl --retry-connrefused --retry 6 -k https://localhost:5000 -v|grep -q checkbox-uuid
|
||||
|
||||
docker kill test-changedetectionio-ssl
|
||||
|
||||
- name: Test IPv6 Mode
|
||||
run: |
|
||||
# IPv6 - :: bind to all interfaces inside container (like 0.0.0.0), ::1 would be localhost only
|
||||
docker run --name test-changedetectionio-ipv6 --rm -p 5000:5000 -e LISTEN_HOST=:: -d test-changedetectionio
|
||||
sleep 3
|
||||
# Should return 0 (no error) when grep finds it on localhost
|
||||
curl --retry-connrefused --retry 6 http://[::1]:5000 -v|grep -q checkbox-uuid
|
||||
docker kill test-changedetectionio-ipv6
|
||||
|
||||
- name: Test changedetection.io SIGTERM and SIGINT signal shutdown
|
||||
run: |
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||
|
||||
__version__ = '0.50.3'
|
||||
__version__ = '0.50.2'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
@@ -65,7 +65,8 @@ def main():
|
||||
|
||||
datastore_path = None
|
||||
do_cleanup = False
|
||||
host = os.environ.get("LISTEN_HOST", "0.0.0.0").strip()
|
||||
host = "0.0.0.0"
|
||||
ipv6_enabled = False
|
||||
port = int(os.environ.get('PORT', 5000))
|
||||
ssl_mode = False
|
||||
|
||||
@@ -107,6 +108,10 @@ def main():
|
||||
if opt == '-d':
|
||||
datastore_path = arg
|
||||
|
||||
if opt == '-6':
|
||||
logger.success("Enabling IPv6 listen support")
|
||||
ipv6_enabled = True
|
||||
|
||||
# Cleanup (remove text files that arent in the index)
|
||||
if opt == '-c':
|
||||
do_cleanup = True
|
||||
@@ -118,20 +123,6 @@ def main():
|
||||
if opt == '-l':
|
||||
logger_level = int(arg) if arg.isdigit() else arg.upper()
|
||||
|
||||
|
||||
logger.success(f"changedetection.io version {get_version()} starting.")
|
||||
# Launch using SocketIO run method for proper integration (if enabled)
|
||||
ssl_cert_file = os.getenv("SSL_CERT_FILE", 'cert.pem')
|
||||
ssl_privkey_file = os.getenv("SSL_PRIVKEY_FILE", 'privkey.pem')
|
||||
if os.getenv("SSL_CERT_FILE") and os.getenv("SSL_PRIVKEY_FILE"):
|
||||
ssl_mode = True
|
||||
|
||||
# SSL mode could have been set by -s too, therefor fallback to default values
|
||||
if ssl_mode:
|
||||
if not os.path.isfile(ssl_cert_file) or not os.path.isfile(ssl_privkey_file):
|
||||
logger.critical(f"Cannot start SSL/HTTPS mode, Please be sure that {ssl_cert_file}' and '{ssl_privkey_file}' exist in in {os.getcwd()}")
|
||||
os._exit(2)
|
||||
|
||||
# Without this, a logger will be duplicated
|
||||
logger.remove()
|
||||
try:
|
||||
@@ -231,19 +222,19 @@ def main():
|
||||
|
||||
|
||||
# SocketIO instance is already initialized in flask_app.py
|
||||
|
||||
# Launch using SocketIO run method for proper integration (if enabled)
|
||||
if socketio_server:
|
||||
if ssl_mode:
|
||||
logger.success(f"SSL mode enabled, attempting to start with '{ssl_cert_file}' and '{ssl_privkey_file}' in {os.getcwd()}")
|
||||
socketio.run(app, host=host, port=int(port), debug=False,
|
||||
ssl_context=(ssl_cert_file, ssl_privkey_file), allow_unsafe_werkzeug=True)
|
||||
socketio.run(app, host=host, port=int(port), debug=False,
|
||||
certfile='cert.pem', keyfile='privkey.pem', allow_unsafe_werkzeug=True)
|
||||
else:
|
||||
socketio.run(app, host=host, port=int(port), debug=False, allow_unsafe_werkzeug=True)
|
||||
else:
|
||||
# Run Flask app without Socket.IO if disabled
|
||||
logger.info("Starting Flask app without Socket.IO server")
|
||||
if ssl_mode:
|
||||
logger.success(f"SSL mode enabled, attempting to start with '{ssl_cert_file}' and '{ssl_privkey_file}' in {os.getcwd()}")
|
||||
app.run(host=host, port=int(port), debug=False,
|
||||
ssl_context=(ssl_cert_file, ssl_privkey_file))
|
||||
app.run(host=host, port=int(port), debug=False,
|
||||
ssl_context=('cert.pem', 'privkey.pem'))
|
||||
else:
|
||||
app.run(host=host, port=int(port), debug=False)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<legend>Add a new organisational tag</legend>
|
||||
<div id="watch-add-wrapper-zone">
|
||||
<div>
|
||||
{{ render_simple_field(form.name, placeholder="Watch group / tag") }}
|
||||
{{ render_simple_field(form.name, placeholder="watch label / tag") }}
|
||||
</div>
|
||||
<div>
|
||||
{{ render_simple_field(form.save_button, title="Save" ) }}
|
||||
|
||||
@@ -159,20 +159,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
|
||||
def mark_all_viewed():
|
||||
# Save the current newest history as the most recently viewed
|
||||
with_errors = request.args.get('with_errors') == "1"
|
||||
tag_limit = request.args.get('tag')
|
||||
logger.debug(f"Limiting to tag {tag_limit}")
|
||||
now = int(time.time())
|
||||
for watch_uuid, watch in datastore.data['watching'].items():
|
||||
if with_errors and not watch.get('last_error'):
|
||||
continue
|
||||
datastore.set_last_viewed(watch_uuid, int(time.time()))
|
||||
|
||||
if tag_limit and ( not watch.get('tags') or tag_limit not in watch['tags'] ):
|
||||
logger.debug(f"Skipping watch {watch_uuid}")
|
||||
continue
|
||||
|
||||
datastore.set_last_viewed(watch_uuid, now)
|
||||
|
||||
return redirect(url_for('watchlist.index', tag=tag_limit))
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
@ui_blueprint.route("/delete", methods=['GET'])
|
||||
@login_optionally_required
|
||||
@@ -303,7 +295,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
|
||||
watch['ignore_text'] += datastore.data['settings']['application']['global_ignore_text']
|
||||
watch['subtractive_selectors'] += datastore.data['settings']['application']['global_subtractive_selectors']
|
||||
|
||||
watch_json = json.dumps(dict(watch))
|
||||
watch_json = json.dumps(watch)
|
||||
|
||||
try:
|
||||
r = requests.request(method="POST",
|
||||
|
||||
@@ -18,20 +18,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
transition: background-size 0.9s ease
|
||||
}
|
||||
</style>
|
||||
<div class="box" id="form-quick-watch-add">
|
||||
<div class="box">
|
||||
|
||||
<form class="pure-form" action="{{ url_for('ui.ui_views.form_quick_watch_add', tag=active_tag_uuid) }}" method="POST" id="new-watch-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" >
|
||||
<fieldset>
|
||||
<legend>Add a new web page change detection watch</legend>
|
||||
<legend>Add a new change detection watch</legend>
|
||||
<div id="watch-add-wrapper-zone">
|
||||
|
||||
{{ render_nolabel_field(form.url, placeholder="https://...", required=true) }}
|
||||
{{ render_nolabel_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder="watch label / tag") }}
|
||||
{{ render_nolabel_field(form.watch_submit_button, title="Watch this URL!" ) }}
|
||||
{{ render_nolabel_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }}
|
||||
</div>
|
||||
<div id="watch-group-tag">
|
||||
{{ render_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder="Watch group / tag", class="transparent-field") }}
|
||||
</div>
|
||||
<div id="quick-watch-processor-type">
|
||||
{{ render_simple_field(form.processor) }}
|
||||
</div>
|
||||
@@ -39,8 +38,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
</fieldset>
|
||||
<span style="color:#eee; font-size: 80%;"><img alt="Create a shareable link" style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" > Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></span>
|
||||
</form>
|
||||
</div>
|
||||
<div class="box">
|
||||
|
||||
<form class="pure-form" action="{{ url_for('ui.form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" >
|
||||
<input type="hidden" id="op_extradata" name="op_extradata" value="" >
|
||||
@@ -214,14 +212,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<li id="post-list-mark-views" class="{%- if has_unviewed -%}has-unviewed{%- endif -%}" style="display: none;" >
|
||||
<a href="{{url_for('ui.mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed</a>
|
||||
</li>
|
||||
{%- if active_tag_uuid -%}
|
||||
<li id="post-list-mark-views-tag">
|
||||
<a href="{{url_for('ui.mark_all_viewed', tag=active_tag_uuid) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed in '{{active_tag.title}}'</a>
|
||||
</li>
|
||||
{%- endif -%}
|
||||
<li>
|
||||
<a href="{{ url_for('ui.form_watch_checknow', tag=active_tag_uuid, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag" id="recheck-all">Recheck
|
||||
all {% if active_tag_uuid %} in '{{active_tag.title}}'{%endif%}</a>
|
||||
all {%- if active_tag_uuid-%} in "{{active_tag.title}}"{%endif%}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('rss.feed', tag=active_tag_uuid, token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}" height="15"></a>
|
||||
|
||||
@@ -100,7 +100,7 @@ watch_api = Api(app, decorators=[csrf.exempt])
|
||||
def init_app_secret(datastore_path):
|
||||
secret = ""
|
||||
|
||||
path = os.path.join(datastore_path, "secret.txt")
|
||||
path = "{}/secret.txt".format(datastore_path)
|
||||
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
|
||||
@@ -738,7 +738,7 @@ class globalSettingsRequestForm(Form):
|
||||
return False
|
||||
|
||||
class globalSettingsApplicationUIForm(Form):
|
||||
open_diff_in_new_tab = BooleanField("Open 'History' page in a new tab", default=True, validators=[validators.Optional()])
|
||||
open_diff_in_new_tab = BooleanField('Open diff page in a new tab', default=True, validators=[validators.Optional()])
|
||||
socket_io_enabled = BooleanField('Realtime UI Updates Enabled', default=True, validators=[validators.Optional()])
|
||||
|
||||
# datastore.data['settings']['application']..
|
||||
|
||||
@@ -43,14 +43,14 @@ class model(watch_base):
|
||||
self.__datastore_path = kw.get('datastore_path')
|
||||
if kw.get('datastore_path'):
|
||||
del kw['datastore_path']
|
||||
|
||||
# Save default before passing to parent, since parent will delete it
|
||||
default_values = kw.get('default')
|
||||
|
||||
|
||||
super(model, self).__init__(*arg, **kw)
|
||||
|
||||
if default_values:
|
||||
self.update(default_values)
|
||||
if kw.get('default'):
|
||||
self.update(kw['default'])
|
||||
del kw['default']
|
||||
|
||||
if self.get('default'):
|
||||
del self['default']
|
||||
|
||||
# Be sure the cached timestamp is ready
|
||||
bump = self.history
|
||||
@@ -227,8 +227,8 @@ class model(watch_base):
|
||||
|
||||
@property
|
||||
def has_history(self):
|
||||
fname = self._get_data_file_path("history.txt")
|
||||
return fname and os.path.isfile(fname)
|
||||
fname = os.path.join(self.watch_data_dir, "history.txt")
|
||||
return os.path.isfile(fname)
|
||||
|
||||
@property
|
||||
def has_browser_steps(self):
|
||||
@@ -405,16 +405,16 @@ class model(watch_base):
|
||||
return not local_lines.issubset(existing_history)
|
||||
|
||||
def get_screenshot(self):
|
||||
fname = self._get_data_file_path("last-screenshot.png")
|
||||
if fname and os.path.isfile(fname):
|
||||
fname = os.path.join(self.watch_data_dir, "last-screenshot.png")
|
||||
if os.path.isfile(fname):
|
||||
return fname
|
||||
|
||||
# False is not an option for AppRise, must be type None
|
||||
return None
|
||||
|
||||
def __get_file_ctime(self, filename):
|
||||
fname = self._get_data_file_path(filename)
|
||||
if fname and os.path.isfile(fname):
|
||||
fname = os.path.join(self.watch_data_dir, filename)
|
||||
if os.path.isfile(fname):
|
||||
return int(os.path.getmtime(fname))
|
||||
return False
|
||||
|
||||
@@ -441,28 +441,20 @@ class model(watch_base):
|
||||
@property
|
||||
def watch_data_dir(self):
|
||||
# The base dir of the watch data
|
||||
if self.__datastore_path and self.get('uuid'):
|
||||
return os.path.join(self.__datastore_path, self['uuid'])
|
||||
return None
|
||||
|
||||
def _get_data_file_path(self, filename):
|
||||
"""Safely get the full path to a data file, returns None if watch_data_dir is None"""
|
||||
if self.watch_data_dir:
|
||||
return os.path.join(self.watch_data_dir, filename)
|
||||
return None
|
||||
return os.path.join(self.__datastore_path, self['uuid']) if self.__datastore_path else None
|
||||
|
||||
def get_error_text(self):
|
||||
"""Return the text saved from a previous request that resulted in a non-200 error"""
|
||||
fname = self._get_data_file_path("last-error.txt")
|
||||
if fname and os.path.isfile(fname):
|
||||
fname = os.path.join(self.watch_data_dir, "last-error.txt")
|
||||
if os.path.isfile(fname):
|
||||
with open(fname, 'r') as f:
|
||||
return f.read()
|
||||
return False
|
||||
|
||||
def get_error_snapshot(self):
|
||||
"""Return path to the screenshot that resulted in a non-200 error"""
|
||||
fname = self._get_data_file_path("last-error-screenshot.png")
|
||||
if fname and os.path.isfile(fname):
|
||||
fname = os.path.join(self.watch_data_dir, "last-error-screenshot.png")
|
||||
if os.path.isfile(fname):
|
||||
return fname
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from changedetectionio import strtobool
|
||||
default_notification_format_for_watch = 'System default'
|
||||
|
||||
class watch_base:
|
||||
class watch_base(dict):
|
||||
|
||||
def __init__(self, *arg, **kw):
|
||||
self.__data = {
|
||||
self.update({
|
||||
# Custom notification content
|
||||
# Re #110, so then if this is set to None, we know to use the default value instead
|
||||
# Requires setting to None on submit if it's the same as the default
|
||||
@@ -129,78 +128,9 @@ class watch_base:
|
||||
'uuid': str(uuid.uuid4()),
|
||||
'webdriver_delay': None,
|
||||
'webdriver_js_execute_code': None, # Run before change-detection
|
||||
}
|
||||
|
||||
if len(arg) == 1 and (isinstance(arg[0], dict) or hasattr(arg[0], 'keys')):
|
||||
self.__data.update(arg[0])
|
||||
if kw:
|
||||
self.__data.update(kw)
|
||||
})
|
||||
|
||||
if self.__data.get('default'):
|
||||
del self.__data['default']
|
||||
super(watch_base, self).__init__(*arg, **kw)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.__data[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.__data[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self.__data[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.__data)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.__data)
|
||||
|
||||
def __contains__(self, key):
|
||||
return key in self.__data
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self.__data)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.__data)
|
||||
|
||||
def keys(self):
|
||||
return self.__data.keys()
|
||||
|
||||
def values(self):
|
||||
return self.__data.values()
|
||||
|
||||
def items(self):
|
||||
return self.__data.items()
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self.__data.get(key, default)
|
||||
|
||||
def pop(self, key, *args):
|
||||
return self.__data.pop(key, *args)
|
||||
|
||||
def popitem(self):
|
||||
return self.__data.popitem()
|
||||
|
||||
def clear(self):
|
||||
self.__data.clear()
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
self.__data.update(*args, **kwargs)
|
||||
|
||||
def setdefault(self, key, default=None):
|
||||
return self.__data.setdefault(key, default)
|
||||
|
||||
def copy(self):
|
||||
return self.__data.copy()
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
from copy import deepcopy
|
||||
new_instance = self.__class__()
|
||||
new_instance.__data = deepcopy(self.__data, memo)
|
||||
return new_instance
|
||||
|
||||
def __reduce__(self):
|
||||
return (self.__class__, (self.__data,))
|
||||
|
||||
def to_dict(self):
|
||||
return dict(self.__data)
|
||||
if self.get('default'):
|
||||
del self['default']
|
||||
@@ -42,10 +42,10 @@ class Restock(dict):
|
||||
|
||||
# Update with any provided positional arguments (dictionaries)
|
||||
if args:
|
||||
if len(args) == 1 and (isinstance(args[0], dict) or hasattr(args[0], 'keys')):
|
||||
if len(args) == 1 and isinstance(args[0], dict):
|
||||
self.update(args[0])
|
||||
else:
|
||||
raise ValueError("Only one positional argument of type 'dict' or dict-like is allowed")
|
||||
raise ValueError("Only one positional argument of type 'dict' is allowed")
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
# Custom logic to handle setting price and original_price
|
||||
|
||||
@@ -48,9 +48,9 @@ $(document).ready(function () {
|
||||
// Connect to Socket.IO on the same host/port, with path from template
|
||||
const socket = io({
|
||||
path: socketio_url, // This will be the path prefix like "/app/socket.io" from the template
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnectionDelay: 3000,
|
||||
reconnectionAttempts: 25
|
||||
transports: ['polling', 'websocket'], // Try WebSocket but fall back to polling
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 15
|
||||
});
|
||||
|
||||
// Connection status logging
|
||||
@@ -98,12 +98,6 @@ $(document).ready(function () {
|
||||
console.log(`Stub handler for notification_event ${data.watch_uuid}`)
|
||||
});
|
||||
|
||||
socket.on('watch_deleted', function (data) {
|
||||
$('tr[data-watch-uuid="' + data.uuid + '"] td').fadeOut(500, function () {
|
||||
$(this).closest('tr').remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for periodically emitted watch data
|
||||
console.log('Adding watch_update event listener');
|
||||
|
||||
|
||||
@@ -16,12 +16,6 @@ $(function () {
|
||||
$('#op_extradata').val(prompt("Enter a tag name"));
|
||||
});
|
||||
|
||||
|
||||
$('.history-link').click(function (e) {
|
||||
// Incase they click 'back' in the browser, it should be removed.
|
||||
$(this).closest('tr').removeClass('unviewed');
|
||||
});
|
||||
|
||||
$('.with-share-link > *').click(function () {
|
||||
$("#copied-clipboard").remove();
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);
|
||||
--color-background-new-watch-form: rgba(0, 0, 0, 0.05);
|
||||
--color-background-new-watch-input: var(--color-white);
|
||||
--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);
|
||||
--color-text-new-watch-input: var(--color-text);
|
||||
--color-border-input: var(--color-grey-500);
|
||||
--color-shadow-input: var(--color-grey-400);
|
||||
@@ -98,7 +97,6 @@ html[data-darkmode="true"] {
|
||||
--color-background-gradient-second: #1e316c;
|
||||
--color-background-gradient-third: #4d2c64;
|
||||
--color-background-new-watch-input: var(--color-grey-100);
|
||||
--color-background-new-watch-input-transparent: var(--color-grey-100);
|
||||
--color-text-new-watch-input: var(--color-text);
|
||||
--color-background-table-thead: var(--color-grey-200);
|
||||
--color-table-background: var(--color-grey-300);
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);
|
||||
--color-background-new-watch-form: rgba(0, 0, 0, 0.05);
|
||||
--color-background-new-watch-input: var(--color-white);
|
||||
--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);
|
||||
--color-text-new-watch-input: var(--color-text);
|
||||
|
||||
--color-border-input: var(--color-grey-500);
|
||||
@@ -113,7 +112,6 @@ html[data-darkmode="true"] {
|
||||
--color-background-gradient-third: #4d2c64;
|
||||
|
||||
--color-background-new-watch-input: var(--color-grey-100);
|
||||
--color-background-new-watch-input-transparent: var(--color-grey-100);
|
||||
--color-text-new-watch-input: var(--color-text);
|
||||
--color-background-table-thead: var(--color-grey-200);
|
||||
--color-table-background: var(--color-grey-300);
|
||||
|
||||
@@ -185,8 +185,7 @@ code {
|
||||
}
|
||||
|
||||
.box {
|
||||
max-width: 100%;
|
||||
margin: 0 1em;
|
||||
max-width: 80%;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -280,7 +279,7 @@ a.pure-button-selected {
|
||||
font-size: 65%;
|
||||
border-bottom-left-radius: initial;
|
||||
border-bottom-right-radius: initial;
|
||||
margin-right: 4px;
|
||||
|
||||
&.active {
|
||||
background: var(--color-background-button-tag-active);
|
||||
font-weight: bold;
|
||||
@@ -373,32 +372,11 @@ label {
|
||||
}
|
||||
}
|
||||
|
||||
// Some field colouring for transperant field
|
||||
.pure-form input[type=text].transparent-field {
|
||||
background-color: var(--color-background-new-watch-input-transparent) !important;
|
||||
color: var(--color-white) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
box-shadow: none !important;
|
||||
-webkit-box-shadow: none !important;
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: lighter;
|
||||
}
|
||||
}
|
||||
|
||||
#new-watch-form {
|
||||
background: var(--color-background-new-watch-form);
|
||||
padding: 1em;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1em;
|
||||
max-width: 100%;
|
||||
|
||||
#url {
|
||||
&::placeholder {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
display: inline-block;
|
||||
@@ -419,13 +397,12 @@ label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
#watch-add-wrapper-zone {
|
||||
|
||||
@media only screen and (min-width: 760px) {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-direction: row;
|
||||
min-width: 70vw;
|
||||
}
|
||||
/* URL field grows always, other stay static in width */
|
||||
> span {
|
||||
@@ -447,22 +424,6 @@ label {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#watch-group-tag {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-white);
|
||||
label, input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -659,6 +620,10 @@ footer {
|
||||
|
||||
@media only screen and (max-width: 760px),
|
||||
(min-device-width: 768px) and (max-device-width: 1024px) {
|
||||
.box {
|
||||
max-width: 95%
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
padding: 0.5em;
|
||||
margin: 0;
|
||||
@@ -1178,14 +1143,16 @@ ul {
|
||||
color: #fff;
|
||||
ul {
|
||||
padding: 0.3rem;
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
> * {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.restock-label {
|
||||
|
||||
@@ -322,7 +322,6 @@ ul#requests-extra_browsers {
|
||||
--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);
|
||||
--color-background-new-watch-form: rgba(0, 0, 0, 0.05);
|
||||
--color-background-new-watch-input: var(--color-white);
|
||||
--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);
|
||||
--color-text-new-watch-input: var(--color-text);
|
||||
--color-border-input: var(--color-grey-500);
|
||||
--color-shadow-input: var(--color-grey-400);
|
||||
@@ -349,7 +348,6 @@ html[data-darkmode="true"] {
|
||||
--color-background-gradient-second: #1e316c;
|
||||
--color-background-gradient-third: #4d2c64;
|
||||
--color-background-new-watch-input: var(--color-grey-100);
|
||||
--color-background-new-watch-input-transparent: var(--color-grey-100);
|
||||
--color-text-new-watch-input: var(--color-text);
|
||||
--color-background-table-thead: var(--color-grey-200);
|
||||
--color-table-background: var(--color-grey-300);
|
||||
@@ -828,8 +826,7 @@ code {
|
||||
background: var(--color-text-watch-tag-list); }
|
||||
|
||||
.box {
|
||||
max-width: 100%;
|
||||
margin: 0 1em;
|
||||
max-width: 80%;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
justify-content: center; }
|
||||
@@ -902,8 +899,7 @@ a.pure-button-selected {
|
||||
color: var(--color-text-button);
|
||||
font-size: 65%;
|
||||
border-bottom-left-radius: initial;
|
||||
border-bottom-right-radius: initial;
|
||||
margin-right: 4px; }
|
||||
border-bottom-right-radius: initial; }
|
||||
.button-tag.active {
|
||||
background: var(--color-background-button-tag-active);
|
||||
font-weight: bold; }
|
||||
@@ -966,25 +962,11 @@ label:hover {
|
||||
#token-table.pure-table th {
|
||||
font-size: 80%; }
|
||||
|
||||
.pure-form input[type=text].transparent-field {
|
||||
background-color: var(--color-background-new-watch-input-transparent) !important;
|
||||
color: var(--color-white) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
box-shadow: none !important;
|
||||
-webkit-box-shadow: none !important; }
|
||||
.pure-form input[type=text].transparent-field::placeholder {
|
||||
opacity: 0.5;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: lighter; }
|
||||
|
||||
#new-watch-form {
|
||||
background: var(--color-background-new-watch-form);
|
||||
padding: 1em;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1em;
|
||||
max-width: 100%; }
|
||||
#new-watch-form #url::placeholder {
|
||||
font-weight: bold; }
|
||||
margin-bottom: 1em; }
|
||||
#new-watch-form input {
|
||||
display: inline-block;
|
||||
margin-bottom: 5px; }
|
||||
@@ -1002,8 +984,7 @@ label:hover {
|
||||
#new-watch-form #watch-add-wrapper-zone {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-direction: row;
|
||||
min-width: 70vw; } }
|
||||
flex-direction: row; } }
|
||||
#new-watch-form #watch-add-wrapper-zone > span {
|
||||
flex-grow: 0; }
|
||||
#new-watch-form #watch-add-wrapper-zone > span input {
|
||||
@@ -1014,17 +995,6 @@ label:hover {
|
||||
@media only screen and (max-width: 760px) {
|
||||
#new-watch-form #watch-add-wrapper-zone #url {
|
||||
width: 100%; } }
|
||||
#new-watch-form #watch-group-tag {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-white); }
|
||||
#new-watch-form #watch-group-tag label, #new-watch-form #watch-group-tag input {
|
||||
margin: 0; }
|
||||
#new-watch-form #watch-group-tag input {
|
||||
flex: 1; }
|
||||
|
||||
#diff-col {
|
||||
padding-left: 40px; }
|
||||
@@ -1159,6 +1129,8 @@ footer {
|
||||
gap: 1em; }
|
||||
|
||||
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) {
|
||||
.box {
|
||||
max-width: 95%; }
|
||||
.edit-form {
|
||||
padding: 0.5em;
|
||||
margin: 0; }
|
||||
@@ -1534,7 +1506,7 @@ ul {
|
||||
padding: 0.3rem; }
|
||||
#quick-watch-processor-type ul li {
|
||||
list-style: none;
|
||||
font-size: 0.9rem; }
|
||||
font-size: 0.8rem; }
|
||||
#quick-watch-processor-type ul li > * {
|
||||
display: inline-block; }
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid as uuid_builder
|
||||
@@ -23,13 +22,6 @@ from blinker import signal
|
||||
from .processors import get_custom_watch_obj_for_processor
|
||||
from .processors.restock_diff import Restock
|
||||
|
||||
class WatchEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
from .model import watch_base
|
||||
if isinstance(obj, watch_base):
|
||||
return dict(obj)
|
||||
return super().default(obj)
|
||||
|
||||
# Because the server will run as a daemon and wont know the URL for notification links when firing off a notification
|
||||
BASE_URL_NOT_SET_TEXT = '("Base URL" not set - see settings - notifications)'
|
||||
|
||||
@@ -53,11 +45,14 @@ class ChangeDetectionStore:
|
||||
# logging.basicConfig(filename='/dev/stdout', level=logging.INFO)
|
||||
self.__data = App.model()
|
||||
self.datastore_path = datastore_path
|
||||
self.json_store_path = os.path.join(self.datastore_path, "url-watches.json")
|
||||
self.json_store_path = "{}/url-watches.json".format(self.datastore_path)
|
||||
logger.info(f"Datastore path is '{self.json_store_path}'")
|
||||
self.needs_write = False
|
||||
self.start_time = time.time()
|
||||
self.stop_thread = False
|
||||
# Base definition for all watchers
|
||||
# deepcopy part of #569 - not sure why its needed exactly
|
||||
self.generic_definition = deepcopy(Watch.model(datastore_path = datastore_path, default={}))
|
||||
|
||||
if path.isfile('changedetectionio/source.txt'):
|
||||
with open('changedetectionio/source.txt') as f:
|
||||
@@ -123,12 +118,14 @@ class ChangeDetectionStore:
|
||||
test_list = self.proxy_list
|
||||
|
||||
# Helper to remove password protection
|
||||
password_reset_lockfile = os.path.join(self.datastore_path, "removepassword.lock")
|
||||
password_reset_lockfile = "{}/removepassword.lock".format(self.datastore_path)
|
||||
if path.isfile(password_reset_lockfile):
|
||||
self.__data['settings']['application']['password'] = False
|
||||
unlink(password_reset_lockfile)
|
||||
|
||||
if not 'app_guid' in self.__data:
|
||||
import os
|
||||
import sys
|
||||
if "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ:
|
||||
self.__data['app_guid'] = "test-" + str(uuid_builder.uuid4())
|
||||
else:
|
||||
@@ -178,14 +175,6 @@ class ChangeDetectionStore:
|
||||
self.__data['settings']['application']['password'] = False
|
||||
self.needs_write = True
|
||||
|
||||
def _deep_merge(self, target, source):
|
||||
"""Recursively merge source dict into target dict"""
|
||||
for key, value in source.items():
|
||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||
self._deep_merge(target[key], value)
|
||||
else:
|
||||
target[key] = value
|
||||
|
||||
def update_watch(self, uuid, update_obj):
|
||||
|
||||
# It's possible that the watch could be deleted before update
|
||||
@@ -193,8 +182,15 @@ class ChangeDetectionStore:
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
# Use recursive merge to handle nested dictionaries properly
|
||||
self._deep_merge(self.__data['watching'][uuid], update_obj)
|
||||
|
||||
# In python 3.9 we have the |= dict operator, but that still will lose data on nested structures...
|
||||
for dict_key, d in self.generic_definition.items():
|
||||
if isinstance(d, dict):
|
||||
if update_obj is not None and dict_key in update_obj:
|
||||
self.__data['watching'][uuid][dict_key].update(update_obj[dict_key])
|
||||
del (update_obj[dict_key])
|
||||
|
||||
self.__data['watching'][uuid].update(update_obj)
|
||||
self.needs_write = True
|
||||
|
||||
@property
|
||||
@@ -390,59 +386,14 @@ class ChangeDetectionStore:
|
||||
return new_uuid
|
||||
|
||||
def visualselector_data_is_ready(self, watch_uuid):
|
||||
output_path = os.path.join(self.datastore_path, watch_uuid)
|
||||
screenshot_filename = os.path.join(output_path, "last-screenshot.png")
|
||||
elements_index_filename = os.path.join(output_path, "elements.deflate")
|
||||
output_path = "{}/{}".format(self.datastore_path, watch_uuid)
|
||||
screenshot_filename = "{}/last-screenshot.png".format(output_path)
|
||||
elements_index_filename = "{}/elements.deflate".format(output_path)
|
||||
if path.isfile(screenshot_filename) and path.isfile(elements_index_filename) :
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path # just for nicer paths
|
||||
|
||||
JSON_INDENT = 2 # or None in production
|
||||
ENCODER = WatchEncoder # your custom encoder
|
||||
|
||||
def save_json_atomic(self, save_path: str | os.PathLike, data) -> None:
|
||||
"""
|
||||
Atomically (re)write *path* with *data* encoded as JSON.
|
||||
The original file is left untouched if anything fails.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path # just for nicer paths
|
||||
|
||||
JSON_INDENT = 2 # or None in production
|
||||
ENCODER = WatchEncoder # your custom encoder
|
||||
|
||||
datapath = Path(save_path)
|
||||
directory = datapath.parent
|
||||
|
||||
# 1. create a unique temp file in the same directory
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=directory,
|
||||
prefix=f"{datapath.name}.",
|
||||
suffix=".tmp",
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
|
||||
json.dump(data, tmp, indent=JSON_INDENT, cls=ENCODER)
|
||||
if os.getenv('JSON_SAVE_FORCE_FLUSH'):
|
||||
tmp.flush() # push Python buffers
|
||||
os.fsync(tmp.fileno()) # force kernel to write to disk
|
||||
os.replace(tmp_name, datapath)
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"Failed to write JSON to {datapath} - {str(e)}")
|
||||
# if anything above blew up, ensure we don't leave junk lying around
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
finally:
|
||||
raise
|
||||
|
||||
|
||||
def sync_to_json(self):
|
||||
logger.info("Saving JSON..")
|
||||
try:
|
||||
@@ -454,7 +405,18 @@ class ChangeDetectionStore:
|
||||
self.sync_to_json()
|
||||
return
|
||||
else:
|
||||
self.save_json_atomic(save_path = self.json_store_path, data =data)
|
||||
|
||||
try:
|
||||
# Re #286 - First write to a temp file, then confirm it looks OK and rename it
|
||||
# This is a fairly basic strategy to deal with the case that the file is corrupted,
|
||||
# system was out of memory, out of RAM etc
|
||||
with open(self.json_store_path+".tmp", 'w') as json_file:
|
||||
# Use compact JSON in production for better performance
|
||||
json.dump(data, json_file, indent=2)
|
||||
os.replace(self.json_store_path+".tmp", self.json_store_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing JSON!! (Main JSON file save was skipped) : {str(e)}")
|
||||
|
||||
self.needs_write = False
|
||||
self.needs_write_urgent = False
|
||||
|
||||
@@ -512,7 +474,7 @@ class ChangeDetectionStore:
|
||||
|
||||
# Load from external config file
|
||||
if path.isfile(proxy_list_file):
|
||||
with open(os.path.join(self.datastore_path, "proxies.json")) as f:
|
||||
with open("{}/proxies.json".format(self.datastore_path)) as f:
|
||||
proxy_list = json.load(f)
|
||||
|
||||
# Mapping from UI config if available
|
||||
@@ -770,10 +732,10 @@ class ChangeDetectionStore:
|
||||
logger.critical(f"Applying update_{update_n}")
|
||||
# Wont exist on fresh installs
|
||||
if os.path.exists(self.json_store_path):
|
||||
shutil.copyfile(self.json_store_path, os.path.join(self.datastore_path, f"url-watches-before-{update_n}.json"))
|
||||
shutil.copyfile(self.json_store_path, self.datastore_path+"/url-watches-before-{}.json".format(update_n))
|
||||
|
||||
try:
|
||||
update_method = getattr(self, f"update_{update_n}")()
|
||||
update_method = getattr(self, "update_{}".format(update_n))()
|
||||
except Exception as e:
|
||||
logger.error(f"Error while trying update_{update_n}")
|
||||
logger.error(e)
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ '{{watch_tag}}' }}</code></td>
|
||||
<td>The watch group / tag</td>
|
||||
<td>The watch label / tag</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ '{{preview_url}}' }}</code></td>
|
||||
|
||||
@@ -236,41 +236,39 @@ def test_group_tag_notification(client, live_server, measure_memory_usage):
|
||||
assert b'Deleted' in res.data
|
||||
|
||||
def test_limit_tag_ui(client, live_server, measure_memory_usage):
|
||||
|
||||
|
||||
test_url = url_for('test_random_content_endpoint', _external=True)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
urls=[]
|
||||
|
||||
# A space can label the tag, only the first one will have a tag
|
||||
client.post(
|
||||
for i in range(20):
|
||||
urls.append(test_url+"?x="+str(i)+" test-tag")
|
||||
|
||||
for i in range(20):
|
||||
urls.append(test_url+"?non-grouped="+str(i))
|
||||
|
||||
res = client.post(
|
||||
url_for("imports.import_page"),
|
||||
data={"urls": f"{test_url} test-tag\r\n{test_url}"},
|
||||
data={"urls": "\r\n".join(urls)},
|
||||
follow_redirects=True
|
||||
)
|
||||
tag_uuid = get_UUID_for_tag_name(client, name="test-tag")
|
||||
assert tag_uuid
|
||||
|
||||
assert b"40 Imported" in res.data
|
||||
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'test-tag' in res.data
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Should be both unviewed
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert res.data.count(b' unviewed ') == 2
|
||||
# All should be here
|
||||
assert res.data.count(b'processor-text_json_diff') == 40
|
||||
|
||||
tag_uuid = get_UUID_for_tag_name(client, name="test-tag")
|
||||
|
||||
# Now we recheck only the tag
|
||||
client.get(url_for('ui.mark_all_viewed', tag=tag_uuid), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
with open('/tmp/fuck.html', 'wb') as f:
|
||||
f.write(res.data)
|
||||
# Should be only 1 unviewed
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert res.data.count(b' unviewed ') == 1
|
||||
|
||||
res = client.get(url_for("watchlist.index", tag=tag_uuid))
|
||||
|
||||
# Just a subset should be here
|
||||
assert b'test-tag' in res.data
|
||||
assert res.data.count(b'processor-text_json_diff') == 20
|
||||
assert b"object at" not in res.data
|
||||
res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
|
||||
assert b'Deleted' in res.data
|
||||
res = client.get(url_for("tags.delete_all"), follow_redirects=True)
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# run from dir above changedetectionio/ dir
|
||||
# python3 -m unittest changedetectionio.tests.unit.test_update_watch_deep_merge
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from unittest.mock import patch
|
||||
|
||||
from changedetectionio import store
|
||||
|
||||
|
||||
class TestUpdateWatchDeepMerge(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create a temporary directory for test data
|
||||
self.test_datastore_path = tempfile.mkdtemp()
|
||||
self.datastore = store.ChangeDetectionStore(datastore_path=self.test_datastore_path, include_default_watches=False)
|
||||
|
||||
# Create a test watch with known nested structure
|
||||
self.test_uuid = self.datastore.add_watch(url='http://example.com')
|
||||
|
||||
# Set up known initial nested structure
|
||||
initial_data = {
|
||||
'time_between_check': {'weeks': None, 'days': 1, 'hours': 6, 'minutes': 30, 'seconds': None},
|
||||
'headers': {'user-agent': 'test-browser', 'accept': 'text/html'},
|
||||
'time_schedule_limit': {
|
||||
'enabled': True,
|
||||
'monday': {
|
||||
'enabled': True,
|
||||
'start_time': '09:00',
|
||||
'duration': {'hours': '8', 'minutes': '00'}
|
||||
},
|
||||
'tuesday': {
|
||||
'enabled': False,
|
||||
'start_time': '10:00',
|
||||
'duration': {'hours': '6', 'minutes': '30'}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.datastore.update_watch(self.test_uuid, initial_data)
|
||||
|
||||
def tearDown(self):
|
||||
self.datastore.stop_thread = True
|
||||
# Clean up the temporary directory
|
||||
shutil.rmtree(self.test_datastore_path, ignore_errors=True)
|
||||
|
||||
def test_simple_flat_update(self):
|
||||
"""Test that simple flat updates work as before"""
|
||||
update_obj = {'url': 'http://newexample.com', 'paused': True}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
self.assertEqual(watch['url'], 'http://newexample.com')
|
||||
self.assertEqual(watch['paused'], True)
|
||||
|
||||
def test_time_between_check_partial_update(self):
|
||||
"""Test partial update of time_between_check preserves existing keys"""
|
||||
# Update only hours, should preserve other existing values
|
||||
update_obj = {'time_between_check': {'hours': 2}}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
time_check = watch['time_between_check']
|
||||
|
||||
# Updated value
|
||||
self.assertEqual(time_check['hours'], 2)
|
||||
# Preserved existing values
|
||||
self.assertEqual(time_check['days'], 1)
|
||||
self.assertEqual(time_check['minutes'], 30)
|
||||
self.assertEqual(time_check['weeks'], None)
|
||||
self.assertEqual(time_check['seconds'], None)
|
||||
|
||||
def test_time_between_check_multiple_partial_updates(self):
|
||||
"""Test multiple partial updates to time_between_check"""
|
||||
# First update
|
||||
update_obj1 = {'time_between_check': {'minutes': 45}}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj1)
|
||||
|
||||
# Second update
|
||||
update_obj2 = {'time_between_check': {'seconds': 15}}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj2)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
time_check = watch['time_between_check']
|
||||
|
||||
# Both updates should be preserved
|
||||
self.assertEqual(time_check['minutes'], 45)
|
||||
self.assertEqual(time_check['seconds'], 15)
|
||||
# Original values should be preserved
|
||||
self.assertEqual(time_check['days'], 1)
|
||||
self.assertEqual(time_check['hours'], 6)
|
||||
|
||||
def test_headers_partial_update(self):
|
||||
"""Test partial update of headers preserves existing headers"""
|
||||
update_obj = {'headers': {'authorization': 'Bearer token123'}}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
headers = watch['headers']
|
||||
|
||||
# New header added
|
||||
self.assertEqual(headers['authorization'], 'Bearer token123')
|
||||
# Existing headers preserved
|
||||
self.assertEqual(headers['user-agent'], 'test-browser')
|
||||
self.assertEqual(headers['accept'], 'text/html')
|
||||
|
||||
def test_headers_update_existing_key(self):
|
||||
"""Test updating an existing header key"""
|
||||
update_obj = {'headers': {'user-agent': 'new-browser'}}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
headers = watch['headers']
|
||||
|
||||
# Updated existing header
|
||||
self.assertEqual(headers['user-agent'], 'new-browser')
|
||||
# Other headers preserved
|
||||
self.assertEqual(headers['accept'], 'text/html')
|
||||
|
||||
def test_time_schedule_limit_deep_nested_update(self):
|
||||
"""Test deep nested update of time_schedule_limit structure"""
|
||||
update_obj = {
|
||||
'time_schedule_limit': {
|
||||
'monday': {
|
||||
'duration': {'hours': '10'} # Only update hours, preserve minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
schedule = watch['time_schedule_limit']
|
||||
|
||||
# Deep nested update applied
|
||||
self.assertEqual(schedule['monday']['duration']['hours'], '10')
|
||||
# Existing nested values preserved
|
||||
self.assertEqual(schedule['monday']['duration']['minutes'], '00')
|
||||
self.assertEqual(schedule['monday']['start_time'], '09:00')
|
||||
self.assertEqual(schedule['monday']['enabled'], True)
|
||||
# Other days preserved
|
||||
self.assertEqual(schedule['tuesday']['enabled'], False)
|
||||
self.assertEqual(schedule['enabled'], True)
|
||||
|
||||
def test_mixed_flat_and_nested_update(self):
|
||||
"""Test update with both flat and nested properties"""
|
||||
update_obj = {
|
||||
'url': 'http://mixed-update.com',
|
||||
'paused': False,
|
||||
'time_between_check': {'days': 2, 'minutes': 15},
|
||||
'headers': {'cookie': 'session=abc123'}
|
||||
}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
|
||||
# Flat updates
|
||||
self.assertEqual(watch['url'], 'http://mixed-update.com')
|
||||
self.assertEqual(watch['paused'], False)
|
||||
|
||||
# Nested updates
|
||||
time_check = watch['time_between_check']
|
||||
self.assertEqual(time_check['days'], 2)
|
||||
self.assertEqual(time_check['minutes'], 15)
|
||||
self.assertEqual(time_check['hours'], 6) # preserved
|
||||
|
||||
headers = watch['headers']
|
||||
self.assertEqual(headers['cookie'], 'session=abc123')
|
||||
self.assertEqual(headers['user-agent'], 'test-browser') # preserved
|
||||
|
||||
def test_overwrite_nested_with_flat(self):
|
||||
"""Test that providing a non-dict value overwrites the entire nested structure"""
|
||||
update_obj = {'time_between_check': 'invalid_value'}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
# Should completely replace the nested dict with the string
|
||||
self.assertEqual(watch['time_between_check'], 'invalid_value')
|
||||
|
||||
def test_add_new_nested_structure(self):
|
||||
"""Test adding a completely new nested dictionary"""
|
||||
update_obj = {
|
||||
'custom_config': {
|
||||
'option1': 'value1',
|
||||
'nested': {
|
||||
'suboption': 'subvalue'
|
||||
}
|
||||
}
|
||||
}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
self.assertEqual(watch['custom_config']['option1'], 'value1')
|
||||
self.assertEqual(watch['custom_config']['nested']['suboption'], 'subvalue')
|
||||
|
||||
def test_empty_dict_update(self):
|
||||
"""Test updating with empty dictionaries"""
|
||||
update_obj = {'headers': {}}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
# Empty dict should preserve existing headers (no keys to merge)
|
||||
self.assertEqual(watch['headers']['user-agent'], 'test-browser')
|
||||
self.assertEqual(watch['headers']['accept'], 'text/html')
|
||||
|
||||
def test_none_values_in_nested_update(self):
|
||||
"""Test handling None values in nested updates"""
|
||||
update_obj = {
|
||||
'time_between_check': {
|
||||
'hours': None,
|
||||
'days': 3
|
||||
}
|
||||
}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
time_check = watch['time_between_check']
|
||||
|
||||
self.assertEqual(time_check['hours'], None)
|
||||
self.assertEqual(time_check['days'], 3)
|
||||
self.assertEqual(time_check['minutes'], 30) # preserved
|
||||
|
||||
def test_real_world_api_update_scenario(self):
|
||||
"""Test a real-world API update scenario from the codebase analysis"""
|
||||
# Based on actual API call patterns found in the codebase
|
||||
update_obj = {
|
||||
"title": "Updated API Watch",
|
||||
'time_between_check': {'minutes': 60},
|
||||
'headers': {'authorization': 'Bearer api-token', 'user-agent': 'api-client'},
|
||||
'notification_urls': ['https://webhook.example.com']
|
||||
}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
|
||||
# Verify all updates
|
||||
self.assertEqual(watch['title'], 'Updated API Watch')
|
||||
self.assertEqual(watch['time_between_check']['minutes'], 60)
|
||||
self.assertEqual(watch['time_between_check']['days'], 1) # preserved
|
||||
self.assertEqual(watch['headers']['authorization'], 'Bearer api-token')
|
||||
self.assertEqual(watch['headers']['user-agent'], 'api-client') # overwrote existing
|
||||
self.assertEqual(watch['headers']['accept'], 'text/html') # preserved
|
||||
self.assertEqual(watch['notification_urls'], ['https://webhook.example.com'])
|
||||
|
||||
def test_watch_not_found(self):
|
||||
"""Test update_watch with non-existent UUID"""
|
||||
# Should not raise an error, just return silently
|
||||
fake_uuid = 'non-existent-uuid'
|
||||
update_obj = {'url': 'http://should-not-update.com'}
|
||||
|
||||
# Should not raise an exception
|
||||
self.datastore.update_watch(fake_uuid, update_obj)
|
||||
|
||||
# Verify no changes were made to existing watch
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
self.assertNotEqual(watch['url'], 'http://should-not-update.com')
|
||||
|
||||
def test_processor_style_update(self):
|
||||
"""Test the type of updates made by processors during check operations"""
|
||||
# Based on async_update_worker.py patterns
|
||||
update_obj = {
|
||||
'last_notification_error': False,
|
||||
'last_error': False,
|
||||
'previous_md5': 'abc123def456',
|
||||
'content-type': 'application/json',
|
||||
'consecutive_filter_failures': 0,
|
||||
'fetch_time': 1.234,
|
||||
'check_count': 42
|
||||
}
|
||||
self.datastore.update_watch(self.test_uuid, update_obj)
|
||||
|
||||
watch = self.datastore.data['watching'][self.test_uuid]
|
||||
|
||||
# Verify processor updates
|
||||
self.assertEqual(watch['last_notification_error'], False)
|
||||
self.assertEqual(watch['last_error'], False)
|
||||
self.assertEqual(watch['previous_md5'], 'abc123def456')
|
||||
self.assertEqual(watch['content-type'], 'application/json')
|
||||
self.assertEqual(watch['consecutive_filter_failures'], 0)
|
||||
self.assertEqual(watch['fetch_time'], 1.234)
|
||||
self.assertEqual(watch['check_count'], 42)
|
||||
|
||||
# Verify nested structures weren't affected
|
||||
self.assertEqual(watch['time_between_check']['days'], 1)
|
||||
self.assertEqual(watch['headers']['user-agent'], 'test-browser')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -69,16 +69,6 @@ services:
|
||||
# Maximum height of screenshots, default is 16000 px, screenshots will be clipped to this if exceeded.
|
||||
# RAM usage will be higher if you increase this.
|
||||
# - SCREENSHOT_MAX_HEIGHT=16000
|
||||
#
|
||||
# HTTPS SSL Mode for webserver, unset both of these, you may need to volume mount these files also.
|
||||
# ./cert.pem:/app/cert.pem and ./privkey.pem:/app/privkey.pem
|
||||
# - SSL_CERT_FILE=cert.pem
|
||||
# - SSL_PRIVKEY_FILE=privkey.pem
|
||||
#
|
||||
# LISTEN_HOST / "host", Same as -h
|
||||
# - LISTEN_HOST=::
|
||||
# - LISTEN_HOST=0.0.0.0
|
||||
|
||||
|
||||
# Comment out ports: when using behind a reverse proxy , enable networks: etc.
|
||||
ports:
|
||||
|
||||
Reference in New Issue
Block a user