Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

basic tag/group handling with filters+notifications #1610

Merged
merged 61 commits into from
Jun 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
463626d
WIP
dgtlmoon May 31, 2023
f9a8394
WIP
dgtlmoon May 31, 2023
4151af6
Fix active tag list
dgtlmoon May 31, 2023
df30fa4
tweak limit
dgtlmoon May 31, 2023
06e6a68
tweak test
dgtlmoon May 31, 2023
efdb9f4
extend test
dgtlmoon Jun 6, 2023
ece0c37
Dont fetch when no URL
dgtlmoon Jun 6, 2023
bf2c0f1
API - new watches by tag should be UUID
dgtlmoon Jun 6, 2023
e2bdf15
WIP
dgtlmoon Jun 6, 2023
be68592
Also check wrong password does not let us in
dgtlmoon Jun 6, 2023
065f1c3
Fix for test
dgtlmoon Jun 6, 2023
4f14907
oops
dgtlmoon Jun 6, 2023
0f872a1
Fix tag limit
dgtlmoon Jun 7, 2023
06bd27e
oops
dgtlmoon Jun 7, 2023
acc4651
fix clone
dgtlmoon Jun 7, 2023
b6db551
fix tag fetch
dgtlmoon Jun 7, 2023
597430e
Fixing tags in notifications
dgtlmoon Jun 9, 2023
f6bdbed
Fix tag list
dgtlmoon Jun 9, 2023
864590c
Fix wrong tag uuid
dgtlmoon Jun 9, 2023
819723a
Fix headers tag file loader
dgtlmoon Jun 9, 2023
c8056cd
rearrange
dgtlmoon Jun 9, 2023
842800c
Fix template a bit
dgtlmoon Jun 9, 2023
960d9bb
Add readme
dgtlmoon Jun 9, 2023
59ae6a8
start linking
dgtlmoon Jun 9, 2023
cb90217
add comment
dgtlmoon Jun 9, 2023
66d5b2a
tweak list join
dgtlmoon Jun 12, 2023
5da5fae
Merge branch 'master' into groups-basic
dgtlmoon Jun 12, 2023
136f2f4
restock shouldnt care about tags
dgtlmoon Jun 12, 2023
b583d30
Merge branch 'master' into groups-basic
dgtlmoon Jun 12, 2023
5360dd3
Add test
dgtlmoon Jun 12, 2023
2e5b7ef
Singular tag test
dgtlmoon Jun 12, 2023
db97f28
Fix tag watch list
dgtlmoon Jun 12, 2023
3a61ac8
Adding test for search
dgtlmoon Jun 13, 2023
1b52128
Adding extra test
dgtlmoon Jun 13, 2023
5bbf9e8
Merge branch 'test-search' into groups-basic
dgtlmoon Jun 13, 2023
e50ca33
Merge branch 'master' into groups-basic
dgtlmoon Jun 14, 2023
6982009
Fix and add test for adding tag in UI
dgtlmoon Jun 14, 2023
bc0e4c9
move to 'tags'
dgtlmoon Jun 14, 2023
aaa8578
Fix field rename
dgtlmoon Jun 14, 2023
5cf8f5b
Fix API
dgtlmoon Jun 14, 2023
1d17a42
tweak for test
dgtlmoon Jun 14, 2023
a76a111
more fixes
dgtlmoon Jun 14, 2023
94af0d8
Speed up test
dgtlmoon Jun 14, 2023
b4ec443
More renaming of field
dgtlmoon Jun 14, 2023
a690745
Speed up test
dgtlmoon Jun 14, 2023
81a99d5
test speedup
dgtlmoon Jun 14, 2023
a7962d1
speedup
dgtlmoon Jun 14, 2023
bbf7a8e
speedup
dgtlmoon Jun 14, 2023
dc960b4
connect tag notifications
dgtlmoon Jun 19, 2023
8549857
Merge branch 'master' into groups-basic
dgtlmoon Jun 19, 2023
117bcf4
Adding group notifications plus test
dgtlmoon Jun 19, 2023
89311f3
bump .gets
dgtlmoon Jun 19, 2023
276165d
Adding toggle of tag mute
dgtlmoon Jun 19, 2023
8ead925
Bump text
dgtlmoon Jun 19, 2023
f71e83f
Adding delete and unlink
dgtlmoon Jun 19, 2023
b04c9eb
Simplify limit tag handling
dgtlmoon Jun 19, 2023
2b11793
Fix comment
dgtlmoon Jun 19, 2023
9e772c7
tweak test
dgtlmoon Jun 19, 2023
09adf0e
Fix recheck link
dgtlmoon Jun 19, 2023
0764c23
Assign tag shortcut
dgtlmoon Jun 19, 2023
6a6900f
fix field val
dgtlmoon Jun 19, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
104 changes: 61 additions & 43 deletions changedetectionio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,25 +317,21 @@ def rss():
return "Access denied, bad token", 403

