diff --git a/changedetectionio/api/api_v1.py b/changedetectionio/api/api_v1.py index 72598188e..4a24ba65b 100644 --- a/changedetectionio/api/api_v1.py +++ b/changedetectionio/api/api_v1.py @@ -24,6 +24,13 @@ 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): # datastore is a black box dependency @@ -307,6 +314,163 @@ class CreateWatch(Resource): 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 diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 44707bd4d..e3804c1a5 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -271,6 +271,12 @@ def changedetection_app(config=None, datastore_o=None): '/api/v1/import', resource_class_kwargs={'datastore': datastore}) + watch_api.add_resource(api_v1.Tags, '/api/v1/tag', + resource_class_kwargs={'datastore': datastore}) + + watch_api.add_resource(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) diff --git a/tests/test_api_tags.py b/tests/test_api_tags.py new file mode 100644 index 000000000..efeae4e55 --- /dev/null +++ b/tests/test_api_tags.py @@ -0,0 +1,158 @@ +#!/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): + 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') + + # Make sure we start with no tags + res = client.get( + url_for("tags.tags_overview_page") + ) + assert b'No tags' in res.data + + # 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 + ) + + assert res.status_code == 201 + assert is_valid_uuid(res.json.get('uuid')) + tag_uuid = res.json.get('uuid') + + # List tags - should include our new tag + res = client.get( + "/api/v1/tag", + 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 + + # Get single tag + res = client.get( + f"/api/v1/tag/{tag_uuid}", + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json['title'] == 'Test Tag' + + # Update tag + res = client.put( + f"/api/v1/tag/{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( + f"/api/v1/tag/{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( + f"/api/v1/tag/{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( + f"/api/v1/tag/{tag_uuid}", + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json['notification_muted'] == True + + # Unmute tag + res = client.get( + f"/api/v1/tag/{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( + f"/api/v1/tag/{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 + test_url = url_for('test_endpoint', _external=True) + res = client.post( + "/api/v1/watch", + 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 + 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', []) + + # Delete tag + res = client.delete( + f"/api/v1/tag/{tag_uuid}", + headers={'x-api-key': api_key} + ) + assert res.status_code == 204 + + # Verify tag is gone + res = client.get( + "/api/v1/tag", + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert tag_uuid not in res.json + + # Verify tag was removed from watch + res = client.get( + f"/api/v1/watch/{watch_uuid}", + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert tag_uuid not in res.json.get('tags', []) + + # Delete the watch + res = client.delete( + f"/api/v1/watch/{watch_uuid}", + headers={'x-api-key': api_key}, + ) + assert res.status_code == 204 \ No newline at end of file