From 4b5b7f6c06cabfcb28c2923c5d3ffd0d78255e72 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Sun, 23 Mar 2025 14:12:20 +0100 Subject: [PATCH] Adding Tags API, Refactoring API code layout --- changedetectionio/api/Import.py | 62 ++++ changedetectionio/api/SystemInfo.py | 54 ++++ changedetectionio/api/Tags.py | 156 ++++++++++ changedetectionio/api/{api_v1.py => Watch.py} | 287 +----------------- changedetectionio/api/__init__.py | 26 ++ changedetectionio/blueprint/ui/__init__.py | 2 +- changedetectionio/blueprint/ui/edit.py | 2 +- changedetectionio/flask_app.py | 23 +- changedetectionio/store.py | 10 +- .../tests}/test_api_tags.py | 85 +++--- 10 files changed, 352 insertions(+), 355 deletions(-) create mode 100644 changedetectionio/api/Import.py create mode 100644 changedetectionio/api/SystemInfo.py create mode 100644 changedetectionio/api/Tags.py rename changedetectionio/api/{api_v1.py => Watch.py} (53%) rename {tests => changedetectionio/tests}/test_api_tags.py (64%) diff --git a/changedetectionio/api/Import.py b/changedetectionio/api/Import.py new file mode 100644 index 000000000..e5abd0a87 --- /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 000000000..72105b995 --- /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 000000000..b001edc21 --- /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 53% rename from changedetectionio/api/api_v1.py rename to changedetectionio/api/Watch.py index 4a24ba65b..1a815670d 100644 --- a/changedetectionio/api/api_v1.py +++ b/changedetectionio/api/Watch.py @@ -9,27 +9,9 @@ import validators 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 - -# 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 class Watch(Resource): def __init__(self, **kwargs): @@ -312,267 +294,4 @@ class CreateWatch(Resource): self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) return {'status': "OK"}, 200 - return list, 200 - -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='No tag exists with the UUID of {}'.format(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 - - -class Tags(Resource): - def __init__(self, **kwargs): - # datastore is a black box dependency - self.datastore = kwargs['datastore'] - - @auth.check_token - @expects_json(schema_create_tag) - def post(self): - """ - @api {post} /api/v1/tag Create a single tag - @apiDescription Requires atleast `title` set - @apiExample {curl} Example usage: - curl http://localhost:5000/api/v1/tag -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"title": "New Tag"}' - @apiName CreateTag - @apiGroup Tag - @apiSuccess (200) {String} OK Was created - @apiSuccess (500) {String} ERR Some other error - """ - json_data = request.get_json() - title = json_data['title'].strip() - - if self.datastore.tag_exists_by_name(title): - return "Tag with this title already exists", 400 - - new_uuid = self.datastore.add_tag(name=title) - if new_uuid: - # Update other fields if provided - tag = self.datastore.data['settings']['application']['tags'].get(new_uuid) - for k, v in json_data.items(): - if k != 'title' and hasattr(tag, k): - tag[k] = v - - self.datastore.needs_write_urgent = True - return {'uuid': new_uuid}, 201 - else: - return "Failed to create tag", 400 - - @auth.check_token - def get(self): - """ - @api {get} /api/v1/tag List tags - @apiDescription Return list of available tags - @apiExample {curl} Example usage: - curl http://localhost:5000/api/v1/tag -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] = { - 'title': tag.get('title', ''), - 'notification_muted': tag.get('notification_muted', False), - 'date_created': tag.get('date_created', 0) - } - - return result, 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 e69de29bb..d60716685 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 16f07f379..5dfffac52 100644 --- a/changedetectionio/blueprint/ui/__init__.py +++ b/changedetectionio/blueprint/ui/__init__.py @@ -231,7 +231,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat 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 73cd78530..db1e8e556 100644 --- a/changedetectionio/blueprint/ui/edit.py +++ b/changedetectionio/blueprint/ui/edit.py @@ -153,7 +153,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe 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 e3804c1a5..229ad9833 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 loguru import logger 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,37 +249,33 @@ def changedetection_app(config=None, datastore_o=None): 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}) - watch_api.add_resource(api_v1.Tags, '/api/v1/tag', + watch_api.add_resource(Tags, '/api/v1/tags', resource_class_kwargs={'datastore': datastore}) - watch_api.add_resource(api_v1.Tag, '/api/v1/tag/', + watch_api.add_resource(Tag, '/api/v1/tag', '/api/v1/tag/', resource_class_kwargs={'datastore': datastore}) - # Setup cors headers to allow all domains - # https://flask-cors.readthedocs.io/en/latest/ - # CORS(app) - @login_manager.user_loader diff --git a/changedetectionio/store.py b/changedetectionio/store.py index efc29275a..0b319cdf2 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -571,16 +571,16 @@ class ChangeDetectionStore: 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 @@ class ChangeDetectionStore: 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 @@ class ChangeDetectionStore: 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/tests/test_api_tags.py b/changedetectionio/tests/test_api_tags.py similarity index 64% rename from tests/test_api_tags.py rename to changedetectionio/tests/test_api_tags.py index efeae4e55..55131d6db 100644 --- a/tests/test_api_tags.py +++ b/changedetectionio/tests/test_api_tags.py @@ -1,67 +1,52 @@ #!/usr/bin/env python3 -import time from flask import url_for from .util import live_server_setup, wait_for_all_checks - import json -import uuid - -def is_valid_uuid(val): - try: - uuid.UUID(str(val)) - return True - except ValueError: - return False - - -def test_setup(client, live_server, measure_memory_usage): +def test_api_tags_listing(client, live_server, measure_memory_usage): live_server_setup(live_server) - - -def test_api_tags(client, live_server, measure_memory_usage): api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + tag_title = 'Test Tag' - # Make sure we start with no tags + # Get a listing res = client.get( - url_for("tags.tags_overview_page") + url_for("tags"), + headers={'x-api-key': api_key} ) - assert b'No tags' in res.data + assert res.text.strip() == "{}", "Should be empty list" + assert res.status_code == 200 - # Create a tag via API res = client.post( - "/api/v1/tag", - data=json.dumps({"title": "Test Tag"}), - headers={'content-type': 'application/json', 'x-api-key': api_key}, - follow_redirects=True + url_for("tag"), + data=json.dumps({"title": tag_title}), + headers={'content-type': 'application/json', 'x-api-key': api_key} ) - assert res.status_code == 201 - assert is_valid_uuid(res.json.get('uuid')) - tag_uuid = res.json.get('uuid') + + new_tag_uuid = res.json.get('uuid') # List tags - should include our new tag res = client.get( - "/api/v1/tag", + url_for("tags"), headers={'x-api-key': api_key} ) assert res.status_code == 200 - assert tag_uuid in res.json - assert res.json[tag_uuid]['title'] == 'Test Tag' - assert res.json[tag_uuid]['notification_muted'] == False + 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( - f"/api/v1/tag/{tag_uuid}", + url_for("tag", uuid=new_tag_uuid), headers={'x-api-key': api_key} ) assert res.status_code == 200 - assert res.json['title'] == 'Test Tag' + assert res.json['title'] == tag_title # Update tag res = client.put( - f"/api/v1/tag/{tag_uuid}", + url_for("tag", uuid=new_tag_uuid), data=json.dumps({"title": "Updated Tag"}), headers={'content-type': 'application/json', 'x-api-key': api_key} ) @@ -70,7 +55,7 @@ def test_api_tags(client, live_server, measure_memory_usage): # Verify update worked res = client.get( - f"/api/v1/tag/{tag_uuid}", + url_for("tag", uuid=new_tag_uuid), headers={'x-api-key': api_key} ) assert res.status_code == 200 @@ -78,7 +63,7 @@ def test_api_tags(client, live_server, measure_memory_usage): # Mute tag notifications res = client.get( - f"/api/v1/tag/{tag_uuid}?muted=muted", + url_for("tag", uuid=new_tag_uuid) + "?muted=muted", headers={'x-api-key': api_key} ) assert res.status_code == 200 @@ -86,7 +71,7 @@ def test_api_tags(client, live_server, measure_memory_usage): # Verify muted status res = client.get( - f"/api/v1/tag/{tag_uuid}", + url_for("tag", uuid=new_tag_uuid), headers={'x-api-key': api_key} ) assert res.status_code == 200 @@ -94,7 +79,7 @@ def test_api_tags(client, live_server, measure_memory_usage): # Unmute tag res = client.get( - f"/api/v1/tag/{tag_uuid}?muted=unmuted", + url_for("tag", uuid=new_tag_uuid) + "?muted=unmuted", headers={'x-api-key': api_key} ) assert res.status_code == 200 @@ -102,16 +87,16 @@ def test_api_tags(client, live_server, measure_memory_usage): # Verify unmuted status res = client.get( - f"/api/v1/tag/{tag_uuid}", + 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 + # Create a watch with the tag and check it matches UUID test_url = url_for('test_endpoint', _external=True) res = client.post( - "/api/v1/watch", + 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 @@ -119,40 +104,40 @@ def test_api_tags(client, live_server, measure_memory_usage): assert res.status_code == 201 watch_uuid = res.json.get('uuid') - # Verify tag is associated with watch + # 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 tag_uuid in res.json.get('tags', []) + assert new_tag_uuid in res.json.get('tags', []) # Delete tag res = client.delete( - f"/api/v1/tag/{tag_uuid}", + 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( - "/api/v1/tag", + url_for("tags"), headers={'x-api-key': api_key} ) assert res.status_code == 200 - assert tag_uuid not in res.json + assert new_tag_uuid not in res.text # Verify tag was removed from watch res = client.get( - f"/api/v1/watch/{watch_uuid}", + url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key} ) assert res.status_code == 200 - assert tag_uuid not in res.json.get('tags', []) + assert new_tag_uuid not in res.json.get('tags', []) # Delete the watch res = client.delete( - f"/api/v1/watch/{watch_uuid}", + url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key}, ) - assert res.status_code == 204 \ No newline at end of file + assert res.status_code == 204