from . import diff
limit_tag = request.args.get('tag')
limit_tag = request.args.get('tag', '').lower().strip()
# Be sure limit_tag is a uuid
for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
if limit_tag == tag.get('title', '').lower().strip():
limit_tag = uuid

# Sort by last_changed and add the uuid which is usually the key..
sorted_watches = []

# @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away
for uuid, watch in datastore.data['watching'].items():

if limit_tag != None:
# Support for comma separated list of tags.
for tag_in_watch in watch['tag'].split(','):
tag_in_watch = tag_in_watch.strip()
if tag_in_watch == limit_tag:
watch['uuid'] = uuid
sorted_watches.append(watch)

else:
watch['uuid'] = uuid
sorted_watches.append(watch)
if limit_tag and not limit_tag in watch['tags']:
continue
watch['uuid'] = uuid
sorted_watches.append(watch)

sorted_watches.sort(key=lambda x: x.last_changed, reverse=False)

Expand Down Expand Up @@ -392,9 +388,17 @@ def rss():
@app.route("/", methods=['GET'])
@login_optionally_required
def index():
global datastore
from changedetectionio import forms

limit_tag = request.args.get('tag')
limit_tag = request.args.get('tag', '').lower().strip()

# Be sure limit_tag is a uuid
for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
if limit_tag == tag.get('title', '').lower().strip():
limit_tag = uuid


# Redirect for the old rss path which used the /?rss=true
if request.args.get('rss'):
return redirect(url_for('rss', tag=limit_tag))
Expand All @@ -414,30 +418,15 @@ def index():
sorted_watches = []
search_q = request.args.get('q').strip().lower() if request.args.get('q') else False
for uuid, watch in datastore.data['watching'].items():

if limit_tag:
# Support for comma separated list of tags.
if not watch.get('tag'):
if limit_tag and not limit_tag in watch['tags']:
continue
for tag_in_watch in watch.get('tag', '').split(','):
tag_in_watch = tag_in_watch.strip()
if tag_in_watch == limit_tag:
watch['uuid'] = uuid
if search_q:
if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
sorted_watches.append(watch)
else:
sorted_watches.append(watch)

else:
#watch['uuid'] = uuid
if search_q:
if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
sorted_watches.append(watch)
else:
if search_q:
if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
sorted_watches.append(watch)
else:
sorted_watches.append(watch)

existing_tags = datastore.get_all_tags()
form = forms.quickWatchForm(request.form)
page = request.args.get(get_page_parameter(), type=int, default=1)
total_count = len(sorted_watches)
Expand All @@ -452,6 +441,7 @@ def index():
# Don't link to hosting when we're on the hosting environment
active_tag=limit_tag,
app_rss_token=datastore.data['settings']['application']['rss_access_token'],
datastore=datastore,
form=form,
guid=datastore.data['app_guid'],
has_proxies=datastore.proxy_list,
Expand All @@ -463,7 +453,7 @@ def index():
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),
tags=existing_tags,
tags=datastore.data['settings']['application'].get('tags'),
watches=sorted_watches
)

Expand Down Expand Up @@ -606,9 +596,13 @@ def edit_page(uuid):

# proxy_override set to the json/text list of the items
form = forms.watchForm(formdata=request.form if request.method == 'POST' else None,
data=default,
data=default
)

# For the form widget tag uuid lookup
form.tags.datastore = datastore # in _value


form.fetch_backend.choices.append(("system", 'System settings default'))

# form.browser_steps[0] can be assumed that we 'goto url' first
Expand Down Expand Up @@ -659,6 +653,16 @@ def edit_page(uuid):
extra_update_obj['filter_text_replaced'] = True
extra_update_obj['filter_text_removed'] = True

# 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 = []
if form.data.get('tags'):
# Sometimes in testing this can be list, dont know why
if type(form.data.get('tags')) == list:
extra_update_obj['tags'] = form.data.get('tags')
else:
for t in form.data.get('tags').split(','):
tag_uuids.append(datastore.add_tag(name=t))
extra_update_obj['tags'] = tag_uuids

