mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-11 12:07:08 +00:00
Compare commits
2 Commits
API-add-se
...
3045-api-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed584b38bf | ||
|
|
46d11f3d70 |
@@ -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.7'
|
__version__ = '0.49.5'
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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
|
|
||||||
|
|||||||
@@ -9,9 +9,20 @@ import validators
|
|||||||
from . import auth
|
from . import auth
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
# Import schemas from __init__.py
|
# See docs/README.md for rebuilding the docs/apidoc information
|
||||||
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):
|
||||||
@@ -295,3 +306,109 @@ 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
|
||||||
@@ -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(title=op_extradata)
|
tag_uuid = datastore.add_tag(name=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(title=t))
|
tag_uuids.append(datastore.add_tag(name=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,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
|
||||||
import flask_login
|
import flask_login
|
||||||
import locale
|
import locale
|
||||||
import os
|
import os
|
||||||
@@ -33,8 +34,7 @@ from loguru import logger
|
|||||||
|
|
||||||
from changedetectionio import __version__
|
from changedetectionio import __version__
|
||||||
from changedetectionio import queuedWatchMetaData
|
from changedetectionio import queuedWatchMetaData
|
||||||
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags
|
from changedetectionio.api import api_v1
|
||||||
from changedetectionio.api.Search import Search
|
|
||||||
from .time_handler import is_within_schedule
|
from .time_handler import is_within_schedule
|
||||||
|
|
||||||
datastore = None
|
datastore = None
|
||||||
@@ -250,35 +250,30 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
return login_manager.unauthorized()
|
return login_manager.unauthorized()
|
||||||
|
|
||||||
|
|
||||||
watch_api.add_resource(WatchSingleHistory,
|
watch_api.add_resource(api_v1.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(WatchHistory,
|
watch_api.add_resource(api_v1.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(CreateWatch, '/api/v1/watch',
|
watch_api.add_resource(api_v1.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(Watch, '/api/v1/watch/<string:uuid>',
|
watch_api.add_resource(api_v1.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(SystemInfo, '/api/v1/systeminfo',
|
watch_api.add_resource(api_v1.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(Import,
|
watch_api.add_resource(api_v1.Import,
|
||||||
'/api/v1/import',
|
'/api/v1/import',
|
||||||
resource_class_kwargs={'datastore': datastore})
|
resource_class_kwargs={'datastore': datastore})
|
||||||
|
|
||||||
watch_api.add_resource(Tags, '/api/v1/tags',
|
# Setup cors headers to allow all domains
|
||||||
resource_class_kwargs={'datastore': datastore})
|
# https://flask-cors.readthedocs.io/en/latest/
|
||||||
|
# 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, title):
|
def add_tag(self, name):
|
||||||
# If name exists, return that
|
# If name exists, return that
|
||||||
n = title.strip().lower()
|
n = name.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 '{title}' already exists, skipping creation.")
|
logger.warning(f"Tag '{name}' 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': title.strip(),
|
'title': name.strip(),
|
||||||
'date_created': int(time.time())
|
'date_created': int(time.time())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -632,41 +632,6 @@ 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 = {}
|
||||||
@@ -882,7 +847,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(title=t))
|
tag_uuids.append(self.add_tag(name=t))
|
||||||
|
|
||||||
self.data['watching'][uuid]['tags'] = tag_uuids
|
self.data['watching'][uuid]['tags'] = tag_uuids
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
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]
|
|
||||||
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
#!/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
|
|
||||||
Reference in New Issue
Block a user