diff --git a/.dockerignore b/.dockerignore index ea273f0..f90f8f0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,5 @@ # Byte-compiled / optimized / DLL files -__pycache__/ +**/__pycache__/ *.py[cod] *$py.class @@ -129,14 +129,15 @@ dmypy.json .pyre/ # Database -*.db +**/*.db +**/*.db-shm +**/*.db-wal # VS code *.code-workspace .vscode/ # Docker -Dockerfile .dockerignore docker-compose.yml @@ -146,8 +147,15 @@ docker-compose.yml .github/ # Various files -*.md +**/*.md LICENSE # Tests tests/ + +# Project management files +release.sh +docs/ +docs-requirements.txt +mkdocs.yml +generate_api_docs.py diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml new file mode 100644 index 0000000..97c59b8 --- /dev/null +++ b/.github/workflows/build_docs.yml @@ -0,0 +1,25 @@ +name: Update and build docs + +on: + push: + branches: + - Development + +jobs: + update_build: + name: Update API docs and build docs + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3 + name: Setup checkout + - uses: actions/setup-python@v4 + name: Setup python 3.8 + with: + python-version: 3.8 + cache: 'pip' + - run: pip install -r requirements.txt -r project_management/docs-requirements.txt + name: Install dependencies + - run: python3 project_management/generate_api_docs.py + name: Generate API docs + - run: mkdocs gh-deploy --force -f project_management/mkdocs.yml + name: Build docs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9b20e4d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + push: + branches: + - Development + +jobs: + test: + runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + name: Set up checkout + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 + name: Set up Python ${{ matrix.python-version }} + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - run: pip install -r requirements.txt + name: Install dependencies + + - run: | + mkdir db + python -m unittest discover -s ./tests -p '*.py' + name: Run Tests diff --git a/.gitignore b/.gitignore index 585383e..033e14a 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,11 @@ dmypy.json # Database *.db +*.db-shm +*.db-wal # VS code *.code-workspace + +# Project management files +release.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 434ded3..e4d4fb7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to MIND ## General steps -Contributing to MIND consists of 5 steps, listed hereunder. +Contributing to MIND consists of 5 steps: 1. Make a [contributing request](https://github.com/Casvt/MIND/issues/new?template=contribute-request.md), where you describe what you plan on doing. This request needs to get approved before you can start, or your pull request won't be accepted. This is to avoid multiple people from doing the same thing and to avoid you wasting your time if we do not wish the changes. This is also where discussions can be held about how something will be implemented. 2. When the request is accepted, start your local development (more info about this below). @@ -11,24 +11,25 @@ Contributing to MIND consists of 5 steps, listed hereunder. ## Local development steps Once your request is accepted, you can start your local development. -1. Clone the repository onto your computer and open it using your prefered IDE (Visual Studio Code is used by us). +1. Fork the repository and clone the fork onto your computer and open it using your preferred IDE (Visual Studio Code is used by us). 2. Make the changes needed and write accompanying tests. 3. Check if the code written follows the styling guide below. -4. Run the finished version, using python 3.7, to check if you've made any errors. -5. Run the tests (unittest is used). This can be done with a button click within VS Code, or with the following command where you need to be inside the root folder of the project: +4. If you want to run the tests manually before committing, use the command below in the root folder of the project: ```bash python3 -m unittest discover -v -s './tests' -p '*_test.py' ``` -6. Test your version thoroughly to catch as many bugs as possible (if any). +5. Update the docs if needed. +6. Commit and push to your fork. When you push, GitHub Actions will do a lot of work for you: the tests are run again on python versions 3.8 - 3.11, the API documentation is updated if any changes have been made to the API and the docs are updated if any changes have been made to the docs. All GitHub Actions need to succeed before you're allowed to make a PR (you'll see a green checkmark next to the commit in GitHub). ## Styling guide The code of MIND is written in such way that it follows the following rules. Your code should too. -1. Compatible with python 3.7 . +1. Compatible with python 3.8 . 2. Tabs (4 space size) are used for indentation. 3. Use type hints as much as possible, though don't if it requires importing extra functions or classes (except for the `typing` library). 4. Each function in the backend needs a doc string describing the function, what the inputs are, what errors could be raised from within the function and what the output is. -5. Each function in the `api.py` file should have a doc string describing the api endpoint. For the formatting, look at the doc strings of the other functions in the file. -6. The imports need to be sorted (the extension `isort` is used in VS Code). -7. The code needs to be compatible with Linux, MacOS and Windows. -8. The code should, though not strictly enforced, reasonably comply with the rule of 80 characters per line. +5. The imports need to be sorted (the extension `isort` is used in VS Code). +6. The code needs to be compatible with Linux, MacOS, Windows and Docker. +7. The code should, though not strictly enforced, reasonably comply with the rule of 80 characters per line. + +If you just code in the same style as the current code, you'll follow most of these rules automatically. diff --git a/Dockerfile b/Dockerfile index f699d48..d21e6eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1 FROM python:3.8-slim-buster +STOPSIGNAL SIGINT WORKDIR /app diff --git a/MIND.py b/MIND.py index 646f244..7e7879a 100644 --- a/MIND.py +++ b/MIND.py @@ -11,16 +11,23 @@ from flask import Flask, render_template, request from waitress.server import create_server from werkzeug.middleware.dispatcher import DispatcherMiddleware -from backend.db import DBConnection, close_db, setup_db -from frontend.api import api, reminder_handler +from backend.db import DBConnection, ThreadedTaskDispatcher, close_db, setup_db +from frontend.api import api, api_prefix, reminder_handler from frontend.ui import ui HOST = '0.0.0.0' PORT = '8080' URL_PREFIX = '' # Must either be empty or start with '/' e.g. '/mind' +LOGGING_LEVEL = logging.INFO THREADS = 10 DB_FILENAME = 'db', 'MIND.db' +logging.basicConfig( + level=LOGGING_LEVEL, + format='[%(asctime)s][%(threadName)s][%(levelname)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + def _folder_path(*folders) -> str: """Turn filepaths relative to the project folder into absolute paths Returns: @@ -32,7 +39,7 @@ def _create_app() -> Flask: """Create a Flask app instance Returns: Flask: The created app instance - """ + """ app = Flask( __name__, template_folder=_folder_path('frontend','templates'), @@ -65,7 +72,7 @@ def _create_app() -> Flask: return render_template('page_not_found.html', url_prefix=logging.URL_PREFIX) app.register_blueprint(ui) - app.register_blueprint(api, url_prefix="/api") + app.register_blueprint(api, url_prefix=api_prefix) # Setup closing database app.teardown_appcontext(close_db) @@ -79,7 +86,7 @@ def MIND() -> None: """ # Check python version if (version_info.major < 3) or (version_info.major == 3 and version_info.minor < 7): - print('Error: the minimum python version required is python3.7 (currently ' + version_info.major + '.' + version_info.minor + '.' + version_info.micro + ')') + logging.error('Error: the minimum python version required is python3.7 (currently ' + version_info.major + '.' + version_info.minor + '.' + version_info.micro + ')') exit(1) # Register web server @@ -90,18 +97,22 @@ def MIND() -> None: app = _create_app() with app.app_context(): if isfile(_folder_path('db', 'Noted.db')): - move(_folder_path('db', 'Noted.db'), _folder_path('db', 'MIND.db')) + move(_folder_path('db', 'Noted.db'), _folder_path(*DB_FILENAME)) + db_location = _folder_path(*DB_FILENAME) + logging.debug(f'Database location: {db_location}') makedirs(dirname(db_location), exist_ok=True) + DBConnection.file = db_location setup_db() reminder_handler.find_next_reminder() # Create waitress server and run - server = create_server(app, host=HOST, port=PORT, threads=THREADS) - print(f'MIND running on http://{HOST}:{PORT}{URL_PREFIX}') + dispatcher = ThreadedTaskDispatcher() + dispatcher.set_thread_count(THREADS) + server = create_server(app, _dispatcher=dispatcher, host=HOST, port=PORT, threads=THREADS) + logging.info(f'MIND running on http://{HOST}:{PORT}{URL_PREFIX}') server.run() - print(f'\nShutting down MIND...') # Stopping thread reminder_handler.stop_handling() diff --git a/README.md b/README.md index 9e2d708..fc59d77 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,28 @@ -# MIND Reminders -A simple self hosted reminder platform that uses push to send notifications to your device. Set the reminder and forget about it! 📢 +# MIND + +[![Docker Pulls](https://img.shields.io/docker/pulls/mrcas/mind.svg)](https://hub.docker.com/r/mrcas/mind) +[![GitHub Downloads](https://img.shields.io/github/downloads/Casvt/MIND/total.svg)](https://github.com/Casvt/MIND/releases) + +__A simple self hosted reminder application that can send push notifications to your device. Set the reminder and forget about it!__ Mind is a simple self hosted application for creating reminders that get pushed to your device using the [Apprise](https://github.com/caronc/apprise) API. You can send messages to just about every platform, including scheduled emails! -## Screenshots -![mind-reminders-home](https://user-images.githubusercontent.com/57927413/213593220-495aeb86-2bf8-4c43-895d-c7cba38c3cee.png) +## Workings +MIND can be used for sending notifications at the desired time. This can be a set time, like a yearly reminder for a birthday, or at a button click, to easily send a predefined notification when you want to. -![mind-reminders-add-notification-services](https://user-images.githubusercontent.com/57927413/212755314-1104531e-7feb-4e59-af1d-927576e47152.png) +## Features +- Works cross-timezone +- Notifications are sent with second-precision +- Uses the [apprise library](https://github.com/caronc/apprise), giving you 80+ platforms to send notifications to +- Easily manage the reminders with sorting options, search ability and color coding +- Docker image available +- Mobile friendly web-interface +- API available -![mind-reminders-edit](https://user-images.githubusercontent.com/57927413/213594471-ecc99a72-cf0f-4570-8e78-92ffbf37e59d.png) +### Planned Features +You can see the planned features in the [Project board](https://github.com/users/Casvt/projects/3). -![mind-reminders-settings](https://user-images.githubusercontent.com/57927413/212755327-b45da53c-72f7-480c-9a77-eaad28803fbb.png) - -## Core Features -* Basic auth -* Utilizes Apprise -* Create, edit and delete reminders -* Schedule reminders -* Recurring reminders -* Docker image -* Mobile friendly - -## Planned Features -You can see our planned features in our [Project board](https://github.com/users/Casvt/projects/3). - -## Installation +## Getting started Replace the timezone value (`TZ=`) to the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) of your timezone! ```bash docker run -d \ @@ -34,13 +32,9 @@ docker run -d \ -p 8080:8080 \ mrcas/mind:latest ``` -## Getting Started -- Create a new account -- Click the bell icon on the left side to add an Apprise push option and save it (Here is an example using Pushover) -![mind-reminders-notification-service](https://user-images.githubusercontent.com/57927413/213593832-6c62307c-cf7c-4d11-b6ce-dea33676d477.png) +More information about installing can be found [in the wiki](https://casvt.github.io/MIND/). - -- Click the home icon and create a reminder! - -You can see the [wiki](https://github.com/Casvt/MIND/wiki) for instructions on how to install MIND on other OS'es. +## Contact +- For support, a [discord server](https://discord.gg/nMNdgG7vsE) is available +- Alternatively, [make an issue](https://github.com/Casvt/MIND/issues) diff --git a/backend/custom_exceptions.py b/backend/custom_exceptions.py index 6059ce2..feee8f8 100644 --- a/backend/custom_exceptions.py +++ b/backend/custom_exceptions.py @@ -1,33 +1,39 @@ #-*- coding: utf-8 -*- +import logging from typing import Any, Dict +class CustomException(Exception): + def __init__(self, e=None) -> None: + logging.warning(self.__doc__) + super().__init__(e) + return -class UsernameTaken(Exception): +class UsernameTaken(CustomException): """The username is already taken""" api_response = {'error': 'UsernameTaken', 'result': {}, 'code': 400} -class UsernameInvalid(Exception): +class UsernameInvalid(CustomException): """The username contains invalid characters""" api_response = {'error': 'UsernameInvalid', 'result': {}, 'code': 400} -class UserNotFound(Exception): - """The user requested by id or username can not be found""" +class UserNotFound(CustomException): + """The user requested can not be found""" api_response = {'error': 'UserNotFound', 'result': {}, 'code': 404} -class AccessUnauthorized(Exception): +class AccessUnauthorized(CustomException): """The password given is not correct""" api_response = {'error': 'AccessUnauthorized', 'result': {}, 'code': 401} -class ReminderNotFound(Exception): +class ReminderNotFound(CustomException): """The reminder with the id can not be found""" api_response = {'error': 'ReminderNotFound', 'result': {}, 'code': 404} -class NotificationServiceNotFound(Exception): +class NotificationServiceNotFound(CustomException): """The notification service was not found""" api_response = {'error': 'NotificationServiceNotFound', 'result': {}, 'code': 404} -class NotificationServiceInUse(Exception): +class NotificationServiceInUse(CustomException): """The notification service is wished to be deleted but a reminder is still using it""" def __init__(self, type: str=''): self.type = type @@ -37,15 +43,11 @@ class NotificationServiceInUse(Exception): def api_response(self) -> Dict[str, Any]: return {'error': 'NotificationServiceInUse', 'result': {'type': self.type}, 'code': 400} -class InvalidTime(Exception): +class InvalidTime(CustomException): """The time given is in the past""" api_response = {'error': 'InvalidTime', 'result': {}, 'code': 400} -class InvalidURL(Exception): - """The apprise url is invalid""" - api_response = {'error': 'InvalidURL', 'result': {}, 'code': 400} - -class KeyNotFound(Exception): +class KeyNotFound(CustomException): """A key was not found in the input that is required to be given""" def __init__(self, key: str=''): self.key = key @@ -55,7 +57,7 @@ class KeyNotFound(Exception): def api_response(self) -> Dict[str, Any]: return {'error': 'KeyNotFound', 'result': {'key': self.key}, 'code': 400} -class InvalidKeyValue(Exception): +class InvalidKeyValue(CustomException): """The value of a key is invalid""" def __init__(self, key: str='', value: str=''): self.key = key @@ -66,6 +68,20 @@ class InvalidKeyValue(Exception): def api_response(self) -> Dict[str, Any]: return {'error': 'InvalidKeyValue', 'result': {'key': self.key, 'value': self.value}, 'code': 400} -class TemplateNotFound(Exception): +class TemplateNotFound(CustomException): """The template was not found""" api_response = {'error': 'TemplateNotFound', 'result': {}, 'code': 404} + +class APIKeyInvalid(CustomException): + """The API key is not correct""" + api_response = {'error': 'APIKeyInvalid', 'result': {}, 'code': 401} + + def __init__(self, e=None) -> None: + return + +class APIKeyExpired(CustomException): + """The API key has expired""" + api_response = {'error': 'APIKeyExpired', 'result': {}, 'code': 401} + + def __init__(self, e=None) -> None: + return diff --git a/backend/db.py b/backend/db.py index fed13b5..43bf7a6 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,30 +1,58 @@ #-*- coding: utf-8 -*- from datetime import datetime -from sqlite3 import Connection, Row -from threading import current_thread +import logging +from sqlite3 import Connection, ProgrammingError, Row +from threading import current_thread, main_thread from time import time from typing import Union from flask import g +from waitress.task import ThreadedTaskDispatcher as OldThreadedTaskDispatcher -__DATABASE_VERSION__ = 5 +from backend.custom_exceptions import AccessUnauthorized, UserNotFound + +__DATABASE_VERSION__ = 6 class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): i = f'{cls}{current_thread()}' - if i not in cls._instances: + if (i not in cls._instances + or cls._instances[i].closed): + logging.debug(f'Creating singleton instance: {i}') cls._instances[i] = super(Singleton, cls).__call__(*args, **kwargs) return cls._instances[i] +class ThreadedTaskDispatcher(OldThreadedTaskDispatcher): + def handler_thread(self, thread_no: int) -> None: + super().handler_thread(thread_no) + i = f'{DBConnection}{current_thread()}' + if i in Singleton._instances and not Singleton._instances[i].closed: + logging.debug(f'Closing singleton instance: {i}') + Singleton._instances[i].close() + + def shutdown(self, cancel_pending: bool = True, timeout: int = 5) -> bool: + print() + logging.info('Shutting down MIND...') + super().shutdown(cancel_pending, timeout) + DBConnection(20.0).close() + class DBConnection(Connection, metaclass=Singleton): file = '' def __init__(self, timeout: float) -> None: + logging.debug(f'Opening database connection for {current_thread()}') super().__init__(self.file, timeout=timeout) super().cursor().execute("PRAGMA foreign_keys = ON;") + self.closed = False + return + + def close(self) -> None: + logging.debug(f'Closing database connection for {current_thread()}') + self.closed = True + super().close() return def get_db(output_type: Union[dict, tuple]=tuple): @@ -54,11 +82,13 @@ def close_db(e=None) -> None: """ try: cursor = g.cursor - db = cursor.connection + db: DBConnection = cursor.connection cursor.close() delattr(g, 'cursor') db.commit() - except AttributeError: + if current_thread() is main_thread(): + db.close() + except (AttributeError, ProgrammingError): pass return @@ -67,7 +97,7 @@ def migrate_db(current_db_version: int) -> None: Migrate a MIND database from it's current version to the newest version supported by the MIND version installed. """ - print('Migrating database to newer version...') + logging.info('Migrating database to newer version...') cursor = get_db() if current_db_version == 1: # V1 -> V2 @@ -168,13 +198,22 @@ def migrate_db(current_db_version: int) -> None: COMMIT; """) current_db_version = 5 - + + if current_db_version == 5: + # V5 -> V6 + from backend.users import User + try: + User('User1', 'Password1').delete() + except (UserNotFound, AccessUnauthorized): + pass + return def setup_db() -> None: """Setup the database """ cursor = get_db() + cursor.execute("PRAGMA journal_mode = wal;") cursor.executescript(""" CREATE TABLE IF NOT EXISTS users( @@ -256,6 +295,7 @@ def setup_db() -> None: "SELECT value FROM config WHERE key = 'database_version' LIMIT 1;" ).fetchone()[0]) + logging.debug(f'Current database version {current_db_version} and desired database version {__DATABASE_VERSION__}') if current_db_version < __DATABASE_VERSION__: migrate_db(current_db_version) cursor.execute( diff --git a/backend/notification_service.py b/backend/notification_service.py index 6c3ea78..12f04d3 100644 --- a/backend/notification_service.py +++ b/backend/notification_service.py @@ -1,21 +1,20 @@ #-*- coding: utf-8 -*- +import logging from typing import List -from apprise import Apprise - -from backend.custom_exceptions import (InvalidURL, NotificationServiceInUse, +from backend.custom_exceptions import (NotificationServiceInUse, NotificationServiceNotFound) from backend.db import get_db class NotificationService: - def __init__(self, notification_service_id: int) -> None: + def __init__(self, user_id: int, notification_service_id: int) -> None: self.id = notification_service_id if not get_db().execute( - "SELECT 1 FROM notification_services WHERE id = ? LIMIT 1;", - (self.id,) + "SELECT 1 FROM notification_services WHERE id = ? AND user_id = ? LIMIT 1;", + (self.id, user_id) ).fetchone(): raise NotificationServiceNotFound @@ -46,9 +45,8 @@ class NotificationService: Returns: dict: The new info about the service """ - if not Apprise().add(url): - raise InvalidURL - + logging.info(f'Updating notification service {self.id}: {title=}, {url=}') + # Get current data and update it with new values data = self.get() new_values = { @@ -79,7 +77,9 @@ class NotificationService: Raises: NotificationServiceInUse: The service is still used by a reminder - """ + """ + logging.info(f'Deleting notification service {self.id}') + # Check if no reminders exist with this service cursor = get_db() cursor.execute(""" @@ -157,7 +157,7 @@ class NotificationServices: Returns: NotificationService: Instance of NotificationService """ - return NotificationService(notification_service_id) + return NotificationService(self.user_id, notification_service_id) def add(self, title: str, url: str) -> NotificationService: """Add a notification service @@ -166,15 +166,11 @@ class NotificationServices: title (str): The title of the service url (str): The apprise url of the service - Raises: - InvalidURL: The apprise url is invalid - Returns: dict: The info about the new service - """ - if not Apprise().add(url): - raise InvalidURL - + """ + logging.info(f'Adding notification service with {title=}, {url=}') + new_id = get_db().execute(""" INSERT INTO notification_services(user_id, title, url) VALUES (?,?,?) diff --git a/backend/reminders.py b/backend/reminders.py index 7724f17..4c0fb69 100644 --- a/backend/reminders.py +++ b/backend/reminders.py @@ -1,6 +1,7 @@ #-*- coding: utf-8 -*- from datetime import datetime +import logging from sqlite3 import IntegrityError from threading import Timer from typing import List, Literal @@ -17,7 +18,6 @@ from backend.db import close_db, get_db filter_function = lambda query, p: ( query in p["title"].lower() or query in p["text"].lower() - or query in p["notification_service_title"].lower() ) def _find_next_time( @@ -30,7 +30,11 @@ def _find_next_time( current_time = datetime.fromtimestamp(datetime.utcnow().timestamp()) while new_time <= current_time: new_time += td - return int(new_time.timestamp()) + result = int(new_time.timestamp()) + logging.debug( + f'{original_time=}, {current_time=} and interval of {repeat_interval} {repeat_quantity} leads to {result}' + ) + return result class ReminderHandler: """Handle set reminders @@ -83,6 +87,7 @@ class ReminderHandler: "DELETE FROM reminders WHERE id = ?;", (reminder['id'],) ) + logging.info(f'Deleted reminder {reminder["id"]}') else: # Set next time new_time = _find_next_time( @@ -124,8 +129,14 @@ class ReminderHandler: or time < self.next_trigger['time']): if self.next_trigger['thread'] is not None: self.next_trigger['thread'].cancel() + t = time - datetime.utcnow().timestamp() - self.next_trigger['thread'] = Timer(t, self.__trigger_reminders, (time,)) + self.next_trigger['thread'] = Timer( + t, + self.__trigger_reminders, + (time,) + ) + self.next_trigger['thread'].name = "ReminderHandler" self.next_trigger['thread'].start() self.next_trigger['time'] = time @@ -143,13 +154,13 @@ reminder_handler = ReminderHandler(handler_context.app_context) class Reminder: """Represents a reminder """ - def __init__(self, reminder_id: int): + def __init__(self, user_id: int, reminder_id: int): self.id = reminder_id # Check if reminder exists if not get_db().execute( - "SELECT 1 FROM reminders WHERE id = ? LIMIT 1", - (self.id,) + "SELECT 1 FROM reminders WHERE id = ? AND user_id = ? LIMIT 1", + (self.id, user_id) ).fetchone(): raise ReminderNotFound @@ -210,6 +221,10 @@ class Reminder: Returns: dict: The new reminder info """ + logging.info( + f'Updating notification service {self.id}: ' + + f'{title=}, {time=}, {notification_services=}, {text=}, {repeat_quantity=}, {repeat_interval=}, {color=}' + ) cursor = get_db() # Validate data @@ -302,7 +317,8 @@ class Reminder: def delete(self) -> None: """Delete the reminder - """ + """ + logging.info(f'Deleting reminder {self.id}') get_db().execute("DELETE FROM reminders WHERE id = ?", (self.id,)) reminder_handler.find_next_reminder() return @@ -311,24 +327,26 @@ class Reminders: """Represents the reminder library of the user account """ sort_functions = { - 'title': (lambda r: (r['title'], r['time']), False), - 'title_reversed': (lambda r: (r['title'], r['time']), True), - 'time': (lambda r: r['time'], False), - 'time_reversed': (lambda r: r['time'], True) + 'time': (lambda r: (r['time'], r['title'], r['text'], r['color']), False), + 'time_reversed': (lambda r: (r['time'], r['title'], r['text'], r['color']), True), + 'title': (lambda r: (r['title'], r['time'], r['text'], r['color']), False), + 'title_reversed': (lambda r: (r['title'], r['time'], r['text'], r['color']), True), + 'date_added': (lambda r: r['id'], False), + 'date_added_reversed': (lambda r: r['id'], True) } def __init__(self, user_id: int): self.user_id = user_id - def fetchall(self, sort_by: Literal["time", "time_reversed", "title", "title_reversed"] = "time") -> List[dict]: + def fetchall(self, sort_by: Literal["time", "time_reversed", "title", "title_reversed", "date_added", "date_added_reversed"] = "time") -> List[dict]: """Get all reminders Args: - sort_by (Literal["time", "time_reversed", "title", "title_reversed"], optional): How to sort the result. Defaults to "time". + sort_by (Literal["time", "time_reversed", "title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "time". Returns: List[dict]: The id, title, text, time and color of each reminder - """ + """ sort_function = self.sort_functions.get( sort_by, self.sort_functions['time'] @@ -354,11 +372,12 @@ class Reminders: return reminders - def search(self, query: str) -> List[dict]: + def search(self, query: str, sort_by: Literal["time", "time_reversed", "title", "title_reversed", "date_added", "date_added_reversed"] = "time") -> List[dict]: """Search for reminders Args: query (str): The term to search for + sort_by (Literal["time", "time_reversed", "title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "time". Returns: List[dict]: All reminders that match. Similar output to self.fetchall @@ -366,7 +385,7 @@ class Reminders: query = query.lower() reminders = list(filter( lambda p: filter_function(query, p), - self.fetchall() + self.fetchall(sort_by) )) return reminders @@ -379,7 +398,7 @@ class Reminders: Returns: Reminder: A Reminder instance """ - return Reminder(id) + return Reminder(self.user_id, id) def add( self, @@ -408,6 +427,11 @@ class Reminders: Returns: dict: The info about the reminder """ + logging.info( + f'Adding reminder with {title=}, {time=}, {notification_services=}, ' + + f'{text=}, {repeat_quantity=}, {repeat_interval=}, {color=}' + ) + if time < datetime.utcnow().timestamp(): raise InvalidTime time = round(time) @@ -418,6 +442,13 @@ class Reminders: raise InvalidKeyValue('repeat_interval', repeat_interval) cursor = get_db() + for service in notification_services: + if not cursor.execute( + "SELECT 1 FROM notification_services WHERE id = ? AND user_id = ? LIMIT 1;", + (service, self.user_id) + ).fetchone(): + raise NotificationServiceNotFound + if repeat_quantity is None and repeat_interval is None: id = cursor.execute(""" INSERT INTO reminders(user_id, title, text, time, color) @@ -444,27 +475,29 @@ class Reminders: # Return info return self.fetchone(id) -def test_reminder( - title: str, - notification_services: List[int], - text: str = '' -) -> None: - """Test send a reminder draft + def test_reminder( + self, + title: str, + notification_services: List[int], + text: str = '' + ) -> None: + """Test send a reminder draft - Args: - title (str): Title title of the entry - notification_service (int): The id of the notification service to use to send the reminder - text (str, optional): The body of the reminder. Defaults to ''. - """ - a = Apprise() - cursor = get_db(dict) - for service in notification_services: - url = cursor.execute( - "SELECT url FROM notification_services WHERE id = ? LIMIT 1;", - (service,) - ).fetchone() - if not url: - raise NotificationServiceNotFound - a.add(url[0]) - a.notify(title=title, body=text) - return + Args: + title (str): Title title of the entry + notification_service (int): The id of the notification service to use to send the reminder + text (str, optional): The body of the reminder. Defaults to ''. + """ + logging.info(f'Testing reminder with {title=}, {notification_services=}, {text=}') + a = Apprise() + cursor = get_db(dict) + for service in notification_services: + url = cursor.execute( + "SELECT url FROM notification_services WHERE id = ? AND user_id = ? LIMIT 1;", + (service, self.user_id) + ).fetchone() + if not url: + raise NotificationServiceNotFound + a.add(url[0]) + a.notify(title=title, body=text) + return diff --git a/backend/static_reminders.py b/backend/static_reminders.py index 8801882..078de45 100644 --- a/backend/static_reminders.py +++ b/backend/static_reminders.py @@ -1,7 +1,8 @@ #-*- coding: utf-8 -*- +import logging from sqlite3 import IntegrityError -from typing import List +from typing import List, Literal from apprise import Apprise @@ -9,17 +10,21 @@ from backend.custom_exceptions import (NotificationServiceNotFound, ReminderNotFound) from backend.db import get_db +filter_function = lambda query, p: ( + query in p["title"].lower() + or query in p["text"].lower() +) class StaticReminder: """Represents a static reminder """ - def __init__(self, reminder_id: int) -> None: + def __init__(self, user_id: int, reminder_id: int) -> None: self.id = reminder_id # Check if reminder exists if not get_db().execute( - "SELECT 1 FROM static_reminders WHERE id = ? LIMIT 1;", - (self.id,) + "SELECT 1 FROM static_reminders WHERE id = ? AND user_id = ? LIMIT 1;", + (self.id, user_id) ).fetchone(): raise ReminderNotFound @@ -70,7 +75,12 @@ class StaticReminder: Returns: dict: The new static reminder info - """ + """ + logging.info( + f'Updating static reminder {self.id}: ' + + f'{title=}, {notification_services=}, {text=}, {color=}' + ) + # Get current data and update it with new values data = self.get() new_values = { @@ -115,22 +125,37 @@ class StaticReminder: def delete(self) -> None: """Delete the static reminder """ + logging.info(f'Deleting static reminder {self.id}') get_db().execute("DELETE FROM static_reminders WHERE id = ?", (self.id,)) return class StaticReminders: """Represents the static reminder library of the user account """ - + sort_functions = { + 'title': (lambda r: (r['title'], r['text'], r['color']), False), + 'title_reversed': (lambda r: (r['title'], r['text'], r['color']), True), + 'date_added': (lambda r: r['id'], False), + 'date_added_reversed': (lambda r: r['id'], True) + } + def __init__(self, user_id: int) -> None: self.user_id = user_id - def fetchall(self) -> List[dict]: + def fetchall(self, sort_by: Literal["title", "title_reversed", "date_added", "date_added_reversed"] = "title") -> List[dict]: """Get all static reminders + Args: + sort_by (Literal["title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "title". + Returns: List[dict]: The id, title, text and color of each static reminder - """ + """ + sort_function = self.sort_functions.get( + sort_by, + self.sort_functions['title'] + ) + reminders: list = list(map( dict, get_db(dict).execute(""" @@ -146,8 +171,28 @@ class StaticReminders: ) )) + # Sort result + reminders.sort(key=sort_function[0], reverse=sort_function[1]) + return reminders - + + def search(self, query: str, sort_by: Literal["title", "title_reversed", "date_added", "date_added_reversed"] = "title") -> List[dict]: + """Search for static reminders + + Args: + query (str): The term to search for + sort_by (Literal["title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "title". + + Returns: + List[dict]: All static reminders that match. Similar output to self.fetchall + """ + query = query.lower() + reminders = list(filter( + lambda p: filter_function(query, p), + self.fetchall(sort_by) + )) + return reminders + def fetchone(self, id: int) -> StaticReminder: """Get one static reminder @@ -157,7 +202,7 @@ class StaticReminders: Returns: StaticReminder: A StaticReminder instance """ - return StaticReminder(id) + return StaticReminder(self.user_id, id) def add( self, @@ -180,6 +225,10 @@ class StaticReminders: Returns: StaticReminder: A StaticReminder instance representing the newly created static reminder """ + logging.info( + f'Adding static reminder with {title=}, {notification_services=}, {text=}, {color=}' + ) + cursor = get_db() id = cursor.execute(""" INSERT INTO static_reminders(user_id, title, text, color) @@ -207,13 +256,16 @@ class StaticReminders: Raises: ReminderNotFound: The static reminder with the given id was not found """ + logging.info(f'Triggering static reminder {self.id}') cursor = get_db(dict) reminder = cursor.execute(""" SELECT title, text FROM static_reminders - WHERE id = ? + WHERE + id = ? + AND user_id = ? LIMIT 1; - """, (id,)).fetchone() + """, (id, self.user_id)).fetchone() if not reminder: raise ReminderNotFound reminder = dict(reminder) diff --git a/backend/templates.py b/backend/templates.py index 9343c8d..177a037 100644 --- a/backend/templates.py +++ b/backend/templates.py @@ -1,22 +1,27 @@ #-*- coding: utf-8 -*- +import logging from sqlite3 import IntegrityError -from typing import List +from typing import List, Literal from backend.custom_exceptions import (NotificationServiceNotFound, TemplateNotFound) from backend.db import get_db +filter_function = lambda query, p: ( + query in p["title"].lower() + or query in p["text"].lower() +) class Template: """Represents a template """ - def __init__(self, template_id: int): + def __init__(self, user_id: int, template_id: int): self.id = template_id exists = get_db().execute( - "SELECT 1 FROM templates WHERE id = ? LIMIT 1;", - (self.id,) + "SELECT 1 FROM templates WHERE id = ? AND user_id = ? LIMIT 1;", + (self.id, user_id) ).fetchone() if not exists: raise TemplateNotFound @@ -65,6 +70,11 @@ class Template: Returns: dict: The new template info """ + logging.info( + f'Updating template {self.id}: ' + + f'{title=}, {notification_services=}, {text=}, {color=}' + ) + cursor = get_db() data = self.get() @@ -107,21 +117,37 @@ class Template: def delete(self) -> None: """Delete the template """ + logging.info(f'Deleting template {self.id}') get_db().execute("DELETE FROM templates WHERE id = ?;", (self.id,)) return class Templates: """Represents the template library of the user account """ + sort_functions = { + 'title': (lambda r: (r['title'], r['text'], r['color']), False), + 'title_reversed': (lambda r: (r['title'], r['text'], r['color']), True), + 'date_added': (lambda r: r['id'], False), + 'date_added_reversed': (lambda r: r['id'], True) + } + def __init__(self, user_id: int): self.user_id = user_id - def fetchall(self) -> List[dict]: + def fetchall(self, sort_by: Literal["title", "title_reversed", "date_added", "date_added_reversed"] = "title") -> List[dict]: """Get all templates + Args: + sort_by (Literal["title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "title". + Returns: List[dict]: The id, title, text and color """ + sort_function = self.sort_functions.get( + sort_by, + self.sort_functions['title'] + ) + templates: list = list(map(dict, get_db(dict).execute(""" SELECT id, @@ -134,8 +160,28 @@ class Templates: (self.user_id,) ))) + # Sort result + templates.sort(key=sort_function[0], reverse=sort_function[1]) + return templates + def search(self, query: str, sort_by: Literal["title", "title_reversed", "date_added", "date_added_reversed"] = "title") -> List[dict]: + """Search for templates + + Args: + query (str): The term to search for + sort_by (Literal["title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "title". + + Returns: + List[dict]: All templates that match. Similar output to self.fetchall + """ + query = query.lower() + reminders = list(filter( + lambda p: filter_function(query, p), + self.fetchall(sort_by) + )) + return reminders + def fetchone(self, id: int) -> Template: """Get one template @@ -145,7 +191,7 @@ class Templates: Returns: Template: A Template instance """ - return Template(id) + return Template(self.user_id, id) def add( self, @@ -164,7 +210,11 @@ class Templates: Returns: Template: The info about the template - """ + """ + logging.info( + f'Adding template with {title=}, {notification_services=}, {text=}, {color=}' + ) + cursor = get_db() id = cursor.execute(""" INSERT INTO templates(user_id, title, text, color) diff --git a/backend/users.py b/backend/users.py index 6b45518..9355bb3 100644 --- a/backend/users.py +++ b/backend/users.py @@ -1,5 +1,6 @@ #-*- coding: utf-8 -*- +import logging from backend.custom_exceptions import (AccessUnauthorized, UsernameInvalid, UsernameTaken, UserNotFound) from backend.db import get_db @@ -27,7 +28,7 @@ class User: self.salt = result['salt'] self.user_id = result['id'] - # check password + # Check password hash_password = get_hash(result['salt'], password) if not hash_password == result['hash']: raise AccessUnauthorized @@ -90,11 +91,14 @@ class User: "UPDATE users SET hash = ? WHERE id = ?", (hash_password, self.user_id) ) + logging.info(f'The user {self.username} ({self.user_id}) changed their password') return def delete(self) -> None: """Delete the user account - """ + """ + logging.info(f'Deleting the user {self.username} ({self.user_id})') + cursor = get_db() cursor.execute("DELETE FROM reminders WHERE user_id = ?", (self.user_id,)) cursor.execute("DELETE FROM templates WHERE user_id = ?", (self.user_id,)) @@ -111,7 +115,8 @@ def _check_username(username: str) -> None: Raises: UsernameInvalid: The username is not valid - """ + """ + logging.debug(f'Checking the username {username}') if username in ONEPASS_INVALID_USERNAMES or username.isdigit(): raise UsernameInvalid if list(filter(lambda c: not c in ONEPASS_USERNAME_CHARACTERS, username)): @@ -132,6 +137,8 @@ def register_user(username: str, password: str) -> int: Returns: user_id (int): The id of the new user. User registered successful """ + logging.info(f'Registering user with username {username}') + # Check if username is valid _check_username(username) @@ -156,4 +163,5 @@ def register_user(username: str, password: str) -> int: (username, salt, hashed_password) ).lastrowid + logging.debug(f'Newly registered user has id {user_id}') return user_id diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..d8658d3 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,625 @@ +# API +Below is the API documentation. Report an issue on [GitHub](https://github.com/Casvt/MIND/issues). + +All endpoints have the `/api` prefix. That means, for example, that `/auth/login` can be reached at `/api/auth/login`. + +## Authentication + +Authentication is done using an API key. +To log in, make a POST request to the [`/api/auth/login`](#authlogin) endpoint. +You'll receive an API key, which you can then use in your requests to authenticate. +Supply it via the url parameter `api_key`. +This API key is valid for one hour after which the key expires, any further requests return 401 'APIKeyExpired' and you are required to log in again. +If no `api_key` is supplied or it is invalid, 401 `APIKeyInvalid` is returned. + +For example: +```bash +curl -sSL 'http://192.168.2.15:8080/api/reminders?api_key=ABCDEFG' +``` + +## Supplying data + +Often, data needs to be supplied with a request. +If the parameters need to be supplied via `url`, add them to the url as url parameters. +If the parameters need to be supplied via `body`, add them to the body as a json object and supply the `Content-Type: application/json` header. + +For example: +```bash +# URL parameter +curl -sSL 'http://192.168.2.15:8080/api/reminders/search?api_key=ABCDEFG&query=Fountain&sort_by=time_reversed' + +# Body parameter +curl -sSLX POST \ + -H 'Content-Type: application/json' \ + -d '{"title": "Test service", "url": "test://fake/url"}' \ + 'http://192.168.2.15:8080/api/notificationservices?api_key=ABCDEFG' +``` + +## Endpoints +The following is automatically generated. Please report any issues on [GitHub](https://github.com/Casvt/MIND/issues). +### `/auth/login` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| No | Login to a user account | + +??? POST + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | username | Yes | The username of the user account | N/A | + | password | Yes | The password of the user account | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 401 | AccessUnauthorized | The password given is not correct | + | 404 | UserNotFound | The user requested can not be found | + +### `/auth/logout` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Logout of a user account | + +??? POST + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + +### `/auth/status` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Get current status of login | + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + +### `/user/add` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| No | Create a new user account | + +??? POST + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | username | Yes | The username of the user account | N/A | + | password | Yes | The password of the user account | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 400 | UsernameInvalid | The username contains invalid characters | + | 400 | UsernameTaken | The username is already taken | + +### `/user` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage a user account | + +??? PUT + + Change the password of the user account + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | new_password | Yes | The new password of the user account | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + +??? DELETE + + Delete the user account + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + +### `/notificationservices` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage the notification services | + +??? GET + + Get a list of all notification services + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + +??? POST + + Add a notification service + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | Yes | The title of the entry | N/A | + | url | Yes | The Apprise URL of the notification service | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + +### `/notificationservices/` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage a specific notification service | + +Replace `` with the ID of the entry. For example: `/notificationservices/2`. + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | NotificationServiceNotFound | The notification service was not found | + +??? PUT + + Edit the notification service + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | No | The title of the entry | N/A | + | url | No | The Apprise URL of the notification service | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 404 | NotificationServiceNotFound | The notification service was not found | + +??? DELETE + + Delete the notification service + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | NotificationServiceNotFound | The notification service was not found | + +### `/reminders` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage the reminders | + +??? GET + + Get a list of all reminders + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `time`, `time_reversed`, `title`, `title_reversed`, `date_added`, `date_added_reversed` | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +??? POST + + Add a reminder + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | Yes | The title of the entry | N/A | + | time | Yes | The UTC epoch timestamp that the reminder should be sent at | N/A | + | notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | repeat_quantity | No | The quantity of the repeat_interval | `years`, `months`, `weeks`, `days`, `hours`, `minutes` | + | repeat_interval | No | The number of the interval | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | InvalidTime | The time given is in the past | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 404 | NotificationServiceNotFound | The notification service was not found | + +### `/reminders/search` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Search through the list of reminders | + +??? GET + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `time`, `time_reversed`, `title`, `title_reversed`, `date_added`, `date_added_reversed` | + | query | Yes | The search term | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +### `/reminders/test` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Test send a reminder draft | + +??? POST + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | Yes | The title of the entry | N/A | + | notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 404 | NotificationServiceNotFound | The notification service was not found | + +### `/reminders/` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage a specific reminder | + +Replace `` with the ID of the entry. For example: `/reminders/2`. + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +??? PUT + + Edit the reminder + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | No | The title of the entry | N/A | + | time | No | The UTC epoch timestamp that the reminder should be sent at | N/A | + | notification_services | No | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | repeat_quantity | No | The quantity of the repeat_interval | `years`, `months`, `weeks`, `days`, `hours`, `minutes` | + | repeat_interval | No | The number of the interval | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | InvalidTime | The time given is in the past | + | 404 | NotificationServiceNotFound | The notification service was not found | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +??? DELETE + + Delete the reminder + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +### `/templates` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage the templates | + +??? GET + + Get a list of all templates + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +??? POST + + Add a template + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | Yes | The title of the entry | N/A | + | notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 404 | NotificationServiceNotFound | The notification service was not found | + +### `/templates/search` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Search through the list of templates | + +??? GET + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` | + | query | Yes | The search term | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +### `/templates/` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage a specific template | + +Replace `` with the ID of the entry. For example: `/templates/2`. + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | TemplateNotFound | The template was not found | + +??? PUT + + Edit the template + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | No | The title of the entry | N/A | + | notification_services | No | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 404 | NotificationServiceNotFound | The notification service was not found | + | 404 | TemplateNotFound | The template was not found | + +??? DELETE + + Delete the template + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | TemplateNotFound | The template was not found | + +### `/staticreminders` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage the static reminders | + +??? GET + + Get a list of all static reminders + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +??? POST + + Add a static reminder + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | Yes | The title of the entry | N/A | + | notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 404 | NotificationServiceNotFound | The notification service was not found | + +### `/staticreminders/search` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Search through the list of staticreminders | + +??? GET + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` | + | query | Yes | The search term | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +### `/staticreminders/` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage a specific static reminder | + +Replace `` with the ID of the entry. For example: `/staticreminders/2`. + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +??? POST + + Trigger the static reminder + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +??? PUT + + Edit the static reminder + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | No | The title of the entry | N/A | + | notification_services | No | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 404 | NotificationServiceNotFound | The notification service was not found | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +??? DELETE + + Delete the static reminder + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | ReminderNotFound | The reminder with the id can not be found | + diff --git a/docs/assets/css/extra.css b/docs/assets/css/extra.css new file mode 100644 index 0000000..3ecc6b3 --- /dev/null +++ b/docs/assets/css/extra.css @@ -0,0 +1,234 @@ +:root { + --color-light: #ffffff; + --color-dim-light: #b5b5b5; + --color-gray: #3c3c3c; + --color-dark: #1b1b1b; +} + +.md-typeset code { + border-radius: 6px; +} + +.md-clipboard { + color: var(--color-gray); +} + +:hover > .md-clipboard { + color: var(--color-dark); +} + +.md-clipboard:hover { + color: var(--color-light); +} + +.md-typeset table:not([class]) { + border-radius: 6px; +} + +.md-typeset a:not(.headerlink) { + text-decoration: underline; +} + +/* */ +/* Light mode */ +/* */ +[data-md-color-scheme="MIND"] { + /* Background color of header */ + --md-primary-fg-color: var(--color-gray); + /* Header and code background color and clipboard color */ + --md-default-fg-color--light: var(--color-dim-light); + --md-primary-fg-color--lighter: #00000052; + --md-default-fg-color--lightest: var(--color-dim-light); + /* Color in header */ + --md-primary-bg-color: var(--color-light); + /* Color in placeholder of search and non-hover for nav */ + --md-primary-bg-color--light: var(--color-dim-light); + --md-primary-bg-color--lighter: #ffffff4d; + --md-primary-bg-color--lightest: #ffffff1f; + /* Background color */ + --md-default-bg-color: var(--color-light); + + /* Color in search */ + --md-default-fg-color: var(--color-dark); + + /* Hover color for mark and ToC */ + --md-accent-fg-color: var(--color-gray); + + /* Code color */ + --md-code-fg-color: var(--color-light); + /* Code background color */ + --md-code-bg-color: var(--color-gray); + --md-code-hl-color: #ffff0080; + --md-code-hl-number-color: #d52a2a; + --md-code-hl-special-color: #db1457; + --md-code-hl-function-color: #a846b9; + --md-code-hl-constant-color: #6e59d9; + --md-code-hl-keyword-color: #3f6ec6; + --md-code-hl-string-color: #1c7d4d; + + /* Slashes in code */ + --md-code-hl-name-color: var(--md-code-fg-color); + --md-code-hl-operator-color: var(--color-dim-light); + --md-code-hl-punctuation-color: var(--color-dim-light); + --md-code-hl-comment-color: var(--color-dim-light); + --md-code-hl-generic-color: var(--color-dim-light); + --md-code-hl-variable-color: var(--color-dim-light); + + /* Text color */ + --md-typeset-color: var(--color-dark); + /* Link color */ + --md-typeset-a-color: var(--color-dim-light); + --md-typeset-mark-color: #ffff0080; + --md-typeset-del-color: #f5503d26; + --md-typeset-ins-color: #0bd57026; + --md-typeset-kbd-color: #fafafa; + --md-typeset-kbd-accent-color: #fff; + --md-typeset-kbd-border-color: #b8b8b8; + --md-typeset-table-color: var(--color-gray); + --md-typeset-table-color--light: rgba(0,0,0,.035); + + --md-admonition-fg-color: var(--color-dark); + --md-admonition-bg-color: var(--color-light); + + --md-warning-fg-color: #000000de; + --md-warning-bg-color: #ff9; + + /* Footer color */ + --md-footer-fg-color: var(--color-light); + /* Footer background color */ + --md-footer-bg-color: var(--color-gray); + + --md-shadow-z1: 0 0.2rem 0.5rem #0000000d,0 0 0.05rem #0000001a; + --md-shadow-z2: 0 0.2rem 0.5rem #0000001a,0 0 0.05rem #00000040; + --md-shadow-z3: 0 0.2rem 0.5rem #0003,0 0 0.05rem #00000059; +} + +[data-md-color-scheme="MIND"] button[data-md-component="top"], +[data-md-color-scheme="MIND"] h1, +[data-md-color-scheme="MIND"] [data-md-component="search"] { + --md-default-fg-color--light: var(--color-dark); +} + +[data-md-color-scheme="MIND"] [data-md-component="toc"] { + color: var(--color-dark); +} + +[data-md-color-scheme="MIND"] [data-md-component="toc"] nav { + color: var(--color-gray); +} + +[data-md-color-scheme="MIND"] .tabbed-block { + --md-default-fg-color--light: var(--color-dark); +} + +[data-md-color-scheme="MIND"] details { + border-color: var(--color-gray); +} + +[data-md-color-scheme="MIND"] details > summary { + background-color: transparent; +} + +[data-md-color-scheme="MIND"] details > summary::before { + background-color: var(--color-dark); +} + +/* */ +/* Dark mode */ +/* */ +[data-md-color-scheme="MIND-dark"] { + /* Background color of header */ + --md-primary-fg-color: var(--color-gray); + /* Header and code background color and clipboard color */ + --md-default-fg-color--light: var(--color-gray); + --md-primary-fg-color--lighter: #00000052; + --md-default-fg-color--lightest: var(--color-gray); + /* Color in header */ + --md-primary-bg-color: var(--color-light); + /* Color in placeholder of search and non-hover for nav */ + --md-primary-bg-color--light: var(--color-dim-light); + --md-primary-bg-color--lighter: #ffffff4d; + --md-primary-bg-color--lightest: #ffffff1f; + /* Background color */ + --md-default-bg-color: var(--color-dark); + + /* Color in search */ + --md-default-fg-color: var(--color-light); + + /* Hover color for mark and ToC */ + --md-accent-fg-color: var(--color-dim-light); + + /* Code color */ + --md-code-fg-color: var(--color-light); + /* Code background color */ + --md-code-bg-color: var(--color-gray); + --md-code-hl-color: #ffff0080; + --md-code-hl-number-color: #d52a2a; + --md-code-hl-special-color: #db1457; + --md-code-hl-function-color: #a846b9; + --md-code-hl-constant-color: #6e59d9; + --md-code-hl-keyword-color: #3f6ec6; + --md-code-hl-string-color: #1c7d4d; + + /* Slashes in code */ + --md-code-hl-name-color: var(--md-code-fg-color); + --md-code-hl-operator-color: var(--color-dim-light); + --md-code-hl-punctuation-color: var(--color-dim-light); + --md-code-hl-comment-color: var(--color-dim-light); + --md-code-hl-generic-color: var(--color-dim-light); + --md-code-hl-variable-color: var(--color-dim-light); + + /* Text color */ + --md-typeset-color: var(--color-light); + /* Link color */ + --md-typeset-a-color: var(--color-dim-light); + --md-typeset-mark-color: #ffff0080; + --md-typeset-del-color: #f5503d26; + --md-typeset-ins-color: #0bd57026; + --md-typeset-kbd-color: #fafafa; + --md-typeset-kbd-accent-color: #fff; + --md-typeset-kbd-border-color: #b8b8b8; + --md-typeset-table-color: var(--color-gray); + --md-typeset-table-color--light: rgba(0,0,0,.035); + + --md-admonition-fg-color: var(--color-light); + --md-admonition-bg-color: var(--color-dark); + + --md-warning-fg-color: #000000de; + --md-warning-bg-color: #ff9; + + /* Footer color */ + --md-footer-fg-color: var(--color-light); + /* Footer background color */ + --md-footer-bg-color: var(--color-gray); + + --md-shadow-z1: 0 0.2rem 0.5rem #0000000d,0 0 0.05rem #0000001a; + --md-shadow-z2: 0 0.2rem 0.5rem #0000001a,0 0 0.05rem #00000040; + --md-shadow-z3: 0 0.2rem 0.5rem #0003,0 0 0.05rem #00000059; +} + +[data-md-color-scheme="MIND-dark"] button[data-md-component="top"], +[data-md-color-scheme="MIND-dark"] h1, +[data-md-color-scheme="MIND-dark"] [data-md-component="search"] { + --md-default-fg-color--light: var(--color-light); +} + +[data-md-color-scheme="MIND-dark"] [data-md-component="toc"] nav { + color: var(--color-dim-light); +} + +[data-md-color-scheme="MIND-dark"] .tabbed-block { + --md-default-fg-color--light: var(--color-dark); +} + +[data-md-color-scheme="MIND-dark"] details { + border-color: var(--color-gray); +} + +[data-md-color-scheme="MIND-dark"] details > summary { + background-color: transparent; +} + +[data-md-color-scheme="MIND-dark"] details > summary::before { + background-color: var(--color-dim-light); +} diff --git a/docs/assets/img/favicon.svg b/docs/assets/img/favicon.svg new file mode 100644 index 0000000..878a5d5 --- /dev/null +++ b/docs/assets/img/favicon.svg @@ -0,0 +1,30 @@ + + + + White + + + + Off-white + + + + Gray + + + + Dark gray + + + + + + + + + + + M​ + + + \ No newline at end of file diff --git a/docs/backup_your_data.md b/docs/backup_your_data.md new file mode 100644 index 0000000..4d14dd9 --- /dev/null +++ b/docs/backup_your_data.md @@ -0,0 +1,23 @@ +# Backup your data + +It's possible, and very easy, to backup all MIND data. This way, you can recover data if something goes wrong, port your instance over to an other computer or run redundant instances. + +## Backing up the data + +It's as simple as making a copy of the database file and storing it somewhere safe. + +1. Stop MIND, if it's still running. +2. Go inside the docker volume of the container (most likely `mind-db`) or the mapped folder. +3. Inside the volume/folder, you'll find the `MIND.db` file. Make a copy of this. That's all you need. +4. You can now start the instance back up. + +The database file contains all data and is the only thing needed to keep a complete backup of your MIND instance. + +## Recovering the data + +It's as simple as putting the database file in the database folder and restarting the instance. + +1. Stop MIND, if it's still running. +2. Go inside the docker volume of the container (most likely `mind-db`) or the mapped folder. +3. Inside the volume/folder, place the database file that you backed up. +4. You can now start the instance back up. Everything should be recovered. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..edcc1dc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,38 @@ +# MIND Docs + +[![Docker Pulls](https://img.shields.io/docker/pulls/mrcas/mind.svg)](https://hub.docker.com/r/mrcas/mind) +[![GitHub Downloads](https://img.shields.io/github/downloads/Casvt/MIND/total.svg)](https://github.com/Casvt/MIND/releases) + +__A simple self hosted reminder application that can send push notifications to your device. Set the reminder and forget about it!__ + +Getting started: + +- [Installation](./installation.md) +- [Setup After Installation](./setup_after_installation.md) + +General Information: + +- [API Docs](./api.md) + +## Workings + +MIND can be used for sending notifications at the desired time. This can be a set time, like a yearly reminder for a birthday, or at a button click, to easily send a predefined notification when you want to. + +## Features + +- Works cross-timezone +- Notifications are sent with second-precision +- Uses the [apprise library](https://github.com/caronc/apprise), giving you 80+ platforms to send notifications to +- Easily manage the reminders with sorting options, search ability and color coding +- Docker image available +- Mobile friendly web-interface +- API available + +### Planned Features + +You can see the planned features in the [Project board](https://github.com/users/Casvt/projects/3). + +## Contact + +- For support, a [discord server](https://discord.gg/nMNdgG7vsE) is available +- Alternatively, [make an issue](https://github.com/Casvt/MIND/issues) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..c19384b --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,97 @@ +# Installation + +The recommended way to install MIND is using Docker. After installing MIND, it is advised to read the [Setup After Installation page](setup_after_installation.md). + +NOTE: Make sure to set all time related settings (time, date, timezone, etc.) correct on your computer, as MIND depends on it to work correctly. + +## Docker + +### Database location + +We first need to create a named volume, or a folder, to store the database file of MIND in. + +=== "Docker CLI" + ```bash + docker volume create mind-db + ``` + +=== "Portainer" + - Open `Volumes` + - Click `Add Volume` + - Enter name matching the one you'll use in compose (`mind-db`, in the above provided command) + - Click `Create the volume` + - Open `Stacks` + - Create the stack with the named volume in it. + +=== "Folder" + Linux standards suggest to put the database in `/opt/application_name`, as the `/opt` directory is where program options should be stored. In this case, you'd create the desired folder using the following command: + ```bash + mkdir /opt/MIND/db + ``` + +### Run the container + +Now that we can store the database somewhere, we can get the container running. + +=== "Docker CLI" + The command to get the docker container running can be found below. Replace the timezone value (`TZ=`) with the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) of your timezone (value of `TZ identifier` on webpage). + ```bash + docker run -d \ + --name mind \ + -v mind-db:/app/db \ + -e TZ=Europe/Amsterdam \ + -p 8080:8080 \ + mrcas/mind:latest + ``` + +=== "Docker Compose" + The contents of the `docker-compose.yml` file would look like below. Replace the timezone value (`TZ=`) with the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) of your timezone (value of `TZ identifier` on webpage). + ```yml + version: '3.3' + services: + mind: + container_name: mind + volumes: + - 'mind-db:/app/db' + environment: + - TZ=Europe/Amsterdam + ports: + - '8080:8080' + image: 'mrcas/mind:latest' + ``` + Now run the compose by running the following command in the root folder: + ```bash + docker compose up -d + ``` + +If you didn't name your docker volume `mind-db` (see [Database location](#database-location)), replace `mind-db` in the command with the name of your volume. If you created a folder, replace `mind-db` with `/opt/MIND/db` or the folder you want. + +Information on how to change the port can be found on the [Setup After Installation page](setup_after_installation.md#port). + +## Manual Install + +See below for installation instructions for your OS if you want to install it manually. + +=== "Linux / MacOS" + ```bash + sudo apt-get install git python3-pip + sudo git clone https://github.com/Casvt/MIND.git /opt/MIND + cd /opt/MIND + python3 -m pip install -r requirements.txt + python3 MIND.py + ``` + +=== "Windows" + 1. Install python [in the Microsoft Store](https://www.microsoft.com/store/productId/9PJPW5LDXLZ5) + 2. Install pip [using these instructions](https://www.liquidweb.com/kb/install-pip-windows/) + 3. Download [the latest release](https://github.com/Casvt/MIND/zipball/master) + 4. Extract the ZIP file + 5. With the folder open, right click and select `Open in Terminal` + 6. Type the following command: + ```bash + python -m pip install -r requirements.txt + ``` + 7. Type the following command: + ```bash + python MIND.py + ``` diff --git a/docs/setup_after_installation.md b/docs/setup_after_installation.md new file mode 100644 index 0000000..18d13de --- /dev/null +++ b/docs/setup_after_installation.md @@ -0,0 +1,34 @@ +# Setup After Installation + +After installing MIND, you should have access to the web-ui. MIND needs some configuration in order for it to work properly. + +## Port + +The first thing to do is decide if you want to leave MIND running on the default port of `8080`. If you _do_, you can go to the next step. If you want to _change_ the port, continue reading. + +=== "Docker CLI" + Alter the command to run the container and replace `-p 8080:8080` with `-p {PORT}:8080`, where `{PORT}` is the desired port (e.g. `-p 8009:8080`). Then run the container with the new version of the command. + +=== "Docker Compose" + Alter the file to run the container and replace `- 8080:8080` with `- {PORT}:8080`, where `{PORT}` is the desired port (e.g. `- 8009:8080`). Then run the container with the new version of the file. + +=== "Manual Install" + Inside the `MIND.py` file at the top, you can set the port via the `PORT` variable. Change it from `PORT = '8080'` to `PORT = '{PORT}'`, where `{PORT}` is the desired port (e.g. `PORT = '8009'`). Then restart the application. + +## Base URL + +If you want to set a base url (e.g. for a reverse proxy), go inside the `MIND.py` file and at the top, you can set the base URL via the `URL_PREFIX` variable. Change it from `URL_PREFIX = ''` to `URL_PREFIX = '/{PREFIX}'`, where `{PREFIX}` is the desired URL prefix (e.g. `URL_PREFIX = '/mind'`). Then restart the application. + +## Creating an account + +When accessing the web-ui, you'll be prompted to log in. Click on `Or create an account`, enter the desired username and password for the account and click `Create`. The account is created and can now be logged in with. The complete authentication process is local and no data is shared with any other service. + +## Set your locale + +In the settings, you can change your locale, so that the dates and times are displayed in the format used by your country. + +## Add a notification service + +A notification service is a way of sending a notification. For example an e-mail to a group of people or a PushBullet notification to a specific device. What the actual content of the notification is, is decided by the title and text of the reminder. The notification service only specifies in which way the title and text is sent. You set it up once, and then you can select it when creating a reminder. + +Go to the "Notification Services" tab in the web-ui and click the `+` button. A notification service consists of a title (name) and an Apprise URL. See the [Apprise URL documentation](https://github.com/caronc/apprise#supported-notifications) to learn how to make a valid Apprise URL. diff --git a/frontend/api.py b/frontend/api.py index 48bc0ea..7f1ac25 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -1,176 +1,389 @@ #-*- coding: utf-8 -*- +from abc import ABC, abstractmethod from os import urandom from re import compile from time import time as epoch_time -from typing import Any, Tuple +from typing import Any, Callable, Dict, List, Tuple, Union +from apprise import Apprise from flask import Blueprint, g, request +from flask.scaffold import T_route -from backend.custom_exceptions import (AccessUnauthorized, InvalidKeyValue, - InvalidTime, InvalidURL, KeyNotFound, +from backend.custom_exceptions import (AccessUnauthorized, APIKeyExpired, + APIKeyInvalid, InvalidKeyValue, + InvalidTime, KeyNotFound, NotificationServiceInUse, NotificationServiceNotFound, - ReminderNotFound, UsernameInvalid, - UsernameTaken, UserNotFound) + ReminderNotFound, TemplateNotFound, + UsernameInvalid, UsernameTaken, + UserNotFound) from backend.notification_service import (NotificationService, NotificationServices) -from backend.reminders import Reminders, reminder_handler, test_reminder +from backend.reminders import Reminders, reminder_handler from backend.static_reminders import StaticReminders from backend.templates import Template, Templates from backend.users import User, register_user -api = Blueprint('api', __name__) -api_key_map = {} +#=================== +# Input validation +#=================== color_regex = compile(r'#[0-9a-f]{6}') -""" -AUTHENTICATION: - After making a POST /auth/login request, you'll receive an api_key in the output. - From then on, make all requests with the url parameter api_key, where the value is the string you received. - One hour after logging in, the api key expires and you are required to login again to get a new api_key. +class DataSource: + DATA = 1 + VALUES = 2 - If no api key is supplied or it is invalid, 401 'ApiKeyInvalid' is returned. - If the api key supplied has expired, 401 'ApiKeyExpired' is returned. -""" +class InputVariable(ABC): + @abstractmethod + def __init__(self, value: Any) -> None: + pass + + @property + @abstractmethod + def name() -> str: + pass + + @abstractmethod + def validate(self) -> bool: + pass + + @property + @abstractmethod + def required() -> bool: + pass + + @property + @abstractmethod + def default() -> Any: + pass + + @property + @abstractmethod + def source() -> int: + pass + + @property + @abstractmethod + def description() -> str: + pass + + @property + @abstractmethod + def related_exceptions() -> List[Exception]: + pass + +class DefaultInputVariable(InputVariable): + source = DataSource.DATA + required = True + default = None + related_exceptions = [] + + def __init__(self, value: Any) -> None: + self.value = value + + def validate(self) -> bool: + return isinstance(self.value, str) and self.value + + def __repr__(self) -> str: + return f'| {self.name} | {"Yes" if self.required else "No"} | {self.description} | N/A |' + +class NonRequiredVersion(InputVariable): + required = False + + def validate(self) -> bool: + return self.value is None or super().validate() + +class UsernameVariable(DefaultInputVariable): + name = 'username' + description = 'The username of the user account' + related_exceptions = [KeyNotFound, UserNotFound] + +class PasswordVariable(DefaultInputVariable): + name = 'password' + description = 'The password of the user account' + related_exceptions = [KeyNotFound, AccessUnauthorized] + +class NewPasswordVariable(PasswordVariable): + name = 'new_password' + description = 'The new password of the user account' + related_exceptions = [KeyNotFound] + +class UsernameCreateVariable(UsernameVariable): + related_exceptions = [KeyNotFound, UsernameInvalid, UsernameTaken] + +class PasswordCreateVariable(PasswordVariable): + related_exceptions = [KeyNotFound] + +class TitleVariable(DefaultInputVariable): + name = 'title' + description = 'The title of the entry' + related_exceptions = [KeyNotFound] + +class URLVariable(DefaultInputVariable): + name = 'url' + description = 'The Apprise URL of the notification service' + related_exceptions = [KeyNotFound, InvalidKeyValue] + + def validate(self) -> bool: + return Apprise().add(self.value) + +class EditTitleVariable(NonRequiredVersion, TitleVariable): + related_exceptions = [] + +class EditURLVariable(NonRequiredVersion, URLVariable): + related_exceptions = [InvalidKeyValue] + +class SortByVariable(DefaultInputVariable): + name = 'sort_by' + description = 'How to sort the result' + required = False + source = DataSource.VALUES + _options = Reminders.sort_functions + default = next(iter(Reminders.sort_functions)) + related_exceptions = [InvalidKeyValue] + + def __init__(self, value: str) -> None: + self.value = value + + def validate(self) -> bool: + return self.value in self._options + + def __repr__(self) -> str: + return '| {n} | {r} | {d} | {v} |'.format( + n=self.name, + r="Yes" if self.required else "No", + d=self.description, + v=", ".join(f'`{o}`' for o in self._options) + ) + +class TemplateSortByVariable(SortByVariable): + _options = Templates.sort_functions + default = next(iter(Templates.sort_functions)) + +class StaticReminderSortByVariable(TemplateSortByVariable): + _options = StaticReminders.sort_functions + default = next(iter(StaticReminders.sort_functions)) + +class TimeVariable(DefaultInputVariable): + name = 'time' + description = 'The UTC epoch timestamp that the reminder should be sent at' + related_exceptions = [KeyNotFound, InvalidKeyValue, InvalidTime] + + def validate(self) -> bool: + return isinstance(self.value, (float, int)) + +class EditTimeVariable(NonRequiredVersion, TimeVariable): + related_exceptions = [InvalidKeyValue, InvalidTime] + +class NotificationServicesVariable(DefaultInputVariable): + name = 'notification_services' + description = "Array of the id's of the notification services to use to send the notification" + related_exceptions = [KeyNotFound, InvalidKeyValue, NotificationServiceNotFound] + + def validate(self) -> bool: + if not isinstance(self.value, list): + return False + if not self.value: + return False + for v in self.value: + if not isinstance(v, int): + return False + return True + +class EditNotificationServicesVariable(NonRequiredVersion, NotificationServicesVariable): + related_exceptions = [InvalidKeyValue, NotificationServiceNotFound] + +class TextVariable(NonRequiredVersion, DefaultInputVariable): + name = 'text' + description = 'The body of the entry' + default = '' + + def validate(self) -> bool: + return isinstance(self.value, str) + +class RepeatQuantityVariable(DefaultInputVariable): + name = 'repeat_quantity' + description = 'The quantity of the repeat_interval' + required = False + _options = ("years", "months", "weeks", "days", "hours", "minutes") + default = None + related_exceptions = [InvalidKeyValue] + + def validate(self) -> bool: + return self.value is None or self.value in self._options + + def __repr__(self) -> str: + return '| {n} | {r} | {d} | {v} |'.format( + n=self.name, + r="Yes" if self.required else "No", + d=self.description, + v=", ".join(f'`{o}`' for o in self._options) + ) + +class RepeatIntervalVariable(DefaultInputVariable): + name = 'repeat_interval' + description = 'The number of the interval' + required = False + default = None + related_exceptions = [InvalidKeyValue] + + def validate(self) -> bool: + return self.value is None or (isinstance(self.value, int) and self.value > 0) + +class ColorVariable(DefaultInputVariable): + name = 'color' + description = 'The hex code of the color of the entry, which is shown in the web-ui' + required = False + default = None + related_exceptions = [InvalidKeyValue] + + def validate(self) -> None: + return self.value is None or color_regex.search(self.value) + +class QueryVariable(DefaultInputVariable): + name = 'query' + description = 'The search term' + source = DataSource.VALUES + +def input_validation() -> Union[None, Dict[str, Any]]: + """Checks, extracts and transforms inputs + + Raises: + KeyNotFound: A required key was not supplied + InvalidKeyValue: The value of a key is not valid + + Returns: + Union[None, Dict[str, Any]]: `None` if the endpoint + method doesn't require input variables. + Otherwise `Dict[str, Any]` with the input variables, checked and formatted. + """ + inputs = {} + input_variables = api_docs[request.url_rule.rule.split(api_prefix)[1]]['input_variables'] + if not input_variables: + return + if input_variables.get(request.method) is None: + return inputs + + given_variables = {} + given_variables[DataSource.DATA] = request.get_json() if request.data else {} + given_variables[DataSource.VALUES] = request.values + for input_variable in input_variables[request.method]: + if ( + input_variable.required and + not input_variable.name in given_variables[input_variable.source] + ): + raise KeyNotFound(input_variable.name) + + input_value = given_variables[input_variable.source].get(input_variable.name, input_variable.default) + + if not input_variable(input_value).validate(): + raise InvalidKeyValue(input_variable.name, input_value) + + inputs[input_variable.name] = input_value + return inputs + +#=================== +# General variables and functions +#=================== + +api_docs: Dict[str, Dict[str, Any]] = {} +class APIBlueprint(Blueprint): + def route( + self, + rule: str, + description: str = '', + input_variables: Dict[str, List[Union[List[InputVariable], str]]] = {}, + requires_auth: bool = True, + **options: Any + ) -> Callable[[T_route], T_route]: + + api_docs[rule] = { + 'endpoint': rule, + 'description': description, + 'requires_auth': requires_auth, + 'methods': options['methods'], + 'input_variables': {k: v[0] for k, v in input_variables.items() if v and v[0]}, + 'method_descriptions': {k: v[1] for k, v in input_variables.items() if v and len(v) == 2 and v[1]} + } + + return super().route(rule, **options) + +api_prefix = "/api" +api = APIBlueprint('api', __name__) +api_key_map = {} def return_api(result: Any, error: str=None, code: int=200) -> Tuple[dict, int]: return {'error': error, 'result': result}, code -def auth(method): - """Used as decorator and, if applied to route, restricts the route to authorized users and supplies user specific info - """ - def wrapper(*args,**kwargs): - hashed_api_key = hash(request.values.get('api_key','')) - if not hashed_api_key in api_key_map: - return return_api({}, 'ApiKeyInvalid', 401) - - exp = api_key_map[hashed_api_key]['exp'] - if exp <= epoch_time(): - return return_api({}, 'ApiKeyExpired', 401) - - # Api key valid - g.hashed_api_key = hashed_api_key - g.exp = exp - g.user_data = api_key_map[hashed_api_key]['user_data'] - return method(*args, **kwargs) +def auth() -> None: + """Checks if the client is logged in - wrapper.__name__ = method.__name__ - return wrapper - -def error_handler(method): - """Catches the errors that can occur in the endpoint and returns the correct api error + Raises: + APIKeyInvalid: The api key supplied is invalid + APIKeyExpired: The api key supplied has expired """ + hashed_api_key = hash(request.values.get('api_key','')) + if not hashed_api_key in api_key_map: + raise APIKeyInvalid + + exp = api_key_map[hashed_api_key]['exp'] + if exp <= epoch_time(): + raise APIKeyExpired + + # Api key valid + g.hashed_api_key = hashed_api_key + g.exp = exp + g.user_data = api_key_map[hashed_api_key]['user_data'] + return + +def endpoint_wrapper(method: Callable) -> Callable: def wrapper(*args, **kwargs): + requires_auth = api_docs[request.url_rule.rule.split(api_prefix)[1]]['requires_auth'] try: - return method(*args, **kwargs) + if requires_auth: + auth() + + inputs = input_validation() + + if inputs is None: + return method(*args, **kwargs) + return method(inputs, *args, **kwargs) + except (UsernameTaken, UsernameInvalid, UserNotFound, AccessUnauthorized, - ReminderNotFound, NotificationServiceNotFound, NotificationServiceInUse, - InvalidTime, InvalidURL, - KeyNotFound, InvalidKeyValue) as e: + ReminderNotFound, NotificationServiceNotFound, + NotificationServiceInUse, InvalidTime, + KeyNotFound, InvalidKeyValue, + APIKeyInvalid, APIKeyExpired, + TemplateNotFound) as e: return return_api(**e.api_response) - + wrapper.__name__ = method.__name__ return wrapper -def extract_key(values: dict, key: str, check_existence: bool=True) -> Any: - value: str = values.get(key) - if check_existence and value is None: - raise KeyNotFound(key) - - if value is not None: - # Check value and optionally convert - if key == 'time': - try: - value = int(value) - except (ValueError, TypeError): - raise InvalidKeyValue(key, value) - - elif key == 'repeat_interval': - try: - value = int(value) - if value <= 0: - raise ValueError - except (ValueError, TypeError): - raise InvalidKeyValue(key, value) - - elif key == 'sort_by': - if not value in Reminders.sort_functions: - raise InvalidKeyValue(key, value) - - elif key == 'repeat_quantity': - if not value in ("years", "months", "weeks", "days", "hours", "minutes"): - raise InvalidKeyValue(key, value) - - elif key in ('username', 'password', 'new_password', 'title', 'url', - 'text', 'query'): - if not isinstance(value, str): - raise InvalidKeyValue(key, value) - - elif key == 'color': - if not color_regex.search(value): - raise InvalidKeyValue(key, value) - - elif key == 'notification_services': - if not value: - raise KeyNotFound(key) - if not isinstance(value, list): - raise InvalidKeyValue(key, value) - for v in value: - if not isinstance(v, int): - raise InvalidKeyValue(key, value) - - else: - if key == 'sort_by': - value = 'time' - - elif key == 'text': - value = '' - - return value - #=================== # Authentication endpoints #=================== -@api.route('/auth/login', methods=['POST']) -@error_handler -def api_login(): - """ - Endpoint: /auth/login - Description: Login to a user account - Requires being logged in: No - Methods: - POST: - Parameters (body): - username (required): the username of the user account - password (required): the password of the user account - Returns: - 200: - The apikey to use for further requests and expiration time (epoch) - 400: - KeyNotFound: One of the required parameters was not given - 401: - PasswordInvalid: The password given is not correct for the user account - 404: - UsernameNotFound: The username was not found - """ - data = request.get_json() +@api.route( + '/auth/login', + 'Login to a user account', + {'POST': [[UsernameVariable, PasswordVariable]]}, + False, + methods=['POST'] +) +@endpoint_wrapper +def api_login(inputs: Dict[str, str]): + user = User(inputs['username'], inputs['password']) - # Check if required keys are given - username = extract_key(data, 'username') - password = extract_key(data, 'password') - - # Check credentials - user = User(username, password) - - # Login valid + # Generate an API key until one + # is generated that isn't used already while True: api_key = urandom(16).hex() # <- length api key / 2 hashed_api_key = hash(api_key) if not hashed_api_key in api_key_map: break + exp = epoch_time() + 3600 api_key_map.update({ hashed_api_key: { @@ -180,39 +393,25 @@ def api_login(): }) result = {'api_key': api_key, 'expires': exp} - return return_api(result) + return return_api(result, code=201) -@api.route('/auth/logout', methods=['POST']) -@error_handler -@auth +@api.route( + '/auth/logout', + 'Logout of a user account', + methods=['POST'] +) +@endpoint_wrapper def api_logout(): - """ - Endpoint: /auth/logout - Description: Logout of a user account - Requires being logged in: Yes - Methods: - POST: - Returns: - 200: - Logout successful - """ api_key_map.pop(g.hashed_api_key) - return return_api({}) + return return_api({}, code=201) -@api.route('/auth/status', methods=['GET']) -@error_handler -@auth +@api.route( + '/auth/status', + 'Get current status of login', + methods=['GET'] +) +@endpoint_wrapper def api_status(): - """ - Endpoint: /auth/status - Description: Get current status of login - Requires being logged in: Yes - Methods: - GET: - Returns: - 200: - The username of the logged in account and the expiration time of the api key (epoch) - """ result = { 'expires': api_key_map[g.hashed_api_key]['exp'], 'username': api_key_map[g.hashed_api_key]['user_data'].username @@ -223,72 +422,34 @@ def api_status(): # User endpoints #=================== -@api.route('/user/add', methods=['POST']) -@error_handler -def api_add_user(): - """ - Endpoint: /user/add - Description: Create a new user account - Requires being logged in: No - Methods: - POST: - Parameters (body): - username (required): the username of the new user account - password (required): the password of the new user account - Returns: - 201: - The user id of the new user account - 400: - KeyNotFound: One of the required parameters was not given - UsernameInvalid: The username given is not allowed - UsernameTaken: The username given is already in use - """ - data = request.get_json() - - # Check if required keys are given - username = extract_key(data, 'username') - password = extract_key(data, 'password') - - # Add user - user_id = register_user(username, password) - return return_api({'user_id': user_id}, code=201) +@api.route( + '/user/add', + 'Create a new user account', + {'POST': [[UsernameCreateVariable, PasswordCreateVariable]]}, + False, + methods=['POST'] +) +@endpoint_wrapper +def api_add_user(inputs: Dict[str, str]): + register_user(inputs['username'], inputs['password']) + return return_api({}, code=201) -@api.route('/user', methods=['PUT', 'DELETE']) -@error_handler -@auth -def api_manage_user(): - """ - Endpoint: /user - Description: Manage a user account - Requires being logged in: Yes - Methods: - PUT: - Description: Change the password of the user account - Parameters (body): - new_password (required): the new password of the user account - Returns: - 200: - Password updated successfully - 400: - KeyNotFound: One of the required parameters was not given - DELETE: - Description: Delete the user account - Returns: - 200: - Account deleted successfully - """ +@api.route( + '/user', + 'Manage a user account', + {'PUT': [[NewPasswordVariable], + 'Change the password of the user account'], + 'DELETE': [[], + 'Delete the user account']}, + methods=['PUT', 'DELETE'] +) +@endpoint_wrapper +def api_manage_user(inputs: Dict[str, str]): if request.method == 'PUT': - data = request.get_json() - - # Check if required key is given - new_password = extract_key(data, 'new_password') - - # Edit user - g.user_data.edit_password(new_password) + g.user_data.edit_password(inputs['new_password']) return return_api({}) elif request.method == 'DELETE': - # Delete user g.user_data.delete() api_key_map.pop(g.hashed_api_key) return return_api({}) @@ -297,83 +458,39 @@ def api_manage_user(): # Notification service endpoints #=================== -@api.route('/notificationservices', methods=['GET', 'POST']) -@error_handler -@auth -def api_notification_services_list(): - """ - Endpoint: /notificationservices - Description: Manage the notification services - Requires being logged in: Yes - Methods: - GET: - Description: Get a list of all notification services - Returns: - 200: - The id, title and url of every notification service - POST: - Description: Add a notification service - Parameters (body): - title (required): the title of the notification service - url (required): the apprise url of the notification service - Returns: - 200: - The id of the new notification service - 400: - KeyNotFound: One of the required parameters was not given - """ +@api.route( + '/notificationservices', + 'Manage the notification services', + {'GET': [[], + 'Get a list of all notification services'], + 'POST': [[TitleVariable, URLVariable], + 'Add a notification service']}, + methods=['GET', 'POST'] +) +@endpoint_wrapper +def api_notification_services_list(inputs: Dict[str, str]): services: NotificationServices = g.user_data.notification_services - + if request.method == 'GET': result = services.fetchall() return return_api(result) elif request.method == 'POST': - data = request.get_json() - title = extract_key(data, 'title') - url = extract_key(data, 'url') - result = services.add(title=title, - url=url).get() + result = services.add(title=inputs['title'], + url=inputs['url']).get() return return_api(result, code=201) -@api.route('/notificationservices/', methods=['GET', 'PUT', 'DELETE']) -@error_handler -@auth -def api_notification_service(n_id: int): - """ - Endpoint: /notificationservices/ - Description: Manage a specific notification service - Requires being logged in: Yes - URL Parameters: - : - The id of the notification service - Methods: - GET: - Returns: - 200: - All info about the notification service - 404: - No notification service found with the given id - PUT: - Description: Edit the notification service - Parameters (body): - title: The new title of the entry. - url: The new apprise url of the entry. - Returns: - 200: - Notification service updated successfully - 400: - The apprise url is invalid - 404: - No notification service found with the given id - DELETE: - Description: Delete the notification service - Returns: - 200: - Notification service deleted successfully - 404: - No notification service found with the given id - """ +@api.route( + '/notificationservices/', + 'Manage a specific notification service', + {'PUT': [[EditTitleVariable, EditURLVariable], + 'Edit the notification service'], + 'DELETE': [[], + 'Delete the notification service']}, + methods=['GET', 'PUT', 'DELETE'] +) +@endpoint_wrapper +def api_notification_service(inputs: Dict[str, str], n_id: int): service: NotificationService = g.user_data.notification_services.fetchone(n_id) if request.method == 'GET': @@ -381,12 +498,8 @@ def api_notification_service(n_id: int): return return_api(result) elif request.method == 'PUT': - data = request.get_json() - title = extract_key(data, 'title', check_existence=False) - url = extract_key(data, 'url', check_existence=False) - - result = service.update(title=title, - url=url) + result = service.update(title=inputs['title'], + url=inputs['url']) return return_api(result) elif request.method == 'DELETE': @@ -397,183 +510,87 @@ def api_notification_service(n_id: int): # Library endpoints #=================== -@api.route('/reminders', methods=['GET', 'POST']) -@error_handler -@auth -def api_reminders_list(): - """ - Endpoint: /reminders - Description: Manage the reminders - Requires being logged in: Yes - Methods: - GET: - Description: Get a list of all reminders - Parameters (url): - sort_by: how to sort the result. Allowed values are 'title', 'title_reversed', 'time' and 'time_reversed' - Returns: - 200: - The id, title, text, time, repeat_quantity, repeat_interval and color of each reminder - POST: - Description: Add a reminder - Parameters (body): - title (required): the title of the reminder - time (required): the UTC epoch timestamp that the reminder should be sent at - notification_services (required): array of the id's of the notification services to use to send the notification - text: the body of the reminder - repeat_quantity ('years', 'months', 'weeks', 'days', 'hours', 'minutes'): The quantity of the repeat_interval - repeat_interval: The number of the interval - color: The hex code of the color of the reminder, which is shown in the web-ui - Returns: - 200: - The info about the new reminder entry - 400: - KeyNotFound: One of the required parameters was not given - 404: - NotificationServiceNotFound: One of the notification services was not found - """ +@api.route( + '/reminders', + 'Manage the reminders', + {'GET': [[SortByVariable], + 'Get a list of all reminders'], + 'POST': [[TitleVariable, TimeVariable, + NotificationServicesVariable, TextVariable, + RepeatQuantityVariable, RepeatIntervalVariable, + ColorVariable], + 'Add a reminder'] + }, + methods=['GET', 'POST'] +) +@endpoint_wrapper +def api_reminders_list(inputs: Dict[str, Any]): reminders: Reminders = g.user_data.reminders if request.method == 'GET': - sort_by = extract_key(request.values, 'sort_by', check_existence=False) - result = reminders.fetchall(sort_by=sort_by) + result = reminders.fetchall(inputs['sort_by']) return return_api(result) - - elif request.method == 'POST': - data = request.get_json() - title = extract_key(data, 'title') - time = extract_key(data, 'time') - notification_services = extract_key(data, 'notification_services') - text = extract_key(data, 'text', check_existence=False) - repeat_quantity = extract_key(data, 'repeat_quantity', check_existence=False) - repeat_interval = extract_key(data, 'repeat_interval', check_existence=False) - color = extract_key(data, 'color', check_existence=False) - result = reminders.add(title=title, - time=time, - notification_services=notification_services, - text=text, - repeat_quantity=repeat_quantity, - repeat_interval=repeat_interval, - color=color) + elif request.method == 'POST': + result = reminders.add(title=inputs['title'], + time=inputs['time'], + notification_services=inputs['notification_services'], + text=inputs['text'], + repeat_quantity=inputs['repeat_quantity'], + repeat_interval=inputs['repeat_interval'], + color=inputs['color']) return return_api(result.get(), code=201) -@api.route('/reminders/search', methods=['GET']) -@error_handler -@auth -def api_reminders_query(): - """ - Endpoint: /reminders/search - Description: Search through the list of reminders - Requires being logged in: Yes - Methods: - GET: - Parameters (url): - query (required): The search term - Returns: - 200: - The search results, listed like GET /reminders - 400: - KeyNotFound: One of the required parameters was not given - """ - query = extract_key(request.values, 'query') - - result = g.user_data.reminders.search(query) +@api.route( + '/reminders/search', + 'Search through the list of reminders', + {'GET': [[SortByVariable, QueryVariable]]}, + methods=['GET'] +) +@endpoint_wrapper +def api_reminders_query(inputs: Dict[str, str]): + result = g.user_data.reminders.search(inputs['query'], inputs['sort_by']) return return_api(result) -@api.route('/reminders/test', methods=['POST']) -@error_handler -@auth -def api_test_reminder(): - """ - Endpoint: /reminders/test - Description: Test send a reminder draft - Requires being logged in: Yes - Methods: - GET: - Parameters (body): - title (required): The title of the entry. - notification_service (required): The new id of the notification service to use to send the reminder. - text: The body of the reminder. - Returns: - 201: - The reminder is sent (doesn't mean it works, just that it was sent) - 400: - KeyNotFound: One of the required parameters was not given - 404: - NotificationServiceNotFound: The notification service given was not found - """ - data = request.get_json() - title = extract_key(data, 'title') - notification_services = extract_key(data, 'notification_services') - text = extract_key(data, 'text', check_existence=False) - - test_reminder(title, notification_services, text) +@api.route( + '/reminders/test', + 'Test send a reminder draft', + {'POST': [[TitleVariable, NotificationServicesVariable, + TextVariable]]}, + methods=['POST'] +) +@endpoint_wrapper +def api_test_reminder(inputs: Dict[str, Any]): + g.user_data.reminders.test_reminder(inputs['title'], inputs['notification_services'], inputs['text']) return return_api({}, code=201) -@api.route('/reminders/', methods=['GET', 'PUT', 'DELETE']) -@error_handler -@auth -def api_get_reminder(r_id: int): - """ - Endpoint: /reminders/ - Description: Manage a specific reminder - Requires being logged in: Yes - URL Parameters: - : - The id of the reminder - Methods: - GET: - Returns: - 200: - All info about the reminder - 404: - No reminder found with the given id - PUT: - Description: Edit the reminder - Parameters (body): - title: The new title of the entry. - time: The new UTC epoch timestamp the the reminder should be send. - notification_services: Array of the new id's of the notification services to use to send the reminder. - text: The new body of the reminder. - repeat_quantity ('years', 'months', 'weeks', 'days', 'hours', 'minutes'): The new quantity of the repeat_interval. - repeat_interval: The new number of the interval. - color: The new hex code of the color of the reminder, which is shown in the web-ui. - Returns: - 200: - Reminder updated successfully - 404: - ReminderNotFound: No reminder found with the given id - NotificationServiceNotFound: One of the notification services was not found - DELETE: - Description: Delete the reminder - Returns: - 200: - Reminder deleted successfully - 404: - No reminder found with the given id - """ +@api.route( + '/reminders/', + 'Manage a specific reminder', + {'PUT': [[EditTitleVariable, EditTimeVariable, + EditNotificationServicesVariable, TextVariable, + RepeatQuantityVariable, RepeatIntervalVariable, + ColorVariable], + 'Edit the reminder'], + 'DELETE': [[], + 'Delete the reminder']}, + methods=['GET', 'PUT', 'DELETE'] +) +@endpoint_wrapper +def api_get_reminder(inputs: Dict[str, Any], r_id: int): reminders: Reminders = g.user_data.reminders if request.method == 'GET': result = reminders.fetchone(r_id).get() return return_api(result) elif request.method == 'PUT': - data = request.get_json() - title = extract_key(data, 'title', check_existence=False) - time = extract_key(data, 'time', check_existence=False) - notification_services = extract_key(data, 'notification_services', check_existence=False) - text = extract_key(data, 'text', check_existence=False) - repeat_quantity = extract_key(data, 'repeat_quantity', check_existence=False) - repeat_interval = extract_key(data, 'repeat_interval', check_existence=False) - color = extract_key(data, 'color', check_existence=False) - - result = reminders.fetchone(r_id).update(title=title, - time=time, - notification_services=notification_services, - text=text, - repeat_quantity=repeat_quantity, - repeat_interval=repeat_interval, - color=color) + result = reminders.fetchone(r_id).update(title=inputs['title'], + time=inputs['time'], + notification_services=inputs['notification_services'], + text=inputs['text'], + repeat_quantity=inputs['repeat_quantity'], + repeat_interval=inputs['repeat_interval'], + color=inputs['color']) return return_api(result) elif request.method == 'DELETE': @@ -584,93 +601,54 @@ def api_get_reminder(r_id: int): # Template endpoints #=================== -@api.route('/templates', methods=['GET', 'POST']) -@error_handler -@auth -def api_get_templates(): - """ - Endpoint: /templates - Description: Manage the templates - Requires being logged in: Yes - Methods: - GET: - Description: Get a list of all templates - Returns: - 200: - The id, title, text and color of every template - POST: - Description: Add a template - Parameters (body): - title (required): the title of the template - notification_services (required): array of the id's of the notification services to use to send the notification - text: the body of the template - color: the hex code of the color of the template, which is shown in the web-ui - Returns: - 200: - The info about the new template entry - 400: - KeyNotFound: One of the required parameters was not given - 404: - NotificationServiceNotFound: One of the notification services was not found - """ +@api.route( + '/templates', + 'Manage the templates', + {'GET': [[TemplateSortByVariable], + 'Get a list of all templates'], + 'POST': [[TitleVariable, NotificationServicesVariable, + TextVariable, ColorVariable], + 'Add a template']}, + methods=['GET', 'POST'] +) +@endpoint_wrapper +def api_get_templates(inputs: Dict[str, Any]): templates: Templates = g.user_data.templates if request.method == 'GET': - result = templates.fetchall() + result = templates.fetchall(inputs['sort_by']) return return_api(result) elif request.method == 'POST': - data = request.get_json() - title = extract_key(data, 'title') - notification_services = extract_key(data, 'notification_services') - text = extract_key(data, 'text', check_existence=False) - color = extract_key(data, 'color', check_existence=False) - - result = templates.add(title=title, - notification_services=notification_services, - text=text, - color=color) + result = templates.add(title=inputs['title'], + notification_services=inputs['notification_services'], + text=inputs['text'], + color=inputs['color']) return return_api(result.get(), code=201) -@api.route('/templates/', methods=['GET', 'PUT', 'DELETE']) -@error_handler -@auth -def api_get_template(t_id: int): - """ - Endpoint: /templates/ - Description: Manage a specific template - Requires being logged in: Yes - URL Parameters: - : - The id of the template - Methods: - GET: - Returns: - 200: - All info about the template - 404: - No template found with the given id - PUT: - Description: Edit the template - Parameters (body): - title: The new title of the entry. - notification_services: The new array of id's of the notification services to use to send the reminder. - text: The new body of the template. - color: The new hex code of the color of the template. - Returns: - 200: - Template updated successfully - 404: - TemplateNotFound: No template found with the given id - NotificationServiceNotFound: One of the notification services was not found - DELETE: - Description: Delete the template - Returns: - 200: - Template deleted successfully - 404: - No template found with the given id - """ +@api.route( + '/templates/search', + 'Search through the list of templates', + {'GET': [[TemplateSortByVariable, QueryVariable]]}, + methods=['GET'] +) +@endpoint_wrapper +def api_templates_query(inputs: Dict[str, str]): + result = g.user_data.templates.search(inputs['query'], inputs['sort_by']) + return return_api(result) + +@api.route( + '/templates/', + 'Manage a specific template', + {'PUT': [[EditTitleVariable, EditNotificationServicesVariable, + TextVariable, ColorVariable], + 'Edit the template'], + 'DELETE': [[], + 'Delete the template']}, + methods=['GET', 'PUT', 'DELETE'] +) +@endpoint_wrapper +def api_get_template(inputs: Dict[str, Any], t_id: int): template: Template = g.user_data.templates.fetchone(t_id) if request.method == 'GET': @@ -678,16 +656,10 @@ def api_get_template(t_id: int): return return_api(result) elif request.method == 'PUT': - data = request.get_json() - title = extract_key(data, 'title', check_existence=False) - notification_services = extract_key(data, 'notification_services', check_existence=False) - text = extract_key(data, 'text', check_existence=False) - color = extract_key(data, 'color', check_existence=False) - - result = template.update(title=title, - notification_services=notification_services, - text=text, - color=color) + result = template.update(title=inputs['title'], + notification_services=inputs['notification_services'], + text=inputs['text'], + color=inputs['color']) return return_api(result) elif request.method == 'DELETE': @@ -698,120 +670,72 @@ def api_get_template(t_id: int): # Static reminder endpoints #=================== -@api.route('/staticreminders', methods=['GET', 'POST']) -@error_handler -@auth -def api_static_reminders_list(): - """ - Endpoint: /staticreminders - Description: Manage the static reminders - Requires being logged in: Yes - Methods: - GET: - Description: Get a list of all static reminders - Returns: - 200: - The id, title, text and color of each static reminder - POST: - Description: Add a static reminder - Parameters (body): - title (required): the title of the static reminder - notification_services (required): array of the id's of the notification services to use to send the notification - text: the body of the static reminder - color: The hex code of the color of the static reminder, which is shown in the web-ui - Returns: - 200: - The info about the new static reminder entry - 400: - KeyNotFound: One of the required parameters was not given - 404: - NotificationServiceNotFound: One of the notification services was not found - """ +@api.route( + '/staticreminders', + 'Manage the static reminders', + {'GET': [[StaticReminderSortByVariable], + 'Get a list of all static reminders'], + 'POST': [[TitleVariable, NotificationServicesVariable, + TextVariable, ColorVariable], + 'Add a static reminder']}, + methods=['GET', 'POST'] +) +@endpoint_wrapper +def api_static_reminders_list(inputs: Dict[str, Any]): reminders: StaticReminders = g.user_data.static_reminders if request.method == 'GET': - result = reminders.fetchall() + result = reminders.fetchall(inputs['sort_by']) return return_api(result) elif request.method == 'POST': - data = request.get_json() - title = extract_key(data, 'title') - notification_services = extract_key(data, 'notification_services') - text = extract_key(data, 'text', check_existence=False) - color = extract_key(data, 'color', check_existence=False) - - result = reminders.add(title=title, - notification_services=notification_services, - text=text, - color=color) + result = reminders.add(title=inputs['title'], + notification_services=inputs['notification_services'], + text=inputs['text'], + color=inputs['color']) return return_api(result.get(), code=201) -@api.route('/staticreminders/', methods=['GET', 'POST', 'PUT', 'DELETE']) -@error_handler -@auth -def api_get_static_reminder(r_id: int): - """ - Endpoint: /staticreminders/ - Description: Manage a specific static reminder - Requires being logged in: Yes - URL Parameters: - : - The id of the static reminder - Methods: - GET: - Returns: - 200: - All info about the static reminder - 404: - No static reminder found with the given id - POST: - Description: Trigger the static reminder - Returns: - 200: - Static reminder triggered successfully - PUT: - Description: Edit the static reminder - Parameters (body): - title: The new title of the static reminder. - notification_services: The new array of id's of the notification services to use to send the reminder. - text: The new body of the static reminder. - color: The new hex code of the color of the static reminder, which is shown in the web-ui. - Returns: - 200: - Static reminder updated successfully - 404: - ReminderNotFound: No static reminder found with the given id - NotificationServiceNotFound: One of the notification services was not found - DELETE: - Description: Delete the static reminder - Returns: - 200: - Static reminder deleted successfully - 404: - No static reminder found with the given id - """ +@api.route( + '/staticreminders/search', + 'Search through the list of staticreminders', + {'GET': [[StaticReminderSortByVariable, QueryVariable]]}, + methods=['GET'] +) +@endpoint_wrapper +def api_static_reminders_query(inputs: Dict[str, str]): + result = g.user_data.static_reminders.search(inputs['query'], inputs['sort_by']) + return return_api(result) + +@api.route( + '/staticreminders/', + 'Manage a specific static reminder', + {'POST': [[], + 'Trigger the static reminder'], + 'PUT': [[EditTitleVariable, EditNotificationServicesVariable, + TextVariable, ColorVariable], + 'Edit the static reminder'], + 'DELETE': [[], + 'Delete the static reminder']}, + methods=['GET', 'POST', 'PUT', 'DELETE'] +) +@endpoint_wrapper +def api_get_static_reminder(inputs: Dict[str, Any], s_id: int): reminders: StaticReminders = g.user_data.static_reminders if request.method == 'GET': - result = reminders.fetchone(r_id).get() + result = reminders.fetchone(s_id).get() return return_api(result) elif request.method == 'POST': - reminders.trigger_reminder(r_id) - return return_api({}) + reminders.trigger_reminder(s_id) + return return_api({}, code=201) elif request.method == 'PUT': - data = request.get_json() - title = extract_key(data, 'title', check_existence=False) - notification_services = extract_key(data, 'notification_services', check_existence=False) - text = extract_key(data, 'text', check_existence=False) - color = extract_key(data, 'color', check_existence=False) - - result = reminders.fetchone(r_id).update(title=title, - notification_services=notification_services, - text=text, - color=color) + result = reminders.fetchone(s_id).update(title=inputs['title'], + notification_services=inputs['notification_services'], + text=inputs['text'], + color=inputs['color']) return return_api(result) elif request.method == 'DELETE': - reminders.fetchone(r_id).delete() + reminders.fetchone(s_id).delete() return return_api({}) diff --git a/frontend/static/css/general.css b/frontend/static/css/general.css index 2b72338..a93a477 100644 --- a/frontend/static/css/general.css +++ b/frontend/static/css/general.css @@ -155,8 +155,9 @@ header > div { width: var(--height); } -h1 { - font-size: clamp(1.3rem, 7vw, 2rem); +header img { + height: 3rem; + width: fit-content; } /* */ @@ -271,12 +272,12 @@ nav > div > button svg { margin-bottom: 2rem; } -.window-container > div:not(#home) > p { - text-align: center; +.window-container > div:not(#home) > h2:not(:first-of-type) { + margin-top: 1.5rem; } -.window-container > div:not(#home) > p > a { - color: var(--color-light); +.window-container > div:not(#home) > p { + text-align: center; } .tab-selector { @@ -286,7 +287,8 @@ nav > div > button svg { align-items: center; flex-wrap: wrap; gap: 1rem; - + + padding-inline: .5rem; padding-top: 1rem; } diff --git a/frontend/static/css/info.css b/frontend/static/css/info.css index 8d121c3..aaa4517 100644 --- a/frontend/static/css/info.css +++ b/frontend/static/css/info.css @@ -107,6 +107,7 @@ .notification-service-list > div > input { width: fit-content; + box-shadow: none; } .repeat-bar, diff --git a/frontend/static/css/library.css b/frontend/static/css/library.css index 80562e9..bdf57cb 100644 --- a/frontend/static/css/library.css +++ b/frontend/static/css/library.css @@ -45,10 +45,16 @@ opacity: 1; } +#sort-input { + width: min-content; + border: 0; + box-shadow: none; +} + /* REMINDER LIST */ -#reminder-list, -#static-reminder-list, -#template-list { +#reminder-tab, +#static-reminder-tab, +#template-tab { --gap: 1rem; --entry-width: 13rem; max-width: 43rem; diff --git a/frontend/static/css/settings.css b/frontend/static/css/settings.css index a348eb1..f0d9889 100644 --- a/frontend/static/css/settings.css +++ b/frontend/static/css/settings.css @@ -25,6 +25,12 @@ box-shadow: var(--default-shadow); } +.settings-container input, +.settings-container textarea, +.settings-container select { + max-width: 20rem; +} + #change-password-form { display: flex; flex-direction: column; @@ -33,10 +39,32 @@ gap: 1rem; } -#change-password-form > input { - max-width: 20rem; -} - #delete-account-button { background-color: var(--color-error); } + +.contact-list { + width: min(100%, 31rem); + margin: auto; + + display: flex; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; +} + +.contact-list > a { + height: 5rem; + width: 15rem; + + display: flex; + justify-content: center; + align-items: center; + + border-radius: 6px; + background-color: var(--color-gray); + color: var(--color-light); + + text-decoration: none; + font-size: 1.2rem; +} \ No newline at end of file diff --git a/frontend/static/img/favicon.svg b/frontend/static/img/favicon.svg new file mode 100644 index 0000000..878a5d5 --- /dev/null +++ b/frontend/static/img/favicon.svg @@ -0,0 +1,30 @@ + + + + White + + + + Off-white + + + + Gray + + + + Dark gray + + + + + + + + + + + M​ + + + \ No newline at end of file diff --git a/frontend/static/js/general.js b/frontend/static/js/general.js index 32ffd2e..bc74a16 100644 --- a/frontend/static/js/general.js +++ b/frontend/static/js/general.js @@ -1,7 +1,7 @@ const types = { - 'reminder': document.getElementById('reminder-list'), - 'static_reminder': document.getElementById('static-reminder-list'), - 'template': document.getElementById('template-list') + 'reminder': document.getElementById('reminder-tab'), + 'static_reminder': document.getElementById('static-reminder-tab'), + 'template': document.getElementById('template-tab') }; const icons = { @@ -47,15 +47,20 @@ function logout() { 'method': 'POST' }) .then(response => { - localStorage.removeItem('MIND_api_key'); + const new_stor = JSON.parse(localStorage.getItem('MIND')) + new_stor.api_key = null + localStorage.setItem('MIND', JSON.stringify(new_stor)); window.location.href = `${url_prefix}/`; }); }; // code run on load +if (localStorage.getItem('MIND') === null) + localStorage.setItem('MIND', JSON.stringify({'api_key': null, 'locale': 'en-GB'})) + const url_prefix = document.getElementById('url_prefix').dataset.value; -const api_key = localStorage.getItem('MIND_api_key'); +const api_key = JSON.parse(localStorage.getItem('MIND')).api_key; if (api_key === null) { window.location.href = `${url_prefix}/`; }; diff --git a/frontend/static/js/library.js b/frontend/static/js/library.js index 3f9d990..215d006 100644 --- a/frontend/static/js/library.js +++ b/frontend/static/js/library.js @@ -1,3 +1,25 @@ +const sorting_options = {}; +sorting_options[types.reminder.id] = [ + ['time', 'Time'], + ['time_reversed', 'Time Reversed'], + ['title', 'Title'], + ['title_reversed', 'Title Reversed'], + ['date_added', 'Date Added'], + ['date_added_reversed', 'Date Added Reversed'] +]; +sorting_options[types.static_reminder.id] = [ + ['title', 'Title'], + ['title_reversed', 'Title Reversed'], + ['date_added', 'Date Added'], + ['date_added_reversed', 'Date Added Reversed'] +]; +sorting_options[types.template.id] = [ + ['title', 'Title'], + ['title_reversed', 'Title Reversed'], + ['date_added', 'Date Added'], + ['date_added_reversed', 'Date Added Reversed'] +]; + function showTab(button) { // Apply styling to selected button document.querySelectorAll('.tab-selector > button').forEach( @@ -5,10 +27,13 @@ function showTab(button) { ); // Show desired tab and hide all others - document.querySelectorAll('#home > div:not(.tab-selector)').forEach( + document.querySelectorAll('#home > div:not(.tab-selector):not(.search-container)').forEach( e => e.classList.add('hidden') ); document.getElementById(button.dataset.target).classList.remove('hidden'); + + fillSortOptions(); + document.querySelector('#search-input').value = ''; }; // @@ -33,7 +58,7 @@ function fillTable(table, results) { const time = document.createElement('p'); var offset = new Date(r.time * 1000).getTimezoneOffset() * -60; var d = new Date((r.time + offset) * 1000); - var formatted_date = d.toLocaleString('en-CA').slice(0,10) + ' ' + d.toTimeString().slice(0,5); + var formatted_date = d.toLocaleString(JSON.parse(localStorage.getItem('MIND')).locale); if (r.repeat_interval !== null) { if (r.repeat_interval === 1) { var quantity = r.repeat_quantity.endsWith('s') ? r.repeat_quantity.slice(0, -1) : r.repeat_quantity; @@ -72,28 +97,110 @@ function fillLibrary(url, type) { }; function fillReminders() { - fillLibrary(`/api/reminders?api_key=${api_key}`, types.reminder); + const sorting = document.querySelector('#sort-input').value; + fillLibrary(`/api/reminders?api_key=${api_key}&sort_by=${sorting}`, types.reminder); }; -function fillStaticReminders() { - fillLibrary(`/api/staticreminders?api_key=${api_key}`, types.static_reminder); +function fillStaticReminders(assume_sorting=false) { + let sorting; + if (assume_sorting) + sorting = sorting_options[types.static_reminder.id][0][0]; + else + sorting = document.querySelector('#sort-input').value; + fillLibrary(`/api/staticreminders?api_key=${api_key}&sort_by=${sorting}`, types.static_reminder); } -function fillTemplates() { - fillLibrary(`/api/templates?api_key=${api_key}`, types.template); +function fillTemplates(assume_sorting=false) { + let sorting; + if (assume_sorting) + sorting = sorting_options[types.template.id][0][0]; + else + sorting = document.querySelector('#sort-input').value; + fillLibrary(`/api/templates?api_key=${api_key}&sort_by=${sorting}`, types.template); }; // // Library search // function searchLibrary() { + const query = document.querySelector('#search-input').value, + tab = document.getElementById( + document.querySelector('.tab-selector > button[data-selected="true"]').dataset.target + ) + const sorting = document.querySelector('#sort-input').value; + let url; + if (tab === types.reminder) + url = `${url_prefix}/api/reminders/search?api_key=${api_key}&query=${query}&sort_by=${sorting}`; + else if (tab === types.static_reminder) + url = `${url_prefix}/api/staticreminders/search?api_key=${api_key}&query=${query}&sort_by=${sorting}`; + else if (tab === types.template) + url = `${url_prefix}/api/templates/search?api_key=${api_key}&query=${query}&sort_by=${sorting}`; + else return; + + fillLibrary(url, tab); +}; + +function clearSearchLibrary() { + document.querySelector('#search-input').value = ''; + const tab = document.getElementById( + document.querySelector('.tab-selector > button[data-selected="true"]').dataset.target + ) + if (tab === types.reminder) + fillReminders(); + else if (tab === types.static_reminder) + fillStaticReminders(); + else if (tab === types.template) + fillTemplates(); + else return; +}; + +// +// Library sort +// +function fillSortOptions() { + const tab = document.getElementById( + document.querySelector('.tab-selector > button[data-selected="true"]').dataset.target + ) + const sort_options = sorting_options[tab.id]; + + const select = document.getElementById('sort-input'); + select.innerHTML = ''; + sort_options.forEach(o => { + const entry = document.createElement('option'); + entry.value = o[0] + entry.innerText = o[1] + select.appendChild(entry); + }); + select.querySelector(':first-child').setAttribute('selected', ''); +}; + +function applySorting() { const query = document.querySelector('#search-input').value; - fetch(`${url_prefix}/api/reminders/search?api_key=${api_key}&query=${query}`) + if (query !== '') { + searchLibrary(); + return; + }; + + const sorting = document.getElementById('sort-input').value, + tab = document.getElementById( + document.querySelector('.tab-selector > button[data-selected="true"]').dataset.target + ) + + let url; + if (tab === types.reminder) + url = `${url_prefix}/api/reminders?api_key=${api_key}&sort_by=${sorting}`; + else if (tab === types.static_reminder) + url = `${url_prefix}/api/staticreminders?api_key=${api_key}&sort_by=${sorting}`; + else if (tab === types.template) + url = `${url_prefix}/api/templates?api_key=${api_key}&sort_by=${sorting}`; + else return; + + fetch(url) .then(response => { if (!response.ok) return Promise.reject(response.status); return response.json(); }) - .then(json => fillTable(types.reminder, json.result)) + .then(json => fillTable(tab, json.result)) .catch(e => { if (e === 401) window.location.href = `${url_prefix}/`; @@ -102,21 +209,18 @@ function searchLibrary() { }); }; -function clearSearchLibrary() { - document.querySelector('#search-input').value = ''; - fillReminders(); -}; - // code run on load document.querySelectorAll('.tab-selector > button').forEach(b => { b.addEventListener('click', e => showTab(b)); }); +fillSortOptions(); fillReminders(); -fillStaticReminders(); -fillTemplates(); +fillStaticReminders(assume_sorting=true); +fillTemplates(assume_sorting=true); setInterval(fillReminders, 60000); document.querySelector('#search-form').setAttribute('action', 'javascript:searchLibrary();'); document.querySelector('#clear-button').addEventListener('click', e => clearSearchLibrary()); +document.querySelector('#sort-input').addEventListener('change', e => applySorting()); diff --git a/frontend/static/js/login.js b/frontend/static/js/login.js index 2adae30..36cd44c 100644 --- a/frontend/static/js/login.js +++ b/frontend/static/js/login.js @@ -44,7 +44,9 @@ function login(data=null) { return response.json(); }) .then(json => { - localStorage.setItem('MIND_api_key', json.result.api_key); + const new_stor = JSON.parse(localStorage.getItem('MIND')); + new_stor.api_key = json.result.api_key; + localStorage.setItem('MIND', JSON.stringify(new_stor)); window.location.href = `${url_prefix}/reminders`; }) .catch(e => { @@ -93,7 +95,7 @@ function create() { }; function checkLogin() { - fetch(`${url_prefix}/api/auth/status?api_key=${localStorage.getItem('MIND_api_key')}`) + fetch(`${url_prefix}/api/auth/status?api_key=${JSON.parse(localStorage.getItem('MIND')).api_key}`) .then(response => { if (!response.ok) return Promise.reject(response.status); window.location.href = '/reminders'; @@ -108,6 +110,9 @@ function checkLogin() { // code run on load +if (localStorage.getItem('MIND') === null) + localStorage.setItem('MIND', JSON.stringify({'api_key': null, 'locale': 'en-GB'})) + const url_prefix = document.getElementById('url_prefix').dataset.value; checkLogin(); diff --git a/frontend/static/js/notification.js b/frontend/static/js/notification.js index 37679bd..272937b 100644 --- a/frontend/static/js/notification.js +++ b/frontend/static/js/notification.js @@ -1,9 +1,3 @@ -const inputs_buttons = { - 'save_button': document.querySelector('#add-row button[data-type="save"]'), - 'title': document.querySelector('#add-row td.title-column input'), - 'url': document.querySelector('#add-row td.url-column input') -}; - function fillNotificationSelection() { fetch(`${url_prefix}/api/notificationservices?api_key=${api_key}`) .then(response => { @@ -33,6 +27,7 @@ function fillNotificationSelection() { const table = document.getElementById('services-list'); table.querySelectorAll('tr:not(#add-row)').forEach(e => e.remove()); + // table.innerHTML = ''; json.result.forEach(service => { const entry = document.createElement('tr'); entry.dataset.id = service.id; @@ -158,13 +153,165 @@ function deleteService(id) { function toggleAddService() { document.getElementById('add-row').classList.toggle('hidden'); + return; + + const cont = document.querySelector('.overflow-container'); + if (cont.classList.contains('show-add')) { + // Hide add + cont.classList.remove('show-add'); + hideAddServiceWindow(); + } else { + // Show add + if (notification_services === null) { + fetch(`${url_prefix}/api/notificationservices/available?api_key=${api_key}`) + .then(response => response.json()) + .then(json => { + notification_services = json.result; + const table = document.querySelector('#service-list'); + json.result.forEach((result, index) => { + const entry = document.createElement('button'); + entry.innerText = result.name; + entry.addEventListener('click', e => showAddServiceWindow(index)); + table.appendChild(entry); + }); + }); + }; + cont.classList.add('show-add'); + }; +}; + +function showAddServiceWindow(index) { + const window = document.getElementById('add-service-window'); + window.innerHTML = ''; + + if (index === -1) { + // Custom url + const title = document.createElement('h3'); + title.innerText = 'Custom URL'; + window.appendChild(title); + + const desc = document.createElement('p'); + desc.innerHTML = 'Enter a custom Apprise URL. See the Apprise URL documentation.'; + window.appendChild(desc); + + const service_title = document.createElement('input'); + service_title.id = 'service-title'; + service_title.type = 'text'; + service_title.placeholder = 'Service Title'; + service_title.required = true; + window.appendChild(service_title); + + const url_input = document.createElement('input'); + url_input.type = 'text'; + url_input.placeholder = 'Apprise URL'; + window.appendChild(url_input); + } else { + const data = notification_services[index]; + console.log(data); + + const title = document.createElement('h3'); + title.innerText = data.name; + window.appendChild(title); + + const docs = document.createElement('a'); + docs.href = data.doc_url; + docs.target = '_blank'; + docs.innerText = 'Documentation'; + window.appendChild(docs); + + const service_title = document.createElement('input'); + service_title.id = 'service-title'; + service_title.type = 'text'; + service_title.placeholder = 'Service Title'; + service_title.required = true; + window.appendChild(service_title); + + data.details.tokens.forEach(token => { + if (token.type === 'choice') { + const choice = document.createElement('select'); + choice.dataset.map = token.map_to; + choice.dataset.prefix = ''; + choice.placeholder = token.name; + choice.required = token.required; + token.options.forEach(option => { + const entry = document.createElement('option'); + entry.value = option; + entry.innerText = option; + choice.appendChild(entry); + }); + window.appendChild(choice); + + } else if (token.type === 'list') { + if (token.content.length === 0) { + + } else { + token.content.forEach(content => { + + }); + }; + + } else if (token.type === 'string') { + const str_input = document.createElement('input'); + str_input.dataset.map = token.map_to; + str_input.dataset.prefix = token.prefix; + str_input.dataset.regex = token.regex; + str_input.type = 'text'; + str_input.placeholder = `${token.name}${!token.required ? ' (Optional)' : ''}`; + str_input.required = token.required; + window.appendChild(str_input); + + } else if (token.type === 'int') { + const int_input = document.createElement('input'); + int_input.dataset.map = token.map_to; + int_input.dataset.prefix = token.prefix; + int_input.type = 'number'; + int_input.placeholder = `${token.name}${!token.required ? ' (Optional)' : ''}`; + int_input.required = token.required; + if (token.min !== null) + int_input.min = token.min; + if (token.max !== null) + int_input.max = token.max; + window.appendChild(int_input); + }; + }); + }; + + // Bottom options + const options = document.createElement('div'); + options.classList.add('options'); + const cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.innerText = 'Cancel'; + cancel.addEventListener('click', e => toggleAddService()); + options.appendChild(cancel); + const add = document.createElement('button'); + add.type = 'submit'; + add.innerText = 'Add'; + options.appendChild(add); + window.appendChild(options); + + document.getElementById('add-service-container').classList.add('show-add-window'); +}; + +function hideAddServiceWindow() { + document.getElementById('add-service-container').classList.remove('show-add-window'); +}; + +function buildAppriseURL() { + return null; }; function addService() { + const add_button = document.querySelector('#add-row > .action-column > button'); const data = { - 'title': inputs_buttons.title.value, - 'url': inputs_buttons.url.value + 'title': document.querySelector('#add-row > .title-column > input').value, + 'url': document.querySelector('#add-row > .url-column > input').value }; + // const add_button = document.querySelector('#add-service-window > .options > button[type="submit"]'); + // const data = { + // 'title': document.querySelector('#service-title').value, + // 'url': buildAppriseURL() + // }; fetch(`${url_prefix}/api/notificationservices?api_key=${api_key}`, { 'method': 'POST', 'headers': {'Content-Type': 'application/json'}, @@ -173,13 +320,9 @@ function addService() { .then(response => { if (!response.ok) return Promise.reject(response.status); - inputs_buttons.title.value = ''; - inputs_buttons.url.value = ''; + add_button.classList.remove('error-input'); + add_button.title = ''; - inputs_buttons.save_button.classList.remove('error-icon'); - inputs_buttons.save_button.title = 'Add'; - inputs_buttons.save_button.setAttribute('aria-label', 'Add'); - toggleAddService(); fillNotificationSelection(); }) @@ -187,9 +330,9 @@ function addService() { if (e === 401) window.location.href = `${url_prefix}/`; else if (e === 400) { - inputs_buttons.save_button.classList.add('error-icon'); - inputs_buttons.save_button.title = 'Invalid Apprise URL'; - inputs_buttons.save_button.setAttribute('aria-label', 'Invalid Apprise URL'); + // add_button.classList.add('error-input'); + add_button.classList.add('error-icon'); + add_button.title = 'Invalid Apprise URL'; } else console.log(e); }); @@ -199,5 +342,9 @@ function addService() { fillNotificationSelection(); +let notification_services = null; + document.getElementById('add-service-button').addEventListener('click', e => toggleAddService()); -document.querySelector('#add-row button[data-type="save"]').addEventListener('click', e => addService()); +// document.querySelector('#service-list button').addEventListener('click', e => showAddServiceWindow(-1)); +// document.getElementById('add-service-window').setAttribute('action', 'javascript:addService();'); +document.querySelector('#add-row > .action-column > button').addEventListener('click', e => addService()); diff --git a/frontend/static/js/settings.js b/frontend/static/js/settings.js index ef21adc..b422e51 100644 --- a/frontend/static/js/settings.js +++ b/frontend/static/js/settings.js @@ -1,3 +1,7 @@ +function loadSettings() { + document.getElementById('locale-input').value = JSON.parse(localStorage.getItem('MIND')).locale; +}; + function changePassword() { const data = { 'new_password': document.getElementById('password-input').value @@ -19,6 +23,13 @@ function changePassword() { }); }; +function updateLocale(e) { + const new_stor = JSON.parse(localStorage.getItem('MIND')); + new_stor.locale = e.target.value; + localStorage.setItem('MIND', JSON.stringify(new_stor)); + window.location.reload(); +}; + function deleteAccount() { fetch(`${url_prefix}/api/user?api_key=${api_key}`, { 'method': 'DELETE' @@ -30,5 +41,8 @@ function deleteAccount() { // code run on load +loadSettings(); + document.getElementById('change-password-form').setAttribute('action', 'javascript:changePassword()'); +document.getElementById('locale-input').addEventListener('change', updateLocale); document.getElementById('delete-account-button').addEventListener('click', e => deleteAccount()); diff --git a/frontend/static/js/window.js b/frontend/static/js/window.js index 13d4c61..834b2d5 100644 --- a/frontend/static/js/window.js +++ b/frontend/static/js/window.js @@ -94,7 +94,7 @@ function testReminder() { const ns = [... document.querySelectorAll('.notification-service-list input[type="checkbox"]:checked') ].map(c => parseInt(c.dataset.id)) - if (!ns) { + if (!ns.length) { input.classList.add('error-input'); input.title = 'No notification service set'; return @@ -143,10 +143,17 @@ function deleteInfo() { .then(response => { if (!response.ok) return Promise.reject(response.status); - fillNotificationSelection(); - fillReminders(); - fillStaticReminders(); - fillTemplates(); + if (cl.contains('show-edit-reminder')) { + // Delete reminder + fillReminders(); + } else if (cl.contains('show-edit-template')) { + // Delete template + fillTemplates(); + loadTemplateSelection(); + } else if (cl.contains('show-edit-static-reminder')) { + // Delete static reminder + fillStaticReminders(); + }; hideWindow(); }) .catch(e => { @@ -192,7 +199,7 @@ function submitInfo() { data['time'] = (new Date(inputs.time.value) / 1000) + (new Date(inputs.time.value).getTimezoneOffset() * 60) if (type_buttons.repeat_button.dataset.selected === 'true') { data['repeat_quantity'] = type_buttons.repeat_quantity.value; - data['repeat_interval'] = type_buttons.repeat_interval.value + data['repeat_interval'] = parseInt(type_buttons.repeat_interval.value) }; fetch_data.url = `${url_prefix}/api/reminders?api_key=${api_key}`; fetch_data.method = 'POST'; @@ -218,7 +225,7 @@ function submitInfo() { data['time'] = (new Date(inputs.time.value) / 1000) + (new Date(inputs.time.value).getTimezoneOffset() * 60) if (type_buttons.repeat_button.dataset.selected === 'true') { data['repeat_quantity'] = type_buttons.repeat_quantity.value; - data['repeat_interval'] = type_buttons.repeat_interval.value + data['repeat_interval'] = parseInt(type_buttons.repeat_interval.value) }; fetch_data.url = `${url_prefix}/api/reminders/${e_id}?api_key=${api_key}`; fetch_data.method = 'PUT'; diff --git a/frontend/templates/login.html b/frontend/templates/login.html index 02e93f0..5639ab8 100644 --- a/frontend/templates/login.html +++ b/frontend/templates/login.html @@ -6,6 +6,8 @@ + + @@ -14,7 +16,7 @@
-