datastore.data['watching'][uuid].update(form.data)
datastore.data['watching'][uuid].update(extra_update_obj)
Expand Down Expand Up @@ -713,7 +717,7 @@ def edit_page(uuid):
form=form,
has_default_notification_urls=True if len(datastore.data['settings']['application']['notification_urls']) else False,
has_empty_checktime=using_default_check_time,
has_extra_headers_file=watch.has_extra_headers_file or datastore.has_extra_headers_file,
has_extra_headers_file=len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,
is_html_webdriver=is_html_webdriver,
jq_support=jq_support,
playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False),
Expand Down Expand Up @@ -1110,8 +1114,8 @@ def get_backup():
os.path.join(datastore_o.datastore_path, list_with_tags_file), "w"
) as f:
for uuid in datastore.data["watching"]:
url = datastore.data["watching"][uuid]["url"]
tag = datastore.data["watching"][uuid]["tag"]
url = datastore.data["watching"][uuid].get('url')
tag = datastore.data["watching"][uuid].get('tags', {})
f.write("{} {}\r\n".format(url, tag))

# Add it to the Zip
Expand Down Expand Up @@ -1199,7 +1203,7 @@ def form_quick_watch_add():

add_paused = request.form.get('edit_and_watch_submit_button') != None
processor = request.form.get('processor', 'text_json_diff')
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tag').strip(), extras={'paused': add_paused, 'processor': processor})
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor})

if new_uuid:
if add_paused:
Expand Down Expand Up @@ -1267,9 +1271,11 @@ def form_watch_checknow():
elif tag != None:
# Items that have this current tag
for watch_uuid, watch in datastore.data['watching'].items():
if (tag != None and tag in watch['tag']):
if (tag != None and tag in watch.get('tags', {})):
if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']:
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False}))
update_q.put(
queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False})
)
i += 1

else:
Expand Down Expand Up @@ -1357,6 +1363,17 @@ def form_watch_list_checkbox_operations():
datastore.data['watching'][uuid.strip()]['notification_format'] = default_notification_format_for_watch
flash("{} watches set to use default notification settings".format(len(uuids)))

elif (op == 'assign-tag'):
op_extradata = request.form.get('op_extradata')
tag_uuid = datastore.add_tag(name=op_extradata)
if op_extradata and tag_uuid:
for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid]['tags'].append(tag_uuid)

flash("{} watches assigned tag".format(len(uuids)))

return redirect(url_for('index'))

@app.route("/api/share-url", methods=['GET'])
Expand All @@ -1366,7 +1383,6 @@ def form_share_put_watch():
the share-link can be imported/added"""
import requests
import json
tag = request.args.get('tag')
uuid = request.args.get('uuid')

# more for testing
Expand Down Expand Up @@ -1419,6 +1435,8 @@ def form_share_put_watch():
import changedetectionio.blueprint.price_data_follower as price_data_follower
app.register_blueprint(price_data_follower.construct_blueprint(datastore, update_q), url_prefix='/price_data_follower')

import changedetectionio.blueprint.tags as tags
app.register_blueprint(tags.construct_blueprint(datastore), url_prefix='/tags')

# @todo handle ctrl break
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
Expand Down
20 changes: 14 additions & 6 deletions changedetectionio/api/api_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ def post(self):
return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400

extras = copy.deepcopy(json_data)

# Because we renamed 'tag' to 'tags' but dont want to change the API (can do this in v2 of the API)
if extras.get('tag'):
extras['tags'] = extras.get('tag')

del extras['url']

new_uuid = self.datastore.add_watch(url=url, extras=extras)
Expand Down Expand Up @@ -259,13 +264,16 @@ def get(self):
"""
list = {}

tag_limit = request.args.get('tag', None)
for k, watch in self.datastore.data['watching'].items():
if tag_limit:
if not tag_limit.lower() in watch.all_tags:
continue
tag_limit = request.args.get('tag', '').lower()


for uuid, watch in self.datastore.data['watching'].items():
# Watch tags by name (replace the other calls?)
tags = self.datastore.get_all_tags_for_watch(uuid=uuid)
if tag_limit and not any(v.get('title').lower() == tag_limit for k, v in tags.items()):
continue

