mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-12-21 23:45:52 +00:00
Compare commits
6 Commits
0.49.5
...
API-add-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64ccf0b4af | ||
|
|
27928f374b | ||
|
|
d58a71cffc | ||
|
|
036b006226 | ||
|
|
f29f89d078 | ||
|
|
289f118581 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||||
|
|
||||||
__version__ = '0.49.5'
|
__version__ = '0.49.7'
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
|||||||
62
changedetectionio/api/Import.py
Normal file
62
changedetectionio/api/Import.py
Normal file
@@ -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
|
||||||
51
changedetectionio/api/Search.py
Normal file
51
changedetectionio/api/Search.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from flask_restful import Resource, abort
|
||||||
|
from flask import request
|
||||||
|
from . import auth
|
||||||
|
|
||||||
|
class Search(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/search Search for watches
|
||||||
|
@apiDescription Search watches by URL or title text
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl "http://localhost:5000/api/v1/search?q=https://example.com/page1" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
curl "http://localhost:5000/api/v1/search?q=https://example.com/page1?tag=Favourites" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
curl "http://localhost:5000/api/v1/search?q=https://example.com?partial=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
@apiName Search
|
||||||
|
@apiGroup Watch Management
|
||||||
|
@apiQuery {String} q Search query to match against watch URLs and titles
|
||||||
|
@apiQuery {String} [tag] Optional name of tag to limit results (name not UUID)
|
||||||
|
@apiQuery {String} [partial] Allow partial matching of URL query
|
||||||
|
@apiSuccess (200) {Object} JSON Object containing matched watches
|
||||||
|
"""
|
||||||
|
query = request.args.get('q', '').strip()
|
||||||
|
tag_limit = request.args.get('tag', '').strip()
|
||||||
|
from changedetectionio.strtobool import strtobool
|
||||||
|
partial = bool(strtobool(request.args.get('partial', '0'))) if 'partial' in request.args else False
|
||||||
|
|
||||||
|
# Require a search query
|
||||||
|
if not query:
|
||||||
|
abort(400, message="Search query 'q' parameter is required")
|
||||||
|
|
||||||
|
# Use the search function from the datastore
|
||||||
|
matching_uuids = self.datastore.search_watches_for_url(query=query, tag_limit=tag_limit, partial=partial)
|
||||||
|
|
||||||
|
# Build the response with watch details
|
||||||
|
results = {}
|
||||||
|
for uuid in matching_uuids:
|
||||||
|
watch = self.datastore.data['watching'].get(uuid)
|
||||||
|
results[uuid] = {
|
||||||
|
'last_changed': watch.last_changed,
|
||||||
|
'last_checked': watch['last_checked'],
|
||||||
|
'last_error': watch['last_error'],
|
||||||
|
'title': watch['title'],
|
||||||
|
'url': watch['url'],
|
||||||
|
'viewed': watch.viewed
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, 200
|
||||||
54
changedetectionio/api/SystemInfo.py
Normal file
54
changedetectionio/api/SystemInfo.py
Normal file
@@ -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
|
||||||
156
changedetectionio/api/Tags.py
Normal file
156
changedetectionio/api/Tags.py
Normal file
@@ -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/<string:uuid>
|
||||||
|
@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
|
||||||
@@ -9,20 +9,9 @@ import validators
|
|||||||
from . import auth
|
from . import auth
|
||||||
import copy
|
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):
|
class Watch(Resource):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
@@ -285,8 +274,6 @@ class CreateWatch(Resource):
|
|||||||
list = {}
|
list = {}
|
||||||
|
|
||||||
tag_limit = request.args.get('tag', '').lower()
|
tag_limit = request.args.get('tag', '').lower()
|
||||||
|
|
||||||
|
|
||||||
for uuid, watch in self.datastore.data['watching'].items():
|
for uuid, watch in self.datastore.data['watching'].items():
|
||||||
# Watch tags by name (replace the other calls?)
|
# Watch tags by name (replace the other calls?)
|
||||||
tags = self.datastore.get_all_tags_for_watch(uuid=uuid)
|
tags = self.datastore.get_all_tags_for_watch(uuid=uuid)
|
||||||
@@ -308,109 +295,3 @@ class CreateWatch(Resource):
|
|||||||
return {'status': "OK"}, 200
|
return {'status': "OK"}, 200
|
||||||
|
|
||||||
return list, 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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -11,19 +11,11 @@ def check_token(f):
|
|||||||
datastore = args[0].datastore
|
datastore = args[0].datastore
|
||||||
|
|
||||||
config_api_token_enabled = datastore.data['settings']['application'].get('api_access_token_enabled')
|
config_api_token_enabled = datastore.data['settings']['application'].get('api_access_token_enabled')
|
||||||
if not config_api_token_enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
api_key_header = request.headers['x-api-key']
|
|
||||||
except KeyError:
|
|
||||||
return make_response(
|
|
||||||
jsonify("No authorization x-api-key header."), 403
|
|
||||||
)
|
|
||||||
|
|
||||||
config_api_token = datastore.data['settings']['application'].get('api_access_token')
|
config_api_token = datastore.data['settings']['application'].get('api_access_token')
|
||||||
|
|
||||||
if api_key_header != config_api_token:
|
# config_api_token_enabled - a UI option in settings if access should obey the key or not
|
||||||
|
if config_api_token_enabled:
|
||||||
|
if request.headers.get('x-api-key') != config_api_token:
|
||||||
return make_response(
|
return make_response(
|
||||||
jsonify("Invalid access - API key invalid."), 403
|
jsonify("Invalid access - API key invalid."), 403
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
elif (op == 'assign-tag'):
|
elif (op == 'assign-tag'):
|
||||||
op_extradata = request.form.get('op_extradata', '').strip()
|
op_extradata = request.form.get('op_extradata', '').strip()
|
||||||
if op_extradata:
|
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:
|
if op_extradata and tag_uuid:
|
||||||
for uuid in uuids:
|
for uuid in uuids:
|
||||||
uuid = uuid.strip()
|
uuid = uuid.strip()
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
extra_update_obj['tags'] = form.data.get('tags')
|
extra_update_obj['tags'] = form.data.get('tags')
|
||||||
else:
|
else:
|
||||||
for t in form.data.get('tags').split(','):
|
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
|
extra_update_obj['tags'] = tag_uuids
|
||||||
|
|
||||||
datastore.data['watching'][uuid].update(form.data)
|
datastore.data['watching'][uuid].update(form.data)
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
|
||||||
import flask_login
|
import flask_login
|
||||||
import locale
|
import locale
|
||||||
import os
|
import os
|
||||||
import pytz
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -35,7 +33,8 @@ from loguru import logger
|
|||||||
|
|
||||||
from changedetectionio import __version__
|
from changedetectionio import __version__
|
||||||
from changedetectionio import queuedWatchMetaData
|
from changedetectionio import queuedWatchMetaData
|
||||||
from changedetectionio.api import api_v1
|
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags
|
||||||
|
from changedetectionio.api.Search import Search
|
||||||
from .time_handler import is_within_schedule
|
from .time_handler import is_within_schedule
|
||||||
|
|
||||||
datastore = None
|
datastore = None
|
||||||
@@ -244,34 +243,42 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
# RSS access with token is allowed
|
# RSS access with token is allowed
|
||||||
elif request.endpoint and 'rss.feed' in request.endpoint:
|
elif request.endpoint and 'rss.feed' in request.endpoint:
|
||||||
return None
|
return None
|
||||||
|
# API routes - use their own auth mechanism (@auth.check_token)
|
||||||
|
elif request.path.startswith('/api/'):
|
||||||
|
return None
|
||||||
else:
|
else:
|
||||||
return login_manager.unauthorized()
|
return login_manager.unauthorized()
|
||||||
|
|
||||||
|
|
||||||
watch_api.add_resource(api_v1.WatchSingleHistory,
|
watch_api.add_resource(WatchSingleHistory,
|
||||||
'/api/v1/watch/<string:uuid>/history/<string:timestamp>',
|
'/api/v1/watch/<string:uuid>/history/<string:timestamp>',
|
||||||
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
|
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
|
||||||
|
|
||||||
watch_api.add_resource(api_v1.WatchHistory,
|
watch_api.add_resource(WatchHistory,
|
||||||
'/api/v1/watch/<string:uuid>/history',
|
'/api/v1/watch/<string:uuid>/history',
|
||||||
resource_class_kwargs={'datastore': datastore})
|
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})
|
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
|
||||||
|
|
||||||
watch_api.add_resource(api_v1.Watch, '/api/v1/watch/<string:uuid>',
|
watch_api.add_resource(Watch, '/api/v1/watch/<string:uuid>',
|
||||||
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
|
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})
|
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
|
||||||
|
|
||||||
watch_api.add_resource(api_v1.Import,
|
watch_api.add_resource(Import,
|
||||||
'/api/v1/import',
|
'/api/v1/import',
|
||||||
resource_class_kwargs={'datastore': datastore})
|
resource_class_kwargs={'datastore': datastore})
|
||||||
|
|
||||||
# Setup cors headers to allow all domains
|
watch_api.add_resource(Tags, '/api/v1/tags',
|
||||||
# https://flask-cors.readthedocs.io/en/latest/
|
resource_class_kwargs={'datastore': datastore})
|
||||||
# CORS(app)
|
|
||||||
|
watch_api.add_resource(Tag, '/api/v1/tag', '/api/v1/tag/<string:uuid>',
|
||||||
|
resource_class_kwargs={'datastore': datastore})
|
||||||
|
|
||||||
|
watch_api.add_resource(Search, '/api/v1/search',
|
||||||
|
resource_class_kwargs={'datastore': datastore})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -571,16 +571,16 @@ class ChangeDetectionStore:
|
|||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def add_tag(self, name):
|
def add_tag(self, title):
|
||||||
# If name exists, return that
|
# If name exists, return that
|
||||||
n = name.strip().lower()
|
n = title.strip().lower()
|
||||||
logger.debug(f">>> Adding new tag - '{n}'")
|
logger.debug(f">>> Adding new tag - '{n}'")
|
||||||
if not n:
|
if not n:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for uuid, tag in self.__data['settings']['application'].get('tags', {}).items():
|
for uuid, tag in self.__data['settings']['application'].get('tags', {}).items():
|
||||||
if n == tag.get('title', '').lower().strip():
|
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
|
return uuid
|
||||||
|
|
||||||
# Eventually almost everything todo with a watch will apply as a Tag
|
# Eventually almost everything todo with a watch will apply as a Tag
|
||||||
@@ -588,7 +588,7 @@ class ChangeDetectionStore:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
from .model import Tag
|
from .model import Tag
|
||||||
new_tag = Tag.model(datastore_path=self.datastore_path, default={
|
new_tag = Tag.model(datastore_path=self.datastore_path, default={
|
||||||
'title': name.strip(),
|
'title': title.strip(),
|
||||||
'date_created': int(time.time())
|
'date_created': int(time.time())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -632,6 +632,41 @@ class ChangeDetectionStore:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def search_watches_for_url(self, query, tag_limit=None, partial=False):
|
||||||
|
"""Search watches by URL, title, or error messages
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query (str): Search term to match against watch URLs, titles, and error messages
|
||||||
|
tag_limit (str, optional): Optional tag name to limit search results
|
||||||
|
partial: (bool, optional): sub-string matching
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of UUIDs of watches that match the search criteria
|
||||||
|
"""
|
||||||
|
matching_uuids = []
|
||||||
|
query = query.lower().strip()
|
||||||
|
tag = self.tag_exists_by_name(tag_limit) if tag_limit else False
|
||||||
|
|
||||||
|
for uuid, watch in self.data['watching'].items():
|
||||||
|
# Filter by tag if requested
|
||||||
|
if tag_limit:
|
||||||
|
if not tag.get('uuid') in watch.get('tags', []):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Search in URL, title, or error messages
|
||||||
|
if partial:
|
||||||
|
if ((watch.get('title') and query in watch.get('title').lower()) or
|
||||||
|
query in watch.get('url', '').lower() or
|
||||||
|
(watch.get('last_error') and query in watch.get('last_error').lower())):
|
||||||
|
matching_uuids.append(uuid)
|
||||||
|
else:
|
||||||
|
if ((watch.get('title') and query == watch.get('title').lower()) or
|
||||||
|
query == watch.get('url', '').lower() or
|
||||||
|
(watch.get('last_error') and query == watch.get('last_error').lower())):
|
||||||
|
matching_uuids.append(uuid)
|
||||||
|
|
||||||
|
return matching_uuids
|
||||||
|
|
||||||
def get_unique_notification_tokens_available(self):
|
def get_unique_notification_tokens_available(self):
|
||||||
# Ask each type of watch if they have any extra notification token to add to the validation
|
# Ask each type of watch if they have any extra notification token to add to the validation
|
||||||
extra_notification_tokens = {}
|
extra_notification_tokens = {}
|
||||||
@@ -847,7 +882,7 @@ class ChangeDetectionStore:
|
|||||||
if tag:
|
if tag:
|
||||||
tag_uuids = []
|
tag_uuids = []
|
||||||
for t in tag.split(','):
|
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
|
self.data['watching'][uuid]['tags'] = tag_uuids
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from .util import live_server_setup, extract_api_key_from_UI, wait_for_all_checks
|
from .util import live_server_setup, wait_for_all_checks
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
@@ -57,16 +57,15 @@ def test_setup(client, live_server, measure_memory_usage):
|
|||||||
|
|
||||||
|
|
||||||
def test_api_simple(client, live_server, measure_memory_usage):
|
def test_api_simple(client, live_server, measure_memory_usage):
|
||||||
# live_server_setup(live_server)
|
#live_server_setup(live_server)
|
||||||
|
|
||||||
api_key = extract_api_key_from_UI(client)
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
|
|
||||||
# Create a watch
|
# Create a watch
|
||||||
set_original_response()
|
set_original_response()
|
||||||
|
|
||||||
# Validate bad URL
|
# Validate bad URL
|
||||||
test_url = url_for('test_endpoint', _external=True,
|
test_url = url_for('test_endpoint', _external=True )
|
||||||
headers={'x-api-key': api_key}, )
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("createwatch"),
|
url_for("createwatch"),
|
||||||
data=json.dumps({"url": "h://xxxxxxxxxom"}),
|
data=json.dumps({"url": "h://xxxxxxxxxom"}),
|
||||||
@@ -293,12 +292,11 @@ def test_access_denied(client, live_server, measure_memory_usage):
|
|||||||
def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
|
def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
|
||||||
|
|
||||||
#live_server_setup(live_server)
|
#live_server_setup(live_server)
|
||||||
api_key = extract_api_key_from_UI(client)
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
|
|
||||||
# Create a watch
|
# Create a watch
|
||||||
set_original_response()
|
set_original_response()
|
||||||
test_url = url_for('test_endpoint', _external=True,
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
headers={'x-api-key': api_key}, )
|
|
||||||
|
|
||||||
# Create new
|
# Create new
|
||||||
res = client.post(
|
res = client.post(
|
||||||
@@ -374,7 +372,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
|
|||||||
|
|
||||||
def test_api_import(client, live_server, measure_memory_usage):
|
def test_api_import(client, live_server, measure_memory_usage):
|
||||||
#live_server_setup(live_server)
|
#live_server_setup(live_server)
|
||||||
api_key = extract_api_key_from_UI(client)
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("import") + "?tag=import-test",
|
url_for("import") + "?tag=import-test",
|
||||||
@@ -393,3 +391,47 @@ def test_api_import(client, live_server, measure_memory_usage):
|
|||||||
res = client.get(url_for('tags.tags_overview_page'))
|
res = client.get(url_for('tags.tags_overview_page'))
|
||||||
assert b'import-test' in res.data
|
assert b'import-test' in res.data
|
||||||
|
|
||||||
|
def test_api_conflict_UI_password(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')
|
||||||
|
|
||||||
|
# Enable password check and diff page access bypass
|
||||||
|
res = client.post(
|
||||||
|
url_for("settings.settings_page"),
|
||||||
|
data={"application-password": "foobar", # password is now set! API should still work!
|
||||||
|
"application-api_access_token_enabled": "y",
|
||||||
|
"requests-time_between_check-minutes": 180,
|
||||||
|
'application-fetch_backend': "html_requests"},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert b"Password protection enabled." in res.data
|
||||||
|
|
||||||
|
# Create a watch
|
||||||
|
set_original_response()
|
||||||
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
|
|
||||||
|
# Create new
|
||||||
|
res = client.post(
|
||||||
|
url_for("createwatch"),
|
||||||
|
data=json.dumps({"url": test_url, "title": "My test URL" }),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
wait_for_all_checks(client)
|
||||||
|
url = url_for("createwatch")
|
||||||
|
# Get a listing, it will be the first one
|
||||||
|
res = client.get(
|
||||||
|
url,
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
assert len(res.json)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
101
changedetectionio/tests/test_api_search.py
Normal file
101
changedetectionio/tests/test_api_search.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from copy import copy
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from .util import live_server_setup, wait_for_all_checks
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_search(client, live_server):
|
||||||
|
live_server_setup(live_server)
|
||||||
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
|
|
||||||
|
watch_data = {}
|
||||||
|
# Add some test watches
|
||||||
|
urls = [
|
||||||
|
'https://example.com/page1',
|
||||||
|
'https://example.org/testing',
|
||||||
|
'https://test-site.com/example'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Import the test URLs
|
||||||
|
res = client.post(
|
||||||
|
url_for("imports.import_page"),
|
||||||
|
data={"urls": "\r\n".join(urls)},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
assert b"3 Imported" in res.data
|
||||||
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
|
# Get a listing, it will be the first one
|
||||||
|
watches_response = client.get(
|
||||||
|
url_for("createwatch"),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Add a title to one watch for title search testing
|
||||||
|
for uuid, watch in watches_response.json.items():
|
||||||
|
|
||||||
|
watch_data = client.get(url_for("watch", uuid=uuid),
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
|
||||||
|
if urls[0] == watch_data.json['url']:
|
||||||
|
# HTTP PUT ( UPDATE an existing watch )
|
||||||
|
client.put(
|
||||||
|
url_for("watch", uuid=uuid),
|
||||||
|
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||||
|
data=json.dumps({'title': 'Example Title Test'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test search by URL
|
||||||
|
res = client.get(url_for("search")+"?q=https://example.com/page1", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert len(res.json) == 1
|
||||||
|
assert list(res.json.values())[0]['url'] == urls[0]
|
||||||
|
|
||||||
|
# Test search by URL - partial should NOT match without ?partial=true flag
|
||||||
|
res = client.get(url_for("search")+"?q=https://example", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert len(res.json) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# Test search by title
|
||||||
|
res = client.get(url_for("search")+"?q=Example Title Test", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert len(res.json) == 1
|
||||||
|
assert list(res.json.values())[0]['url'] == urls[0]
|
||||||
|
assert list(res.json.values())[0]['title'] == 'Example Title Test'
|
||||||
|
|
||||||
|
# Test search that should return multiple results (partial = true)
|
||||||
|
res = client.get(url_for("search")+"?q=https://example&partial=true", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert len(res.json) == 2
|
||||||
|
|
||||||
|
# Test empty search
|
||||||
|
res = client.get(url_for("search")+"?q=", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
# Add a tag to test search with tag filter
|
||||||
|
tag_name = 'test-tag'
|
||||||
|
res = client.post(
|
||||||
|
url_for("tag"),
|
||||||
|
data=json.dumps({"title": tag_name}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
tag_uuid = res.json['uuid']
|
||||||
|
|
||||||
|
# Add the tag to one watch
|
||||||
|
for uuid, watch in watches_response.json.items():
|
||||||
|
if urls[2] == watch['url']:
|
||||||
|
client.put(
|
||||||
|
url_for("watch", uuid=uuid),
|
||||||
|
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||||
|
data=json.dumps({'tags': [tag_uuid]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Test search with tag filter and q
|
||||||
|
res = client.get(url_for("search") + f"?q={urls[2]}&tag={tag_name}", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert len(res.json) == 1
|
||||||
|
assert list(res.json.values())[0]['url'] == urls[2]
|
||||||
|
|
||||||
143
changedetectionio/tests/test_api_tags.py
Normal file
143
changedetectionio/tests/test_api_tags.py
Normal file
@@ -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
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from .util import live_server_setup, extract_UUID_from_client, extract_api_key_from_UI, wait_for_all_checks
|
from .util import live_server_setup, extract_UUID_from_client, wait_for_all_checks
|
||||||
|
|
||||||
|
|
||||||
def set_response_with_ldjson():
|
def set_response_with_ldjson():
|
||||||
@@ -110,7 +110,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage
|
|||||||
assert b'tracking-ldjson-price-data' in res.data
|
assert b'tracking-ldjson-price-data' in res.data
|
||||||
|
|
||||||
# and last snapshop (via API) should be just the price
|
# and last snapshop (via API) should be just the price
|
||||||
api_key = extract_api_key_from_UI(client)
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
res = client.get(
|
res = client.get(
|
||||||
url_for("watchsinglehistory", uuid=uuid, timestamp='latest'),
|
url_for("watchsinglehistory", uuid=uuid, timestamp='latest'),
|
||||||
headers={'x-api-key': api_key},
|
headers={'x-api-key': api_key},
|
||||||
|
|||||||
@@ -95,20 +95,6 @@ def wait_for_notification_endpoint_output():
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# kinda funky, but works for now
|
|
||||||
def extract_api_key_from_UI(client):
|
|
||||||
import re
|
|
||||||
res = client.get(
|
|
||||||
url_for("settings.settings_page"),
|
|
||||||
)
|
|
||||||
# <span id="api-key">{{api_key}}</span>
|
|
||||||
|
|
||||||
m = re.search('<span id="api-key">(.+?)</span>', str(res.data))
|
|
||||||
api_key = m.group(1)
|
|
||||||
return api_key.strip()
|
|
||||||
|
|
||||||
|
|
||||||
# kinda funky, but works for now
|
# kinda funky, but works for now
|
||||||
def get_UUID_for_tag_name(client, name):
|
def get_UUID_for_tag_name(client, name):
|
||||||
app_config = client.application.config.get('DATASTORE').data
|
app_config = client.application.config.get('DATASTORE').data
|
||||||
|
|||||||
Reference in New Issue
Block a user