MIND Reminders

+
diff --git a/frontend/templates/page_not_found.html b/frontend/templates/page_not_found.html index 1d7b929..5f47268 100644 --- a/frontend/templates/page_not_found.html +++ b/frontend/templates/page_not_found.html @@ -5,6 +5,8 @@ + + @@ -12,7 +14,7 @@
-

MIND Reminders

+

MIND

diff --git a/frontend/templates/reminders.html b/frontend/templates/reminders.html index 2050993..47e7198 100644 --- a/frontend/templates/reminders.html +++ b/frontend/templates/reminders.html @@ -6,6 +6,8 @@ + + @@ -33,7 +35,7 @@ -

MIND Reminders

+
-
-
- -
- -
-
-
- + + + +
+ +
+
+ -
+ + +

Add a notification service first!

+ @@ -272,9 +269,72 @@ +

Locale

+

Delete Account

+

Contact and Donation

+
diff --git a/project_management/docs-requirements.txt b/project_management/docs-requirements.txt new file mode 100644 index 0000000..37c7ed3 --- /dev/null +++ b/project_management/docs-requirements.txt @@ -0,0 +1,6 @@ +wheel>=0.38.4 +mkdocs-material>=8.5.11 +mkdocs-redirects>=1.2.0 +mkdocs-git-revision-date-localized-plugin>=1.1.0 +Pygments>=2.13.0 +pymdown-extensions>=9.9 diff --git a/project_management/generate_api_docs.py b/project_management/generate_api_docs.py new file mode 100644 index 0000000..98c8935 --- /dev/null +++ b/project_management/generate_api_docs.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +#-*- coding: utf-8 -*- + +from sys import path +from os.path import dirname + +path.insert(0, dirname(path[0])) + +from subprocess import run +from typing import Union +from frontend.api import (DataSource, NotificationServiceNotFound, + ReminderNotFound, TemplateNotFound, api_docs) +from MIND import _folder_path, api_prefix + +url_var_map = { + 'int:n_id': NotificationServiceNotFound, + 'int:r_id': ReminderNotFound, + 'int:t_id': TemplateNotFound, + 'int:s_id': ReminderNotFound +} + +result = f"""# API +Below is the API documentation. Report an issue on [GitHub](https://github.com/Casvt/MIND/issues). + +All endpoints have the `{api_prefix}` prefix. That means, for example, that `/auth/login` can be reached at `{api_prefix}/auth/login`. + +## Authentication + +Authentication is done using an API key. +To log in, make a POST request to the [`{api_prefix}/auth/login`](#authlogin) endpoint. +You'll receive an API key, which you can then use in your requests to authenticate. +Supply it via the url parameter `api_key`. +This API key is valid for one hour after which the key expires, any further requests return 401 'APIKeyExpired' and you are required to log in again. +If no `api_key` is supplied or it is invalid, 401 `APIKeyInvalid` is returned. + +For example: +```bash +curl -sSL 'http://192.168.2.15:8080{api_prefix}/reminders?api_key=ABCDEFG' +``` + +## Supplying data + +Often, data needs to be supplied with a request. +If the parameters need to be supplied via `url`, add them to the url as url parameters. +If the parameters need to be supplied via `body`, add them to the body as a json object and supply the `Content-Type: application/json` header. + +For example: +```bash +# URL parameter +curl -sSL 'http://192.168.2.15:8080{api_prefix}/reminders/search?api_key=ABCDEFG&query=Fountain&sort_by=time_reversed' + +# Body parameter +curl -sSLX POST \\ + -H 'Content-Type: application/json' \\ + -d '{{"title": "Test service", "url": "test://fake/url"}}' \\ + 'http://192.168.2.15:8080{api_prefix}/notificationservices?api_key=ABCDEFG' +``` + +## Endpoints +The following is automatically generated. Please report any issues on [GitHub](https://github.com/Casvt/MIND/issues). +""" + +for rule, data in api_docs.items(): + result += f"""### `{rule}` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| {'Yes' if data['requires_auth'] else 'No'} | {data['description']} | +""" + + url_var = rule.replace('<', '>').split('>') + url_var: Union[str, None] = None if len(url_var) == 1 else url_var[1] + + if url_var: + result += f""" +Replace `<{url_var}>` with the ID of the entry. For example: `{rule.replace(f'<{url_var}>', '2')}`. +""" + + for method in data['methods']: + result += f"\n??? {method}\n" + + if method in data['method_descriptions']: + result += f"\n {data['method_descriptions'][method]}\n" + + var_types = { + 'url': list(var for var in data['input_variables'].get(method, []) if var.source == DataSource.VALUES), + 'body': list(var for var in data['input_variables'].get(method, []) if var.source == DataSource.DATA) + } + + for var_type, entries in var_types.items(): + if entries: + entries = [e('') for e in entries] + result += f""" + **Parameters ({var_type})** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | +""" + for entry in entries: + result += f" {entry}\n" + + result += f""" + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | {201 if method == 'POST' else 200}| N/A | Success | +""" + + url_exception = [url_var_map[url_var]] if url_var in url_var_map else [] + variable_exceptions = [e for v in data['input_variables'].get(method, []) for e in v.related_exceptions] + related_exceptions = sorted( + (e() for e in set(variable_exceptions + url_exception)), + key=lambda e: (e.api_response['code'], e.api_response['error']) + ) + for related_exception in related_exceptions: + ar = related_exception.api_response + result += f" | {ar['code']} | {ar['error']} | {related_exception.__doc__} |\n" + + result += '\n' + +with open(_folder_path('docs', 'api.md'), 'r') as f: + current_content = f.read() + +if current_content == result: + print('Nothing changed') +else: + with open(_folder_path('docs', 'api.md'), 'w+') as f: + f.write(result) + + run(["git", "config", "--global", "user.email", '"casvantijn@gmail.com"']) + run(["git", "config", "--global", "user.name", '"CasVT"']) + run(["git", "checkout", "Development"]) + run(["git", "add", _folder_path('docs', 'api.md')]) + run(["git", "commit", "-m", "Updated API docs"]) + run(["git", "push"]) diff --git a/project_management/mkdocs.yml b/project_management/mkdocs.yml new file mode 100644 index 0000000..be312fd --- /dev/null +++ b/project_management/mkdocs.yml @@ -0,0 +1,85 @@ +# Site Details +site_name: MIND Docs +site_description: The official guide for MIND +site_author: Casvt +repo_url: https://github.com/Casvt/MIND +edit_uri: blob/Development/docs/ +repo_name: Casvt/MIND +docs_dir: ../docs + +extra_css: + - assets/css/extra.css + +# Site appearance (logos, colours, icons) +theme: + name: material + language: en + code_wrap: true + features: + - content.tabs.link + - content.code.copy + - header.autohide + - navigation.expand + - navigation.indexes + - navigation.instant + - navigation.sections + - navigation.tabs + - navigation.tabs.sticky + - navigation.top + - navigation.tracking + favicon: assets/img/favicon.svg + logo: assets/img/favicon.svg + palette: + # Light mode + - media: "(prefers-color-scheme: light)" + scheme: MIND + toggle: + icon: material/toggle-switch-off-outline + name: Switch to dark mode + # Dark mode + - media: "(prefers-color-scheme: dark)" + scheme: MIND-dark + toggle: + icon: material/toggle-switch + name: Switch to light mode + +# Markdown extensions +markdown_extensions: + - abbr + - attr_list + - meta + - pymdownx.details + - pymdownx.highlight: + guess_lang: true + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.saneheaders + - pymdownx.smartsymbols + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - sane_lists + - toc: + permalink: true + toc_depth: 5 + - tables + +# mkdocs function extensions +plugins: + - search + - git-revision-date-localized: + type: timeago + locale: en + fallback_to_build_date: false + +# Navigation Layout +nav: + - Home: index.md + - Getting Started: + - Installation: installation.md + - Setup After Installation: setup_after_installation.md + - Other Docs: + - API: api.md + - Backup Your Data: backup_your_data.md diff --git a/tests/api_test.py b/tests/api_test.py index c062c4d..1e7671b 100644 --- a/tests/api_test.py +++ b/tests/api_test.py @@ -3,7 +3,7 @@ import unittest from flask import Blueprint from backend.custom_exceptions import * -from frontend.api import api, auth, error_handler, extract_key, return_api +from frontend.api import api, return_api class Test_API(unittest.TestCase): def test_blueprint(self): @@ -22,41 +22,4 @@ class Test_API(unittest.TestCase): self.assertEqual(result[1], case['code']) else: self.assertEqual(result[1], 200) - - def test_auth(self): - method = lambda x: x - result = auth(method) - self.assertEqual(result.__name__, method.__name__) - - def _raise_exception(self, e, *args): - raise e(*args) - - def test_error_handler(self): - result = error_handler(self._raise_exception) - self.assertEqual(result.__name__, self._raise_exception.__name__) - self.assertEqual(result(UsernameTaken), return_api(**UsernameTaken.api_response)) - self.assertEqual(result(UsernameInvalid), return_api(**UsernameInvalid.api_response)) - self.assertEqual(result(UserNotFound), return_api(**UserNotFound.api_response)) - self.assertEqual(result(AccessUnauthorized), return_api(**AccessUnauthorized.api_response)) - self.assertEqual(result(ReminderNotFound), return_api(**ReminderNotFound.api_response)) - self.assertEqual(result(NotificationServiceNotFound), return_api(**NotificationServiceNotFound.api_response)) - self.assertEqual(result(InvalidTime), return_api(**InvalidTime.api_response)) - self.assertEqual(result(InvalidURL), return_api(**InvalidURL.api_response)) - self.assertEqual(result(NotificationServiceInUse, 'test'), return_api(**NotificationServiceInUse('test').api_response)) - self.assertEqual(result(KeyNotFound, 'test'), return_api(**KeyNotFound('test').api_response)) - self.assertEqual(result(InvalidKeyValue, 'test', 'value'), return_api(**InvalidKeyValue('test', 'value').api_response)) - with self.assertRaises(TypeError): - result(TypeError) - with self.assertRaises(KeyError): - result(KeyError) - - def test_extract_key(self): - with self.assertRaises(KeyNotFound): - extract_key({'test': 'value'}, 'no_key') - self.assertIsNone(extract_key({'test': 'value'}, 'no_key', check_existence=False)) - with self.assertRaises(InvalidKeyValue): - extract_key({'time': ''}, 'time') - self.assertIsInstance(extract_key({'time': '1'}, 'time'), int) - - \ No newline at end of file diff --git a/tests/custom_exceptions_test.py b/tests/custom_exceptions_test.py index 07547aa..88388da 100644 --- a/tests/custom_exceptions_test.py +++ b/tests/custom_exceptions_test.py @@ -7,9 +7,19 @@ import backend.custom_exceptions class Test_Custom_Exceptions(unittest.TestCase): def test_type(self): - defined_exceptions: List[Exception] = map(lambda c: c[1], getmembers(modules['backend.custom_exceptions'], isclass)) + defined_exceptions: List[Exception] = filter( + lambda c: c.__module__ == 'backend.custom_exceptions' and c is not backend.custom_exceptions.CustomException, + map( + lambda c: c[1], + getmembers(modules['backend.custom_exceptions'], isclass) + ) + ) + for defined_exception in defined_exceptions: - self.assertEqual(getmro(defined_exception)[1], Exception) + self.assertEqual( + getmro(defined_exception)[1], + backend.custom_exceptions.CustomException + ) result = defined_exception().api_response self.assertIsInstance(result, dict) result['error'] diff --git a/tests/db_test.py b/tests/db_test.py index 965114f..13b3802 100644 --- a/tests/db_test.py +++ b/tests/db_test.py @@ -8,5 +8,3 @@ class Test_DB(unittest.TestCase): DBConnection.file = _folder_path(*DB_FILENAME) instance = DBConnection(timeout=20.0) self.assertEqual(instance.cursor().execute("PRAGMA foreign_keys;").fetchone()[0], 1) - - \ No newline at end of file diff --git a/tests/reminders_test.py b/tests/reminders_test.py index 4ea59ce..c394d43 100644 --- a/tests/reminders_test.py +++ b/tests/reminders_test.py @@ -11,10 +11,9 @@ class Test_Reminder_Handler(unittest.TestCase): def test_filter_function(self): p = { 'title': 'TITLE', - 'text': 'TEXT', - 'notification_service_title': 'NOTIFICATION_SERVICE_TITLE' + 'text': 'TEXT' } - for test_case in ('', 'title', 'service', 'ex'): + for test_case in ('', 'title', 'ex'): self.assertTrue(filter_function(test_case, p)) for test_case in (' ', 'Hello'): self.assertFalse(filter_function(test_case, p))