list[k] = {'url': watch['url'],
list[uuid] = {'url': watch['url'],
'title': watch['title'],
'last_checked': watch['last_checked'],
'last_changed': watch.last_changed,
Expand Down
9 changes: 9 additions & 0 deletions changedetectionio/blueprint/tags/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Groups tags

## How it works

Watch has a list() of tag UUID's, which relate to a config under application.settings.tags

The 'tag' is actually a watch, because they basically will eventually share 90% of the same config.

So a tag is like an abstract of a watch
131 changes: 131 additions & 0 deletions changedetectionio/blueprint/tags/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
from flask import Blueprint, request, make_response, render_template, flash, url_for, redirect
from changedetectionio.store import ChangeDetectionStore
from changedetectionio import login_optionally_required


def construct_blueprint(datastore: ChangeDetectionStore):
tags_blueprint = Blueprint('tags', __name__, template_folder="templates")

@tags_blueprint.route("/list", methods=['GET'])
@login_optionally_required
def tags_overview_page():
from .form import SingleTag
add_form = SingleTag(request.form)
output = render_template("groups-overview.html",
form=add_form,
available_tags=datastore.data['settings']['application'].get('tags', {}),
)

return output

@tags_blueprint.route("/add", methods=['POST'])
@login_optionally_required
def form_tag_add():
from .form import SingleTag
add_form = SingleTag(request.form)

if not add_form.validate():
for widget, l in add_form.errors.items():
flash(','.join(l), 'error')
return redirect(url_for('tags.tags_overview_page'))

title = request.form.get('name').strip()

if datastore.tag_exists_by_name(title):
flash(f'The tag "{title}" already exists', "error")
return redirect(url_for('tags.tags_overview_page'))

datastore.add_tag(title)
flash("Tag added")


return redirect(url_for('tags.tags_overview_page'))

@tags_blueprint.route("/mute/<string:uuid>", methods=['GET'])
@login_optionally_required
def mute(uuid):
if datastore.data['settings']['application']['tags'].get(uuid):
datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = not datastore.data['settings']['application']['tags'][uuid]['notification_muted']
return redirect(url_for('tags.tags_overview_page'))

@tags_blueprint.route("/delete/<string:uuid>", methods=['GET'])
@login_optionally_required
def delete(uuid):
removed = 0
# Delete the tag, and any tag reference
if datastore.data['settings']['application']['tags'].get(uuid):
del datastore.data['settings']['application']['tags'][uuid]

for watch_uuid, watch in datastore.data['watching'].items():
if watch.get('tags') and uuid in watch['tags']:
removed += 1
watch['tags'].remove(uuid)

flash(f"Tag deleted and removed from {removed} watches")
return redirect(url_for('tags.tags_overview_page'))

@tags_blueprint.route("/unlink/<string:uuid>", methods=['GET'])
@login_optionally_required
def unlink(uuid):
unlinked = 0
for watch_uuid, watch in datastore.data['watching'].items():
if watch.get('tags') and uuid in watch['tags']:
unlinked += 1
watch['tags'].remove(uuid)

flash(f"Tag unlinked removed from {unlinked} watches")
return redirect(url_for('tags.tags_overview_page'))

@tags_blueprint.route("/edit/<string:uuid>", methods=['GET'])
@login_optionally_required
def form_tag_edit(uuid):
from changedetectionio import forms

if uuid == 'first':
uuid = list(datastore.data['settings']['application']['tags'].keys()).pop()

default = datastore.data['settings']['application']['tags'].get(uuid)

form = forms.watchForm(formdata=request.form if request.method == 'POST' else None,
data=default,
)
form.datastore=datastore # needed?

output = render_template("edit-tag.html",
data=default,
form=form,
settings_application=datastore.data['settings']['application'],
)

return output


@tags_blueprint.route("/edit/<string:uuid>", methods=['POST'])
@login_optionally_required
def form_tag_edit_submit(uuid):
from changedetectionio import forms
if uuid == 'first':
uuid = list(datastore.data['settings']['application']['tags'].keys()).pop()

default = datastore.data['settings']['application']['tags'].get(uuid)

form = forms.watchForm(formdata=request.form if request.method == 'POST' else None,
data=default,
)
# @todo subclass form so validation works
#if not form.validate():
# for widget, l in form.errors.items():
# flash(','.join(l), 'error')
# return redirect(url_for('tags.form_tag_edit_submit', uuid=uuid))

datastore.data['settings']['application']['tags'][uuid].update(form.data)
datastore.needs_write_urgent = True
flash("Updated")

return redirect(url_for('tags.tags_overview_page'))


@tags_blueprint.route("/delete/<string:uuid>", methods=['GET'])
def form_tag_delete(uuid):
return redirect(url_for('tags.tags_overview_page'))
return tags_blueprint