diff --git a/changedetectionio/api/Import.py b/changedetectionio/api/Import.py new file mode 100644 index 00000000000..e5abd0a8741 --- /dev/null +++ b/changedetectionio/api/Import.py @@ -0,0 +1,62 @@ +import os +from changedetectionio.strtobool import strtobool +from flask_restful import abort, Resource +from flask import request +import validators +from . import auth + + +class Import(Resource): + def __init__(self, **kwargs): + # datastore is a black box dependency + self.datastore = kwargs['datastore'] + + @auth.check_token + def post(self): + """ + @api {post} /api/v1/import Import a list of watched URLs + @apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line. + @apiExample {curl} Example usage: + curl http://localhost:5000/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a" + @apiName Import + @apiGroup Watch + @apiSuccess (200) {List} OK List of watch UUIDs added + @apiSuccess (500) {String} ERR Some other error + """ + + extras = {} + + if request.args.get('proxy'): + plist = self.datastore.proxy_list + if not request.args.get('proxy') in plist: + return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 + else: + extras['proxy'] = request.args.get('proxy') + + dedupe = strtobool(request.args.get('dedupe', 'true')) + + tags = request.args.get('tag') + tag_uuids = request.args.get('tag_uuids') + + if tag_uuids: + tag_uuids = tag_uuids.split(',') + + urls = request.get_data().decode('utf8').splitlines() + added = [] + allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) + for url in urls: + url = url.strip() + if not len(url): + continue + + # If hosts that only contain alphanumerics are allowed ("localhost" for example) + if not validators.url(url, simple_host=allow_simplehost): + return f"Invalid or unsupported URL - {url}", 400 + + if dedupe and self.datastore.url_exists(url): + continue + + new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids) + added.append(new_uuid) + + return added \ No newline at end of file diff --git a/changedetectionio/api/SystemInfo.py b/changedetectionio/api/SystemInfo.py new file mode 100644 index 00000000000..72105b995ed --- /dev/null +++ b/changedetectionio/api/SystemInfo.py @@ -0,0 +1,54 @@ +from flask_restful import Resource +from . import auth + + +class SystemInfo(Resource): + def __init__(self, **kwargs): + # datastore is a black box dependency + self.datastore = kwargs['datastore'] + self.update_q = kwargs['update_q'] + + @auth.check_token + def get(self): + """ + @api {get} /api/v1/systeminfo Return system info + @apiDescription Return some info about the current system state + @apiExample {curl} Example usage: + curl http://localhost:5000/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45" + HTTP/1.0 200 + { + 'queue_size': 10 , + 'overdue_watches': ["watch-uuid-list"], + 'uptime': 38344.55, + 'watch_count': 800, + 'version': "0.40.1" + } + @apiName Get Info + @apiGroup System Information + """ + import time + overdue_watches = [] + + # Check all watches and report which have not been checked but should have been + + for uuid, watch in self.datastore.data.get('watching', {}).items(): + # see if now - last_checked is greater than the time that should have been + # this is not super accurate (maybe they just edited it) but better than nothing + t = watch.threshold_seconds() + if not t: + # Use the system wide default + t = self.datastore.threshold_seconds + + time_since_check = time.time() - watch.get('last_checked') + + # Allow 5 minutes of grace time before we decide it's overdue + if time_since_check - (5 * 60) > t: + overdue_watches.append(uuid) + from changedetectionio import __version__ as main_version + return { + 'queue_size': self.update_q.qsize(), + 'overdue_watches': overdue_watches, + 'uptime': round(time.time() - self.datastore.start_time, 2), + 'watch_count': len(self.datastore.data.get('watching', {})), + 'version': main_version + }, 200 \ No newline at end of file diff --git a/changedetectionio/api/Tags.py b/changedetectionio/api/Tags.py new file mode 100644 index 00000000000..b001edc21b3 --- /dev/null +++ b/changedetectionio/api/Tags.py @@ -0,0 +1,156 @@ +from flask_expects_json import expects_json +from flask_restful import abort, Resource +from flask import request +from . import auth + +# Import schemas from __init__.py +from . import schema_tag, schema_create_tag, schema_update_tag + + +class Tag(Resource): + def __init__(self, **kwargs): + # datastore is a black box dependency + self.datastore = kwargs['datastore'] + + # Get information about a single tag + # curl http://localhost:5000/api/v1/tag/ + @auth.check_token + def get(self, uuid): + """ + @api {get} /api/v1/tag/:uuid Single tag - get data or toggle notification muting. + @apiDescription Retrieve tag information and set notification_muted status + @apiExample {curl} Example usage: + curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45" + curl "http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=muted" -H"x-api-key:813031b16330fe25e3780cf0325daa45" + @apiName Tag + @apiGroup Tag + @apiParam {uuid} uuid Tag unique ID. + @apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state + @apiSuccess (200) {String} OK When muted operation OR full JSON object of the tag + @apiSuccess (200) {JSON} TagJSON JSON Full JSON object of the tag + """ + from copy import deepcopy + tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid)) + if not tag: + abort(404, message=f'No tag exists with the UUID of {uuid}') + + if request.args.get('muted', '') == 'muted': + self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = True + return "OK", 200 + elif request.args.get('muted', '') == 'unmuted': + self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = False + return "OK", 200 + + return tag + + @auth.check_token + def delete(self, uuid): + """ + @api {delete} /api/v1/tag/:uuid Delete a tag and remove it from all watches + @apiExample {curl} Example usage: + curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" + @apiParam {uuid} uuid Tag unique ID. + @apiName DeleteTag + @apiGroup Tag + @apiSuccess (200) {String} OK Was deleted + """ + if not self.datastore.data['settings']['application']['tags'].get(uuid): + abort(400, message='No tag exists with the UUID of {}'.format(uuid)) + + # Delete the tag, and any tag reference + del self.datastore.data['settings']['application']['tags'][uuid] + + # Remove tag from all watches + for watch_uuid, watch in self.datastore.data['watching'].items(): + if watch.get('tags') and uuid in watch['tags']: + watch['tags'].remove(uuid) + + return 'OK', 204 + + @auth.check_token + @expects_json(schema_update_tag) + def put(self, uuid): + """ + @api {put} /api/v1/tag/:uuid Update tag information + @apiExample {curl} Example usage: + Update (PUT) + curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"title": "New Tag Title"}' + + @apiDescription Updates an existing tag using JSON + @apiParam {uuid} uuid Tag unique ID. + @apiName UpdateTag + @apiGroup Tag + @apiSuccess (200) {String} OK Was updated + @apiSuccess (500) {String} ERR Some other error + """ + tag = self.datastore.data['settings']['application']['tags'].get(uuid) + if not tag: + abort(404, message='No tag exists with the UUID of {}'.format(uuid)) + + tag.update(request.json) + self.datastore.needs_write_urgent = True + + return "OK", 200 + + + @auth.check_token + # Only cares for {'title': 'xxxx'} + def post(self): + """ + @api {post} /api/v1/watch Create a single tag + @apiExample {curl} Example usage: + curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"name": "Work related"}' + @apiName Create + @apiGroup Tag + @apiSuccess (200) {String} OK Was created + @apiSuccess (500) {String} ERR Some other error + """ + + json_data = request.get_json() + title = json_data.get("title",'').strip() + + + new_uuid = self.datastore.add_tag(title=title) + if new_uuid: + return {'uuid': new_uuid}, 201 + else: + return "Invalid or unsupported tag", 400 + +class Tags(Resource): + def __init__(self, **kwargs): + # datastore is a black box dependency + self.datastore = kwargs['datastore'] + + @auth.check_token + def get(self): + """ + @api {get} /api/v1/tags List tags + @apiDescription Return list of available tags + @apiExample {curl} Example usage: + curl http://localhost:5000/api/v1/tags -H"x-api-key:813031b16330fe25e3780cf0325daa45" + { + "cc0cfffa-f449-477b-83ea-0caafd1dc091": { + "title": "Tech News", + "notification_muted": false, + "date_created": 1677103794 + }, + "e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": { + "title": "Shopping", + "notification_muted": true, + "date_created": 1676662819 + } + } + @apiName ListTags + @apiGroup Tag Management + @apiSuccess (200) {String} OK JSON dict + """ + result = {} + for uuid, tag in self.datastore.data['settings']['application']['tags'].items(): + result[uuid] = { + 'date_created': tag.get('date_created', 0), + 'notification_muted': tag.get('notification_muted', False), + 'title': tag.get('title', ''), + 'uuid': tag.get('uuid') + } + + return result, 200 \ No newline at end of file diff --git a/changedetectionio/api/api_v1.py b/changedetectionio/api/Watch.py similarity index 73% rename from changedetectionio/api/api_v1.py rename to changedetectionio/api/Watch.py index 72598188ef0..1a815670d8b 100644 --- a/changedetectionio/api/api_v1.py +++ b/changedetectionio/api/Watch.py @@ -9,20 +9,9 @@ from . import auth import copy -# See docs/README.md for rebuilding the docs/apidoc information +# Import schemas from __init__.py +from . import schema, schema_create_watch, schema_update_watch -from . import api_schema -from ..model import watch_base - -# Build a JSON Schema atleast partially based on our Watch model -watch_base_config = watch_base() -schema = api_schema.build_watch_json_schema(watch_base_config) - -schema_create_watch = copy.deepcopy(schema) -schema_create_watch['required'] = ['url'] - -schema_update_watch = copy.deepcopy(schema) -schema_update_watch['additionalProperties'] = False class Watch(Resource): def __init__(self, **kwargs): @@ -305,110 +294,4 @@ def get(self): self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) return {'status': "OK"}, 200 - return list, 200 - -class Import(Resource): - def __init__(self, **kwargs): - # datastore is a black box dependency - self.datastore = kwargs['datastore'] - - @auth.check_token - def post(self): - """ - @api {post} /api/v1/import Import a list of watched URLs - @apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line. - @apiExample {curl} Example usage: - curl http://localhost:5000/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a" - @apiName Import - @apiGroup Watch - @apiSuccess (200) {List} OK List of watch UUIDs added - @apiSuccess (500) {String} ERR Some other error - """ - - extras = {} - - if request.args.get('proxy'): - plist = self.datastore.proxy_list - if not request.args.get('proxy') in plist: - return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 - else: - extras['proxy'] = request.args.get('proxy') - - dedupe = strtobool(request.args.get('dedupe', 'true')) - - tags = request.args.get('tag') - tag_uuids = request.args.get('tag_uuids') - - if tag_uuids: - tag_uuids = tag_uuids.split(',') - - urls = request.get_data().decode('utf8').splitlines() - added = [] - allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) - for url in urls: - url = url.strip() - if not len(url): - continue - - # If hosts that only contain alphanumerics are allowed ("localhost" for example) - if not validators.url(url, simple_host=allow_simplehost): - return f"Invalid or unsupported URL - {url}", 400 - - if dedupe and self.datastore.url_exists(url): - continue - - new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids) - added.append(new_uuid) - - return added - -class SystemInfo(Resource): - def __init__(self, **kwargs): - # datastore is a black box dependency - self.datastore = kwargs['datastore'] - self.update_q = kwargs['update_q'] - - @auth.check_token - def get(self): - """ - @api {get} /api/v1/systeminfo Return system info - @apiDescription Return some info about the current system state - @apiExample {curl} Example usage: - curl http://localhost:5000/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45" - HTTP/1.0 200 - { - 'queue_size': 10 , - 'overdue_watches': ["watch-uuid-list"], - 'uptime': 38344.55, - 'watch_count': 800, - 'version': "0.40.1" - } - @apiName Get Info - @apiGroup System Information - """ - import time - overdue_watches = [] - - # Check all watches and report which have not been checked but should have been - - for uuid, watch in self.datastore.data.get('watching', {}).items(): - # see if now - last_checked is greater than the time that should have been - # this is not super accurate (maybe they just edited it) but better than nothing - t = watch.threshold_seconds() - if not t: - # Use the system wide default - t = self.datastore.threshold_seconds - - time_since_check = time.time() - watch.get('last_checked') - - # Allow 5 minutes of grace time before we decide it's overdue - if time_since_check - (5 * 60) > t: - overdue_watches.append(uuid) - from changedetectionio import __version__ as main_version - return { - 'queue_size': self.update_q.qsize(), - 'overdue_watches': overdue_watches, - 'uptime': round(time.time() - self.datastore.start_time, 2), - 'watch_count': len(self.datastore.data.get('watching', {})), - 'version': main_version - }, 200 + return list, 200 \ No newline at end of file diff --git a/changedetectionio/api/__init__.py b/changedetectionio/api/__init__.py index e69de29bb2d..d607166850d 100644 --- a/changedetectionio/api/__init__.py +++ b/changedetectionio/api/__init__.py @@ -0,0 +1,26 @@ +import copy +from . import api_schema +from ..model import watch_base + +# Build a JSON Schema atleast partially based on our Watch model +watch_base_config = watch_base() +schema = api_schema.build_watch_json_schema(watch_base_config) + +schema_create_watch = copy.deepcopy(schema) +schema_create_watch['required'] = ['url'] + +schema_update_watch = copy.deepcopy(schema) +schema_update_watch['additionalProperties'] = False + +# Tag schema is also based on watch_base since Tag inherits from it +schema_tag = copy.deepcopy(schema) +schema_create_tag = copy.deepcopy(schema_tag) +schema_create_tag['required'] = ['title'] +schema_update_tag = copy.deepcopy(schema_tag) +schema_update_tag['additionalProperties'] = False + +# Import all API resources +from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch +from .Tags import Tags, Tag +from .Import import Import +from .SystemInfo import SystemInfo diff --git a/changedetectionio/blueprint/ui/__init__.py b/changedetectionio/blueprint/ui/__init__.py index 16f07f379ca..5dfffac52db 100644 --- a/changedetectionio/blueprint/ui/__init__.py +++ b/changedetectionio/blueprint/ui/__init__.py @@ -231,7 +231,7 @@ def form_watch_list_checkbox_operations(): elif (op == 'assign-tag'): op_extradata = request.form.get('op_extradata', '').strip() if op_extradata: - tag_uuid = datastore.add_tag(name=op_extradata) + tag_uuid = datastore.add_tag(title=op_extradata) if op_extradata and tag_uuid: for uuid in uuids: uuid = uuid.strip() diff --git a/changedetectionio/blueprint/ui/edit.py b/changedetectionio/blueprint/ui/edit.py index 73cd785308d..db1e8e5564d 100644 --- a/changedetectionio/blueprint/ui/edit.py +++ b/changedetectionio/blueprint/ui/edit.py @@ -153,7 +153,7 @@ def edit_page(uuid): 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)) + tag_uuids.append(datastore.add_tag(title=t)) extra_update_obj['tags'] = tag_uuids datastore.data['watching'][uuid].update(form.data) diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 44707bd4ded..229ad9833b2 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 - import flask_login import locale import os @@ -34,7 +33,7 @@ from changedetectionio import __version__ from changedetectionio import queuedWatchMetaData -from changedetectionio.api import api_v1 +from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags from .time_handler import is_within_schedule datastore = None @@ -250,30 +249,32 @@ def check_authentication(): return login_manager.unauthorized() - watch_api.add_resource(api_v1.WatchSingleHistory, + watch_api.add_resource(WatchSingleHistory, '/api/v1/watch//history/', resource_class_kwargs={'datastore': datastore, 'update_q': update_q}) - watch_api.add_resource(api_v1.WatchHistory, + watch_api.add_resource(WatchHistory, '/api/v1/watch//history', resource_class_kwargs={'datastore': datastore}) - watch_api.add_resource(api_v1.CreateWatch, '/api/v1/watch', + watch_api.add_resource(CreateWatch, '/api/v1/watch', resource_class_kwargs={'datastore': datastore, 'update_q': update_q}) - watch_api.add_resource(api_v1.Watch, '/api/v1/watch/', + watch_api.add_resource(Watch, '/api/v1/watch/', resource_class_kwargs={'datastore': datastore, 'update_q': update_q}) - watch_api.add_resource(api_v1.SystemInfo, '/api/v1/systeminfo', + watch_api.add_resource(SystemInfo, '/api/v1/systeminfo', resource_class_kwargs={'datastore': datastore, 'update_q': update_q}) - watch_api.add_resource(api_v1.Import, + watch_api.add_resource(Import, '/api/v1/import', resource_class_kwargs={'datastore': datastore}) - # Setup cors headers to allow all domains - # https://flask-cors.readthedocs.io/en/latest/ - # CORS(app) + watch_api.add_resource(Tags, '/api/v1/tags', + resource_class_kwargs={'datastore': datastore}) + + watch_api.add_resource(Tag, '/api/v1/tag', '/api/v1/tag/', + resource_class_kwargs={'datastore': datastore}) diff --git a/changedetectionio/store.py b/changedetectionio/store.py index efc29275a07..0b319cdf24b 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -571,16 +571,16 @@ def get_tag_overrides_for_watch(self, uuid, attr): return ret - def add_tag(self, name): + def add_tag(self, title): # If name exists, return that - n = name.strip().lower() + n = title.strip().lower() logger.debug(f">>> Adding new tag - '{n}'") if not n: return False for uuid, tag in self.__data['settings']['application'].get('tags', {}).items(): if n == tag.get('title', '').lower().strip(): - logger.warning(f"Tag '{name}' already exists, skipping creation.") + logger.warning(f"Tag '{title}' already exists, skipping creation.") return uuid # Eventually almost everything todo with a watch will apply as a Tag @@ -588,7 +588,7 @@ def add_tag(self, name): with self.lock: from .model import Tag new_tag = Tag.model(datastore_path=self.datastore_path, default={ - 'title': name.strip(), + 'title': title.strip(), 'date_created': int(time.time()) }) @@ -847,7 +847,7 @@ def update_12(self): if tag: tag_uuids = [] for t in tag.split(','): - tag_uuids.append(self.add_tag(name=t)) + tag_uuids.append(self.add_tag(title=t)) self.data['watching'][uuid]['tags'] = tag_uuids diff --git a/changedetectionio/tests/test_api_tags.py b/changedetectionio/tests/test_api_tags.py new file mode 100644 index 00000000000..55131d6dbd5 --- /dev/null +++ b/changedetectionio/tests/test_api_tags.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 + +from flask import url_for +from .util import live_server_setup, wait_for_all_checks +import json + +def test_api_tags_listing(client, live_server, measure_memory_usage): + live_server_setup(live_server) + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + tag_title = 'Test Tag' + + # Get a listing + res = client.get( + url_for("tags"), + headers={'x-api-key': api_key} + ) + assert res.text.strip() == "{}", "Should be empty list" + assert res.status_code == 200 + + res = client.post( + url_for("tag"), + data=json.dumps({"title": tag_title}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 201 + + new_tag_uuid = res.json.get('uuid') + + # List tags - should include our new tag + res = client.get( + url_for("tags"), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert new_tag_uuid in res.text + assert res.json[new_tag_uuid]['title'] == tag_title + assert res.json[new_tag_uuid]['notification_muted'] == False + + # Get single tag + res = client.get( + url_for("tag", uuid=new_tag_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json['title'] == tag_title + + # Update tag + res = client.put( + url_for("tag", uuid=new_tag_uuid), + data=json.dumps({"title": "Updated Tag"}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 200 + assert b'OK' in res.data + + # Verify update worked + res = client.get( + url_for("tag", uuid=new_tag_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json['title'] == 'Updated Tag' + + # Mute tag notifications + res = client.get( + url_for("tag", uuid=new_tag_uuid) + "?muted=muted", + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert b'OK' in res.data + + # Verify muted status + res = client.get( + url_for("tag", uuid=new_tag_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json['notification_muted'] == True + + # Unmute tag + res = client.get( + url_for("tag", uuid=new_tag_uuid) + "?muted=unmuted", + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert b'OK' in res.data + + # Verify unmuted status + res = client.get( + url_for("tag", uuid=new_tag_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json['notification_muted'] == False + + # Create a watch with the tag and check it matches UUID + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("createwatch"), + data=json.dumps({"url": test_url, "tag": "Updated Tag", "title": "Watch with tag"}), + headers={'content-type': 'application/json', 'x-api-key': api_key}, + follow_redirects=True + ) + assert res.status_code == 201 + watch_uuid = res.json.get('uuid') + + # Verify tag is associated with watch by name if need be + res = client.get( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert new_tag_uuid in res.json.get('tags', []) + + # Delete tag + res = client.delete( + url_for("tag", uuid=new_tag_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 204 + + # Verify tag is gone + res = client.get( + url_for("tags"), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert new_tag_uuid not in res.text + + # Verify tag was removed from watch + res = client.get( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert new_tag_uuid not in res.json.get('tags', []) + + # Delete the watch + res = client.delete( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key}, + ) + assert res.status_code == 204