diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..8a05031 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: casvt +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..60dceea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug, enhancement +assignees: '' + +--- + +**Description of the bug** + + +**To Reproduce** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behaviour** + + +**Screenshots** + + +**Version info** + - OS: [e.g. iOS] + - Browser: [e.g. chrome, safari] + - Noted Version: [e.g. V1.0.0] + - Python Version: [e.g. V3.7] + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/contribute-request.md b/.github/ISSUE_TEMPLATE/contribute-request.md new file mode 100644 index 0000000..1cadcdd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/contribute-request.md @@ -0,0 +1,12 @@ +--- +name: Contribute request +about: Announce that you want to contribute to the project +title: '' +labels: contributing +assignees: '' + +--- + +**What do you want to contribute? (e.g. add a feature, fix a bug, improve documentation)** + +**How long do you think it will take to finish it? (e.g. one week, one month, three months)** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..37800ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** + + +**Describe the solution you'd like** + + +**Additional context** + diff --git a/.gitignore b/.gitignore index 3e4c422..f1306e4 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,6 @@ dmypy.json # VS code *.code-workspace -.vscode/ \ No newline at end of file + +# Docker +Dockerfile \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c79b2a6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "*_test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2f226f4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +casvantijn@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..082e749 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing to Noted +## General steps +Contributing to Noted consists of 5 steps, listed hereunder. + +1. Make a [contributing request](https://github.com/Casvt/Noted/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). +3. When done, create a pull request to the Development branch, where you mention again what you've changed/added and give a link to the original contributing request issue. +4. The PR will be reviewed and if requested, changes will need to be made before it is accepted. +5. When everything is okay, the PR will be accepted and you'll be done! + +## 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). +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: +```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). + +## Styling guide +The code of Noted is written in such way that it follows the following rules. Your code should too. + +1. Compatible with python 3.7 . +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. diff --git a/Noted.py b/Noted.py index 68d07b2..a227985 100644 --- a/Noted.py +++ b/Noted.py @@ -2,32 +2,33 @@ #-*- coding: utf-8 -*- from os import urandom -from os.path import dirname, join +from os.path import dirname, join, abspath from sys import version_info from flask import Flask, render_template, request from waitress.server import create_server -from backend.db import close_db, setup_db +from backend.db import close_db, setup_db, DBConnection from frontend.api import api, reminder_handler from frontend.ui import ui HOST = '0.0.0.0' PORT = '8080' THREADS = 10 +DB_FILENAME = 'Noted.db' def _folder_path(*folders) -> str: """Turn filepaths relative to the project folder into absolute paths Returns: str: The absolute filepath """ - return join(dirname(__file__), *folders) + return join(dirname(abspath(__file__)), *folders) def _create_app() -> Flask: """Create a Flask app instance Returns: Flask: The created app instance - """ + """ app = Flask( __name__, template_folder=_folder_path('frontend','templates'), @@ -78,6 +79,7 @@ def Noted() -> None: # Register web server app = _create_app() with app.app_context(): + DBConnection.file = _folder_path(DB_FILENAME) setup_db() reminder_handler._find_next_reminder() diff --git a/README.md b/README.md index 5439543..83ea8f4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,32 @@ -# Noted +# Noted Reminders A simple self hosted reminder platform that uses push to send notifications to your device. Set the reminder and forget about it! 📢 -See the [wiki](https://github.com/Casvt/Noted/wiki) for instructions on how to install and setup Noted. \ No newline at end of file +Noted 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 + +![noted-reminders-dashboard-cards](https://user-images.githubusercontent.com/57927413/212755016-05b99226-3f6c-48b7-b99a-253e15c82947.png) + +![noted-reminders-add-notification-services](https://user-images.githubusercontent.com/57927413/212755314-1104531e-7feb-4e59-af1d-927576e47152.png) + +![noted-reminders-settings](https://user-images.githubusercontent.com/57927413/212755327-b45da53c-72f7-480c-9a77-eaad28803fbb.png) + +## Core Features +* Basic auth +* Utilizes Apprise +* Create and delete reminders +* Schedule reminders + +## Planned Features +You can see our planned features in our [Project board](https://github.com/users/Casvt/projects/3). + +## Installation + +``` +sudo apt-get install git python3-pip +sudo git clone https://github.com/Casvt/Noted.git /opt/Noted +cd /opt/Noted +python3 -m pip install -r requirements.txt +python3 Noted.py +``` +You can see the [wiki](https://github.com/Casvt/Noted/wiki) for instructions on how to install using other OS. diff --git a/backend/custom_exceptions.py b/backend/custom_exceptions.py index be9e831..9534954 100644 --- a/backend/custom_exceptions.py +++ b/backend/custom_exceptions.py @@ -1,5 +1,7 @@ #-*- coding: utf-8 -*- +from typing import Any, Dict + class UsernameTaken(Exception): """The username is already taken""" api_response = {'error': 'UsernameTaken', 'result': {}, 'code': 400} @@ -26,7 +28,13 @@ class NotificationServiceNotFound(Exception): class NotificationServiceInUse(Exception): """The notification service is wished to be deleted but a reminder is still using it""" - api_response = {'error': 'NotificationServiceInUse', 'result': {}, 'code': 400} + def __init__(self, type: str=''): + self.type = type + super().__init__(self.type) + + @property + def api_response(self) -> Dict[str, Any]: + return {'error': 'NotificationServiceInUse', 'result': {'type': self.type}, 'code': 400} class InvalidTime(Exception): """The time given is in the past""" @@ -43,7 +51,7 @@ class KeyNotFound(Exception): super().__init__(self.key) @property - def api_response(self): + def api_response(self) -> Dict[str, Any]: return {'error': 'KeyNotFound', 'result': {'key': self.key}, 'code': 400} class InvalidKeyValue(Exception): @@ -54,5 +62,9 @@ class InvalidKeyValue(Exception): super().__init__(self.key) @property - def api_response(self): - return {'error': 'KeyNotFound', 'result': {'key': self.key, 'value': self.value}, 'code': 400} + def api_response(self) -> Dict[str, Any]: + return {'error': 'InvalidKeyValue', 'result': {'key': self.key, 'value': self.value}, 'code': 400} + +class TemplateNotFound(Exception): + """The template was not found""" + api_response = {'error': 'TemplateNotFound', 'result': {}, 'code': 404} diff --git a/backend/db.py b/backend/db.py index 859209a..d63c297 100644 --- a/backend/db.py +++ b/backend/db.py @@ -6,21 +6,24 @@ from typing import Union from flask import g -NOTED_DB_FILE = 'Noted.db' +__DATABASE_VERSION__ = 1 class Singleton(type): - _instances = {} - def __call__(cls, *args, **kwargs): - i = f'{cls}{current_thread()}' - if i not in cls._instances: - cls._instances[i] = super(Singleton, cls).__call__(*args, **kwargs) + _instances = {} + def __call__(cls, *args, **kwargs): + i = f'{cls}{current_thread()}' + if i not in cls._instances: + cls._instances[i] = super(Singleton, cls).__call__(*args, **kwargs) - return cls._instances[i] + return cls._instances[i] class DBConnection(Connection, metaclass=Singleton): - def __init__(self, file: str, timeout: float): - super().__init__(file, timeout=timeout) - super().cursor().execute("PRAGMA foreign_keys = ON;") + file = '' + + def __init__(self, timeout: float) -> None: + super().__init__(self.file, timeout=timeout) + super().cursor().execute("PRAGMA foreign_keys = ON;") + return def get_db(output_type: Union[dict, tuple]=tuple): """Get a database cursor instance. Coupled to Flask's g. @@ -34,7 +37,7 @@ def get_db(output_type: Union[dict, tuple]=tuple): try: cursor = g.cursor except AttributeError: - db = DBConnection(NOTED_DB_FILE, timeout=20.0) + db = DBConnection(timeout=20.0) cursor = g.cursor = db.cursor() if output_type is dict: @@ -57,6 +60,16 @@ def close_db(e=None) -> None: pass return +def migrate_db(current_db_version: int) -> None: + """ + Migrate a Noted database from it's current version + to the newest version supported by the Noted version installed. + """ + print('Migrating database to newer version...') + cursor = get_db() + + return + def setup_db() -> None: """Setup the database """ @@ -84,10 +97,43 @@ def setup_db() -> None: text TEXT, time INTEGER NOT NULL, notification_service INTEGER NOT NULL, + + repeat_quantity VARCHAR(15), + repeat_interval INTEGER, + original_time INTEGER, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (notification_service) REFERENCES notification_services(id) ); + CREATE TABLE IF NOT EXISTS templates( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + title VARCHAR(255) NOT NULL, + text TEXT, + notification_service INTEGER NOT NULL, + + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (notification_service) REFERENCES notification_services(id) + ); + CREATE TABLE IF NOT EXISTS config( + key VARCHAR(255) PRIMARY KEY, + value TEXT NOT NULL + ); """) + cursor.execute(""" + INSERT OR IGNORE INTO config(key, value) + VALUES ('database_version', ?); + """, + (__DATABASE_VERSION__,) + ) + current_db_version = int(cursor.execute("SELECT value FROM config WHERE key = 'database_version' LIMIT 1;").fetchone()[0]) + + if current_db_version < __DATABASE_VERSION__: + migrate_db(current_db_version) + cursor.execute( + "UPDATE config SET value = ? WHERE key = 'database_version' LIMIT 1;", + (__DATABASE_VERSION__,) + ) + return diff --git a/backend/notification_service.py b/backend/notification_service.py index 9dbb0dd..f92df6b 100644 --- a/backend/notification_service.py +++ b/backend/notification_service.py @@ -80,11 +80,19 @@ class NotificationService: # Check if no reminders exist with this service cursor = get_db() cursor.execute( - "SELECT id FROM reminders WHERE notification_service = ? LIMIT 1", + "SELECT 1 FROM reminders WHERE notification_service = ? LIMIT 1;", (self.id,) ) if cursor.fetchone(): - raise NotificationServiceInUse + raise NotificationServiceInUse('reminder') + + # Check if no templates exist with this service + cursor.execute( + "SELECT 1 FROM templates WHERE notification_service = ? LIMIT 1;", + (self.id,) + ) + if cursor.fetchone(): + raise NotificationServiceInUse('template') cursor.execute( "DELETE FROM notification_services WHERE id = ?", diff --git a/backend/reminders.py b/backend/reminders.py index f90ab5d..cd7a111 100644 --- a/backend/reminders.py +++ b/backend/reminders.py @@ -1,18 +1,39 @@ #-*- coding: utf-8 -*- +from datetime import datetime from sqlite3 import IntegrityError from threading import Thread -from time import sleep, time as epoch_time +from time import sleep +from time import time as epoch_time from typing import List, Literal from apprise import Apprise +from dateutil.relativedelta import relativedelta from flask import Flask -from backend.custom_exceptions import (InvalidTime, +from backend.custom_exceptions import (InvalidKeyValue, InvalidTime, NotificationServiceNotFound, ReminderNotFound) 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( + original_time: int, + repeat_quantity: Literal["year", "month", "week", "day", "hours", "minutes"], + repeat_interval: int +) -> int: + td = relativedelta(**{repeat_quantity: repeat_interval}) + new_time = datetime.fromtimestamp(original_time) + current_time = datetime.fromtimestamp(epoch_time()) + while new_time <= current_time: + new_time += td + return int(new_time.timestamp()) + class ReminderHandler(): """Run in a thread to handle the set reminders """ @@ -26,7 +47,6 @@ class ReminderHandler(): def _find_next_reminder(self) -> None: """Note when next reminder is (could be in the past) or otherwise None """ - with self.context(): next_timestamp = get_db().execute( "SELECT time FROM reminders ORDER BY time LIMIT 1;" @@ -58,26 +78,45 @@ class ReminderHandler(): while not self.stop: if self.next_reminder and self.next_reminder <= epoch_time(): with self.context(): - cursor = get_db() + cursor = get_db(dict) # Get all reminders for the timestamp - reminders = cursor.execute( - "SELECT notification_service, title, text FROM reminders WHERE time = ?", + reminders = cursor.execute(""" + SELECT + id, + notification_service, title, text, + repeat_quantity, repeat_interval, original_time + FROM reminders + WHERE time = ?; + """, (self.next_reminder,) ).fetchall() - # Send of each reminder for reminder in reminders: + # Send of reminder a = Apprise() url = cursor.execute( "SELECT url FROM notification_services WHERE id = ?", - (reminder[0],) - ).fetchone()[0] + (reminder["notification_service"],) + ).fetchone()["url"] a.add(url) - a.notify(title=reminder[1], body=reminder[2]) + a.notify(title=reminder["title"], body=reminder["text"]) + + if reminder['repeat_quantity'] is None: + # Delete the reminders from the database + cursor.execute("DELETE FROM reminders WHERE id = ?", (reminder['id'],)) + else: + # Set next time + new_time = _find_next_time( + reminder['original_time'], + reminder['repeat_quantity'], + reminder['repeat_interval'] + ) + self.submit_next_reminder(new_time) + cursor.execute( + "UPDATE reminders SET time = ? WHERE id = ?;", + (new_time, reminder['id']) + ) - # Delete the reminders from the database - cursor.execute("DELETE FROM reminders WHERE time = ?", (self.next_reminder,)) - # Note when next reminder is (could be in the past) or otherwise None self._find_next_reminder() @@ -115,7 +154,9 @@ class Reminder: r.title, r.text, r.time, r.notification_service, - ns.title AS notification_service_title + ns.title AS notification_service_title, + r.repeat_quantity, + r.repeat_interval FROM reminders r INNER JOIN notification_services ns @@ -133,7 +174,9 @@ class Reminder: title: str = None, time: int = None, notification_service: int = None, - text: str = None + text: str = None, + repeat_quantity: Literal["year", "month", "week", "day", "hours", "minutes"] = None, + repeat_interval: int = None ) -> dict: """Edit the reminder @@ -144,11 +187,20 @@ class Reminder: text (str, optional): The new body of the reminder. Defaults to None. Returns: - dict: The new password info + dict: The new reminder info """ + cursor = get_db() + # Validate data - if time < epoch_time(): - raise InvalidTime + if repeat_quantity is None and repeat_interval is not None: + raise InvalidKeyValue('repeat_quantity', repeat_quantity) + elif repeat_quantity is not None and repeat_interval is None: + raise InvalidKeyValue('repeat_interval', repeat_interval) + repeated_reminder = repeat_quantity is not None and repeat_interval is not None + + if not repeated_reminder: + if time < epoch_time(): + raise InvalidTime time = round(time) # Get current data and update it with new values @@ -157,28 +209,50 @@ class Reminder: 'title': title, 'time': time, 'notification_service': notification_service, - 'text': text + 'text': text, + 'repeat_quantity': repeat_quantity, + 'repeat_interval': repeat_interval } for k, v in new_values.items(): - if v is not None: + if k in ('repeat_quantity', 'repeat_interval') or v is not None: data[k] = v # Update database try: - get_db().execute(""" - UPDATE reminders - SET title=?, text=?, time=?, notification_service=? - WHERE id = ?; - """, ( - data["title"], - data["text"], - data["time"], - data["notification_service"], - self.id - )) + if not repeated_reminder: + next_time = data["time"] + cursor.execute(""" + UPDATE reminders + SET title=?, text=?, time=?, notification_service=?, repeat_quantity=?, repeat_interval=? + WHERE id = ?; + """, ( + data["title"], + data["text"], + data["time"], + data["notification_service"], + data["repeat_quantity"], + data["repeat_interval"], + self.id + )) + else: + next_time = _find_next_time(data["time"], data["repeat_quantity"], data["repeat_interval"]) + cursor.execute(""" + UPDATE reminders + SET title=?, text=?, time=?, notification_service=?, repeat_quantity=?, repeat_interval=?, original_time=? + WHERE id = ?; + """, ( + data["title"], + data["text"], + next_time, + data["notification_service"], + data["repeat_quantity"], + data["repeat_interval"], + data["time"], + self.id + )) except IntegrityError: raise NotificationServiceNotFound - reminder_handler.submit_next_reminder(time) + reminder_handler.submit_next_reminder(next_time) return self.get() @@ -190,7 +264,7 @@ class Reminder: return class Reminders: - """Represents the reminder vault of the user account + """Represents the reminder library of the user account """ sort_functions = { 'title': (lambda r: (r['title'], r['time']), False), @@ -223,7 +297,9 @@ class Reminders: r.title, r.text, r.time, r.notification_service, - ns.title AS notification_service_title + ns.title AS notification_service_title, + r.repeat_quantity, + r.repeat_interval FROM reminders r INNER JOIN notification_services ns @@ -249,16 +325,11 @@ class Reminders: List[dict]: All reminders that match. Similar output to self.fetchall """ query = query.lower() - passwords = self.fetchall() - passwords = list(filter( - lambda p: ( - query in p["title"].lower() - or query in p["text"].lower() - or query in p["notification_service_title"].lower() - ), - passwords + reminders = list(filter( + lambda p: filter_function(query, p), + self.fetchall() )) - return passwords + return reminders def fetchone(self, id: int) -> Reminder: """Get one reminder @@ -276,7 +347,9 @@ class Reminders: title: str, time: int, notification_service: int, - text: str = '' + text: str = '', + repeat_quantity: Literal["year", "month", "week", "day", "hours", "minutes"] = None, + repeat_interval: int = None ) -> Reminder: """Add a reminder @@ -285,22 +358,34 @@ class Reminders: time (int): The epoch timestamp the the reminder should be send. 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 ''. + repeat_quantity (Literal["year", "month", "week", "day", "hours", "minutes"], optional): The quantity of the repeat specified for the reminder. Defaults to None. + repeat_interval (int, optional): The amount of repeat_quantity, like "5" (hours). Defaults to None. Returns: dict: The info about the reminder """ - # Validate data if time < epoch_time(): raise InvalidTime time = round(time) - - # Insert into db + + if repeat_quantity is None and repeat_interval is not None: + raise InvalidKeyValue('repeat_quantity', repeat_quantity) + elif repeat_quantity is not None and repeat_interval is None: + raise InvalidKeyValue('repeat_interval', repeat_interval) + try: - id = get_db().execute(""" - INSERT INTO reminders(user_id, title, text, time, notification_service) - VALUES (?,?,?,?,?); - """, (self.user_id, title, text, time, notification_service,) - ).lastrowid + if repeat_quantity is None and repeat_interval is None: + id = get_db().execute(""" + INSERT INTO reminders(user_id, title, text, time, notification_service) + VALUES (?,?,?,?,?); + """, (self.user_id, title, text, time, notification_service) + ).lastrowid + else: + id = get_db().execute(""" + INSERT INTO reminders(user_id, title, text, time, notification_service, repeat_quantity, repeat_interval, original_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?); + """, (self.user_id, title, text, time, notification_service, repeat_quantity, repeat_interval, time) + ).lastrowid except IntegrityError: raise NotificationServiceNotFound reminder_handler.submit_next_reminder(time) diff --git a/backend/security.py b/backend/security.py index 0991364..8cb6a96 100644 --- a/backend/security.py +++ b/backend/security.py @@ -9,7 +9,7 @@ def get_hash(salt: bytes, data: str) -> bytes: """Hash a string using the supplied salt Args: - salt (bytes): The salt to use wwhen hashing + salt (bytes): The salt to use when hashing data (str): The data to hash Returns: diff --git a/backend/templates.py b/backend/templates.py new file mode 100644 index 0000000..efe22ab --- /dev/null +++ b/backend/templates.py @@ -0,0 +1,151 @@ +#-*- coding: utf-8 -*- + +from sqlite3 import IntegrityError +from typing import List + +from backend.custom_exceptions import (NotificationServiceNotFound, + TemplateNotFound) +from backend.db import get_db + +class Template: + """Represents a template + """ + def __init__(self, template_id: int): + self.id = template_id + + exists = get_db().execute("SELECT 1 FROM templates WHERE id = ? LIMIT 1;", (self.id,)).fetchone() + if not exists: + raise TemplateNotFound + + def get(self) -> dict: + """Get info about the template + + Returns: + dict: The info about the template + """ + template = get_db(dict).execute(""" + SELECT + id, + title, text, + notification_service + FROM templates + WHERE id = ?; + """, + (self.id,) + ).fetchone() + + return dict(template) + + def update(self, + title: str = None, + notification_service: int = None, + text: str = None + ) -> dict: + """Edit the template + + Args: + title (str): The new title of the entry. Defaults to None. + notification_service (int): The new id of the notification service to use to send the reminder. Defaults to None. + text (str, optional): The new body of the template. Defaults to None. + + Returns: + dict: The new template info + """ + cursor = get_db() + + data = self.get() + new_values = { + 'title': title, + 'notification_service': notification_service, + 'text': text + } + for k, v in new_values.items(): + if v is not None: + data[k] = v + + try: + cursor.execute(""" + UPDATE templates + SET title=?, notification_service=?, text=? + WHERE id = ?; + """, ( + data['title'], + data['notification_service'], + data['text'], + self.id + )) + except IntegrityError: + raise NotificationServiceNotFound + + return self.get() + + def delete(self) -> None: + """Delete the template + """ + get_db().execute("DELETE FROM templates WHERE id = ?;", (self.id,)) + return + +class Templates: + """Represents the template library of the user account + """ + def __init__(self, user_id: int): + self.user_id = user_id + + def fetchall(self) -> List[dict]: + """Get all templates + + Returns: + List[dict]: The id, title, text and notification_service + """ + templates: list = list(map(dict, get_db(dict).execute(""" + SELECT + id, + title, text, + notification_service + FROM templates + WHERE user_id = ? + ORDER BY title, id; + """, + (self.user_id,) + ).fetchall())) + + return templates + + def fetchone(self, id: int) -> Template: + """Get one template + + Args: + id (int): The id of the template to fetch + + Returns: + Template: A Template instance + """ + return Template(id) + + def add( + self, + title: str, + notification_service: int, + text: str = '' + ) -> Template: + """Add a template + + Args: + title (str): The 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 ''. + + Returns: + Template: The info about the template + """ + try: + id = get_db().execute(""" + INSERT INTO templates(user_id, title, text, notification_service) + VALUES (?,?,?,?); + """, + (self.user_id, title, text, notification_service) + ).lastrowid + except IntegrityError: + raise NotificationServiceNotFound + + return self.fetchone(id) diff --git a/backend/users.py b/backend/users.py index 7b7388f..f5a313f 100644 --- a/backend/users.py +++ b/backend/users.py @@ -6,9 +6,10 @@ from backend.db import get_db from backend.notification_service import NotificationServices from backend.reminders import Reminders from backend.security import generate_salt_hash, get_hash +from backend.templates import Templates ONEPASS_USERNAME_CHARACTERS = 'abcedfghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.!@$' -ONEPASS_INVALID_USERNAMES = ['users','api'] +ONEPASS_INVALID_USERNAMES = ['reminders','api'] class User: """Represents an user account @@ -52,6 +53,17 @@ class User: self.notification_services_instance = NotificationServices(self.user_id) return self.notification_services_instance + @property + def templates(self) -> Templates: + """Get access to the templates of the user account + + Returns: + Templates: Templates instance that can be used to access the templates of the user account + """ + if not hasattr(self, 'templates_instance'): + self.templates_instance = Templates(self.user_id) + return self.templates_instance + def edit_password(self, new_password: str) -> None: """Change the password of the account @@ -106,7 +118,7 @@ def register_user(username: str, password: str) -> int: Returns: user_id (int): The id of the new user. User registered successful """ - #check if username is valid + # Check if username is valid _check_username(username) cursor = get_db() diff --git a/frontend/api.py b/frontend/api.py index 5ff088e..89b646f 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -6,13 +6,16 @@ from typing import Any, Tuple from flask import Blueprint, g, request -from backend.custom_exceptions import (AccessUnauthorized, InvalidKeyValue, InvalidTime, InvalidURL, - KeyNotFound, NotificationServiceInUse, NotificationServiceNotFound, ReminderNotFound, - UsernameInvalid, UsernameTaken, - UserNotFound) +from backend.custom_exceptions import (AccessUnauthorized, InvalidKeyValue, + InvalidTime, InvalidURL, KeyNotFound, + NotificationServiceInUse, + NotificationServiceNotFound, + ReminderNotFound, UsernameInvalid, + UsernameTaken, UserNotFound) from backend.notification_service import (NotificationService, NotificationServices) -from backend.reminders import reminder_handler, Reminders +from backend.reminders import Reminders, reminder_handler +from backend.templates import Template, Templates from backend.users import User, register_user api = Blueprint('api', __name__) @@ -80,10 +83,27 @@ def extract_key(values: dict, key: str, check_existence: bool=True) -> Any: 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 ("year", "month", "week", "day", "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) else: if key == 'sort_by': @@ -358,7 +378,7 @@ def api_notification_service(n_id: int): return return_api({}) #=================== -# Vault endpoints +# Library endpoints #=================== @api.route('/reminders', methods=['GET','POST']) @@ -376,7 +396,7 @@ def api_reminders_list(): sort_by: how to sort the result. Allowed values are 'title', 'title_reversed', 'time' and 'time_reversed' Returns: 200: - The id, title, url and username of every reminder + The id, title, text, time, notification_service, notification_service_title, repeat_quantity and repeat_interval of each reminder POST: Description: Add a reminder Parameters (body (content-type: application/json)): @@ -384,9 +404,11 @@ def api_reminders_list(): time (required): the epoch timestamp that the reminder should be sent at notification_service (required): the id of the notification service to use to send the notification text: the body of the reminder + repeat_quantity ('year', 'month', 'week', 'day', 'hours', 'minutes'): The quantity of the repeat_interval + repeat_interval: The number of the interval Returns: 200: - The id of the new reminder entry + The info about the new reminder entry 400: KeyNotFound: One of the required parameters was not given """ @@ -403,11 +425,15 @@ def api_reminders_list(): time = extract_key(data, 'time') notification_service = extract_key(data, 'notification_service') 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) result = reminders.add(title=title, time=time, notification_service=notification_service, - text=text) + text=text, + repeat_quantity=repeat_quantity, + repeat_interval=repeat_interval) return return_api(result.get(), code=201) @api.route('/reminders/search', methods=['GET']) @@ -433,7 +459,7 @@ def api_reminders_query(): result = g.user_data.reminders.search(query) return return_api(result) -@api.route('/reminders/', methods=['GET','PUT','DELETE']) +@api.route('/reminders/', methods=['GET','PUT','DELETE']) @error_handler @auth def api_get_reminder(r_id: int): @@ -458,6 +484,8 @@ def api_get_reminder(r_id: int): time: The new epoch timestamp the the reminder should be send. notification_service: The new id of the notification service to use to send the reminder. text: The new body of the reminder. + repeat_quantity ('year', 'month', 'week', 'day', 'hours', 'minutes'): The quantity of the repeat_interval + repeat_interval: The number of the interval Returns: 200: Reminder updated successfully @@ -482,13 +510,123 @@ def api_get_reminder(r_id: int): time = extract_key(data, 'time', check_existence=False) notification_service = extract_key(data, 'notification_service', 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) + result = reminders.fetchone(r_id).update(title=title, time=time, notification_service=notification_service, - text=text) + text=text, + repeat_quantity=repeat_quantity, + repeat_interval=repeat_interval) return return_api(result) elif request.method == 'DELETE': reminders.fetchone(r_id).delete() return return_api({}) + +#=================== +# 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, notification_service and text of every template + POST: + Description: Add a template + Parameters (body (content-type: application/json)): + title (required): the title of the template + notification_service (required): the id of the notification service to use to send the notification + text: the body of the template + Returns: + 200: + The info about the new template entry + 400: + KeyNotFound: One of the required parameters was not given + """ + templates: Templates = g.user_data.templates + + if request.method == 'GET': + result = templates.fetchall() + return return_api(result) + + elif request.method == 'POST': + data = request.get_json() + title = extract_key(data, 'title') + notification_service = extract_key(data, 'notification_service') + text = extract_key(data, 'text', check_existence=False) + + result = templates.add(title=title, + notification_service=notification_service, + text=text) + 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 (content-type: application/json)): + title: The new title of the entry. + notification_service: The new id of the notification service to use to send the reminder. + text: The new body of the template. + Returns: + 200: + Template updated successfully + 404: + No template found with the given id + DELETE: + Description: Delete the template + Returns: + 200: + Template deleted successfully + 404: + No template found with the given id + """ + template: Template = g.user_data.templates.fetchone(t_id) + + if request.method == 'GET': + result = template.get() + return return_api(result) + + elif request.method == 'PUT': + data = request.get_json() + title = extract_key(data, 'title', check_existence=False) + notification_service = extract_key(data, 'notification_service', check_existence=False) + text = extract_key(data, 'text', check_existence=False) + + result = template.update(title=title, + notification_service=notification_service, + text=text) + return return_api(result) + + elif request.method == 'DELETE': + template.delete() + return return_api({}) diff --git a/frontend/static/css/add_edit.css b/frontend/static/css/add_edit.css index 79c8faf..d374b70 100644 --- a/frontend/static/css/add_edit.css +++ b/frontend/static/css/add_edit.css @@ -12,7 +12,8 @@ .form-container > form input, .form-container > form select, -.form-container > form textarea { +.form-container > form textarea, +.form-container > form button { padding: 1rem; } @@ -27,11 +28,71 @@ } .sub-inputs > input, -.sub-inputs > select { +.sub-inputs > select, +.sub-inputs > button { width: calc(50% - (var(--gap) / 2)); } -.options > button { +.sub-inputs > button { + display: flex; + justify-content: center; + align-items: center; + gap: .75rem; + + border: 2px solid var(--color-gray); + box-shadow: var(--default-shadow); + + font-size: 1.1rem; +} + +.sub-inputs > button > svg { + width: 1rem; + height: 1rem; +} + +.sub-inputs > button[data-selected="false"] > svg { + opacity: 0; +} + +.repeat-bar, +.repeat-edit-bar { + display: flex; + justify-content: space-between; + align-items: center; + overflow-x: auto; + + border: 2px solid var(--color-gray); + border-radius: 4px; + box-shadow: var(--default-shadow); +} + +.repeat-bar > *, +.repeat-edit-bar > * { + border: 0; + box-shadow: none; +} + +.repeat-bar > p, +.repeat-edit-bar > p { + min-width: 7rem; + + padding: 1rem; + padding-right: .25rem; +} + +.repeat-bar > input[type="number"], +.repeat-edit-bar > input[type="number"] { + min-width: 5rem; + + padding-left: .25rem; +} + +.repeat-bar > select, +.repeat-edit-bar > select { + min-width: 8rem; +} + +div.options > button { width: 6rem; border: 2px solid var(--color-gray); @@ -46,6 +107,11 @@ background-color: var(--color-gray); } +#delete-reminder { + border-color: var(--color-error); + color: var(--color-error); +} + @media (max-width: 460px) { .sub-inputs > input, .sub-inputs > select { diff --git a/frontend/static/css/general.css b/frontend/static/css/general.css index 9d354db..dabaaa5 100644 --- a/frontend/static/css/general.css +++ b/frontend/static/css/general.css @@ -2,6 +2,8 @@ box-sizing: border-box; margin: 0; padding: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-size: 1rem; } :root { @@ -262,6 +264,27 @@ nav > div > button svg { color: var(--color-light); } +.tab-selector { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + + padding-top: 1rem; +} + +.tab-selector > button { + border: 2px solid var(--color-gray); + padding: .5rem 1rem; + transition: background-color .3s ease-in-out; +} + +.tab-selector > button[data-selected="true"] { + background-color: var(--color-gray); +} + @media (max-width: 543px) { .window-container { margin-left: 0; diff --git a/frontend/static/css/notification.css b/frontend/static/css/notification.css index 6a823f6..dbea9fb 100644 --- a/frontend/static/css/notification.css +++ b/frontend/static/css/notification.css @@ -5,7 +5,6 @@ flex-direction: column; align-items: center; gap: 1rem; - overflow-x: auto; } #add-service-button { @@ -18,66 +17,63 @@ width: 1rem; } -.table-container > table { +.overflow-container { margin-inline: auto; - + width: 100%; + max-width: 50rem; + + overflow-x: auto; +} + +.overflow-container > table { border-spacing: 0px; } -.table-container > table th, -.table-container > table td { +.overflow-container > table th, +.overflow-container > table td { text-align: left; } -.table-container > table th { - padding-block: .5rem; +.overflow-container > table th { + padding: .5rem; } #services-list > tr > td { border-top: 1px solid var(--color-gray); -} - -#services-list > tr.edit > td { - padding-block: .5rem; + padding: .25rem; } .title-column { - width: 8rem; + min-width: 9.5rem; + width: 25%; padding-left: 1.5rem; padding-right: 1rem; } .url-column { - width: 21rem; + min-width: 26rem; + width: 65%; } #services-list input { - min-width: 6rem; - border-radius: 2px; - padding: .5rem; + padding: .25rem; box-shadow: none; } -.url-column > input { - min-width: 10rem; -} - #services-list input:read-only { border-color: transparent; - padding: 0; } -#services-list > tr.edit > td.action-column { - padding-block: 1rem; -} - -.action-column { +#services-list > tr > td.action-column { + min-width: 4rem; + width: 20%; + display: flex; gap: .5rem; - padding: .5rem; + padding: calc(.5rem + 2px); padding-right: 1.5rem; } @@ -92,6 +88,6 @@ } #services-list > tr.edit > td.action-column > button[data-type="edit"], -#services-list > tr:not(.edit) > td.action-column > button[data-type="save"] { +#services-list > tr:not(.edit):not(#add-row) > td.action-column > button[data-type="save"] { display: none; } \ No newline at end of file diff --git a/frontend/static/css/reminders.css b/frontend/static/css/reminders_templates.css similarity index 60% rename from frontend/static/css/reminders.css rename to frontend/static/css/reminders_templates.css index cce7875..5ec7de9 100644 --- a/frontend/static/css/reminders.css +++ b/frontend/static/css/reminders_templates.css @@ -46,25 +46,30 @@ } /* REMINDER LIST */ -#reminder-list { +#reminder-list, +#template-list { + --gap: 1rem; + --entry-width: 13rem; max-width: 43rem; margin-inline: auto; display: flex; - justify-content: center; - gap: 1rem; + justify-content: left; + gap: var(--gap); flex-wrap: wrap; padding: 1rem; } -#add-entry { +#add-entry, +#add-template { justify-content: center; align-items: center; gap: .5rem; } -#add-entry svg { +#add-entry svg, +#add-template svg { height: 2rem; width: 2rem; } @@ -79,67 +84,41 @@ } .entry { - width: 13rem; + width: var(--entry-width); height: 6rem; position: relative; display: flex; flex-direction: column; justify-content: space-between; + gap: .1rem; - padding: 1rem; + border-radius: 4px; + padding: .75rem; background-color: var(--color-gray); } +button.entry.fit { + flex-grow: 1; +} + +.entry.expand { + width: calc(var(--entry-width) * 2 + var(--gap)); +} + .entry h2 { overflow-y: hidden; -} - -.entry-overlay { - --height: 1.7rem; + height: 100%; - opacity: 0; - - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: var(--height); - - display: flex; - justify-content: flex-end; - align-items: center; - gap: .5rem; - - border-bottom-left-radius: inherit; - border-bottom-right-radius: inherit; - padding: .5rem; - background-color: rgb(0 0 0 / 25%); - - transition: opacity .2s ease-in; + text-align: left; + font-size: 1.25em; + line-height: 1.18; + font-weight: 500; } -.entry-overlay::before { - content: ''; - position: absolute; - height: 25px; - width: 100%; - background: linear-gradient(transparent, rgb(0 0 0 / 25%)); - left: 0; - bottom: var(--height); -} - -.entry-overlay > button { - background-color: transparent; -} - -.entry-overlay > button > svg { - width: .9rem; - height: .9rem; -} - -.entry:hover > .entry-overlay { - opacity: 1; +#delete-template { + border-color: var(--color-error); + color: var(--color-error); } @media (max-width: 543px) { diff --git a/frontend/static/js/add.js b/frontend/static/js/add.js index 8e60c7c..09b5299 100644 --- a/frontend/static/js/add.js +++ b/frontend/static/js/add.js @@ -5,13 +5,30 @@ const inputs = { 'text': document.getElementById('text-input') }; +const type_buttons = { + 'normal-button': document.getElementById('normal-button'), + 'repeat-button': document.getElementById('repeat-button'), + + 'repeat-bar': document.querySelector('.repeat-bar'), + 'repeat-interval': document.getElementById('repeat-interval'), + 'repeat-quantity': document.getElementById('repeat-quantity') +}; + function addReminder() { + inputs.time.classList.remove('error-input'); + inputs.time.removeAttribute('title'); + const data = { 'title': inputs.title.value, 'time': new Date(inputs.time.value).getTime() / 1000, 'notification_service': inputs.notification_service.value, 'text': inputs.text.value }; + if (type_buttons['repeat-button'].dataset.selected === 'true') { + data['repeat_quantity'] = type_buttons['repeat-quantity'].value; + data['repeat_interval'] = type_buttons['repeat-interval'].value + }; + fetch(`/api/reminders?api_key=${api_key}`, { 'method': 'POST', 'headers': {'Content-Type': 'application/json'}, @@ -30,6 +47,9 @@ function addReminder() { .catch(e => { if (e === 401) { window.location.href = '/'; + } else if (e === 400) { + inputs.time.classList.add('error-input'); + inputs.time.title = 'Time is in the past'; } else { console.log(e); }; @@ -38,21 +58,46 @@ function addReminder() { function showAdd() { if (!document.getElementById('add-entry').classList.contains('error')) { + loadTemplates(force=false); showWindow('add'); + } else { + showWindow('notification'); }; }; function closeAdd() { hideWindow(); setTimeout(() => { + document.getElementById('template-selection').value = document.querySelector('#template-selection option[selected]').value; inputs.title.value = ''; inputs.time.value = ''; inputs.notification_service.value = document.querySelector('#notification-service-input option[selected]').value; + toggleNormal(); inputs.text.value = ''; }, 500); }; +function toggleNormal() { + type_buttons['normal-button'].dataset.selected = 'true'; + type_buttons['repeat-button'].dataset.selected = 'false'; + + type_buttons['repeat-bar'].classList.add('hidden'); + type_buttons['repeat-interval'].removeAttribute('required'); + type_buttons['repeat-interval'].value = ''; +}; + +function toggleRepeated() { + type_buttons['normal-button'].dataset.selected = 'false'; + type_buttons['repeat-button'].dataset.selected = 'true'; + + type_buttons['repeat-bar'].classList.remove('hidden'); + type_buttons['repeat-interval'].setAttribute('required', ''); +}; + // code run on load document.getElementById('add-form').setAttribute('action', 'javascript:addReminder();'); +document.getElementById('template-selection').addEventListener('change', e => loadTemplate()); +document.getElementById('normal-button').addEventListener('click', e => toggleNormal()); +document.getElementById('repeat-button').addEventListener('click', e => toggleRepeated()); document.getElementById('close-add').addEventListener('click', e => closeAdd()); diff --git a/frontend/static/js/edit.js b/frontend/static/js/edit.js index e84d465..b296c40 100644 --- a/frontend/static/js/edit.js +++ b/frontend/static/js/edit.js @@ -5,14 +5,30 @@ const edit_inputs = { 'text': document.getElementById('text-edit-input') }; +const edit_type_buttons = { + 'normal-edit-button': document.getElementById('normal-edit-button'), + 'repeat-edit-button': document.getElementById('repeat-edit-button'), + + 'repeat-edit-bar': document.querySelector('.repeat-edit-bar'), + 'repeat-edit-interval': document.getElementById('repeat-edit-interval'), + 'repeat-edit-quantity': document.getElementById('repeat-edit-quantity') +}; + function editReminder() { const id = document.getElementById('edit-form').dataset.id; const data = { 'title': edit_inputs.title.value, 'time': new Date(edit_inputs.time.value).getTime() / 1000, 'notification_service': edit_inputs.notification_service.value, - 'text': edit_inputs.text.value + 'text': edit_inputs.text.value, + 'repeat_quantity': null, + 'repeat_interval': null }; + if (edit_type_buttons['repeat-edit-button'].dataset.selected === 'true') { + data['repeat_quantity'] = edit_type_buttons['repeat-edit-quantity'].value; + data['repeat_interval'] = edit_type_buttons['repeat-edit-interval'].value; + }; + fetch(`/api/reminders/${id}?api_key=${api_key}`, { 'method': 'PUT', 'headers': {'Content-Type': 'application/json'}, @@ -50,11 +66,22 @@ function showEdit(id) { }) .then(json => { edit_inputs.title.value = json.result.title; + edit_inputs.time.value = new Date( (json.result.time + new Date(json.result.time * 1000).getTimezoneOffset() * -60) * 1000 ).toISOString().slice(0, 16); edit_inputs.notification_service.value = json.result.notification_service; + + if (json.result['repeat_interval'] === null) { + toggleEditNormal(); + } else { + toggleEditRepeated(); + edit_type_buttons['repeat-edit-interval'].value = json.result['repeat_interval']; + edit_type_buttons['repeat-edit-quantity'].value = json.result['repeat_quantity']; + }; + edit_inputs.text.value = json.result.text !== null ? json.result.text : ''; + showWindow('edit'); }) .catch(e => { @@ -68,7 +95,53 @@ function showEdit(id) { }); }; +function toggleEditNormal() { + edit_type_buttons['normal-edit-button'].dataset.selected = 'true'; + edit_type_buttons['repeat-edit-button'].dataset.selected = 'false'; + + edit_type_buttons['repeat-edit-bar'].classList.add('hidden'); + edit_type_buttons['repeat-edit-interval'].removeAttribute('required'); + edit_type_buttons['repeat-edit-interval'].value = ''; +}; + +function toggleEditRepeated() { + edit_type_buttons['normal-edit-button'].dataset.selected = 'false'; + edit_type_buttons['repeat-edit-button'].dataset.selected = 'true'; + + edit_type_buttons['repeat-edit-bar'].classList.remove('hidden'); + edit_type_buttons['repeat-edit-interval'].setAttribute('required', ''); +}; + +function deleteReminder() { + const id = document.getElementById('edit-form').dataset.id; + fetch(`/api/reminders/${id}?api_key=${api_key}`, { + 'method': 'DELETE' + }) + .then(response => { + // catch errors + if (!response.ok) { + return Promise.reject(response.status); + }; + + fillList(); + hideWindow(); + return; + }) + .catch(e => { + if (e === 401) { + window.location.href = '/'; + } else if (e === 404) { + fillList(); + } else { + console.log(e); + }; + }); +}; + // code run on load document.getElementById('edit-form').setAttribute('action', 'javascript:editReminder();'); +document.getElementById('normal-edit-button').addEventListener('click', e => toggleEditNormal()); +document.getElementById('repeat-edit-button').addEventListener('click', e => toggleEditRepeated()); document.getElementById('close-edit').addEventListener('click', e => hideWindow()); +document.getElementById('delete-reminder').addEventListener('click', e => deleteReminder()); diff --git a/frontend/static/js/general.js b/frontend/static/js/general.js index 270b43e..b410fb9 100644 --- a/frontend/static/js/general.js +++ b/frontend/static/js/general.js @@ -30,6 +30,23 @@ function hideWindow() { }); }; +function showTab(tab_id, button_id, load_function=null) { + document.querySelectorAll(`.tab-selector > button`).forEach(e => { + if (e.id === button_id) { + e.dataset.selected = "true" + } else { + e.dataset.selected = "false" + }; + }); + document.querySelectorAll(`#home > div:not(.tab-selector)`).forEach(e => { + e.classList.add('hidden'); + }); + document.getElementById(tab_id).classList.remove('hidden'); + if (load_function !== null) { + load_function(force=false); + }; +}; + // code run on load const api_key = sessionStorage.getItem('api_key'); @@ -44,6 +61,9 @@ document.getElementById('notification-services-button').addEventListener('click' document.getElementById('settings-button').addEventListener('click', e => showWindow('settings')); document.getElementById('logout-button').addEventListener('click', e => logout()); +document.getElementById('reminders-selector').addEventListener('click', e => showTab('reminder-tab', 'reminders-selector')); +document.getElementById('templates-selector').addEventListener('click', e => showTab('template-tab', 'templates-selector', loadTemplates)); + const edit_icon = ''; const delete_icon = ''; const save_icon = ''; diff --git a/frontend/static/js/notification.js b/frontend/static/js/notification.js index c57f3d6..9596eab 100644 --- a/frontend/static/js/notification.js +++ b/frontend/static/js/notification.js @@ -11,8 +11,12 @@ function fillNotificationSelection() { .then(json => { if (json.result.length) { document.getElementById('add-entry').classList.remove('error', 'error-icon'); - [document.getElementById('notification-service-input'), - document.getElementById('notification-service-edit-input')].forEach(options => { + [ + document.getElementById('notification-service-input'), + document.getElementById('notification-service-edit-input'), + document.getElementById('notification-service-template-input'), + document.getElementById('notification-service-template-edit-input') + ].forEach(options => { options.innerHTML = ''; json.result.forEach(service => { const entry = document.createElement('option'); @@ -95,10 +99,11 @@ function deleteService(id) { fetch(`/api/notificationservices/${id}?api_key=${api_key}`, { 'method': 'DELETE' }) - .then(response => { + .then(response => response.json()) + .then(json => { // catch errors - if (!response.ok) { - return Promise.reject(response.status); + if (json.error !== null) { + return Promise.reject(json); }; row.remove(); @@ -107,12 +112,12 @@ function deleteService(id) { }; }) .catch(e => { - if (e === 401) { + if (e.error === 'ApiKeyExpired' || e.error === 'ApiKeyInvalid') { window.location.href = '/'; - } else if (e === 400) { + } else if (e.error === 'NotificationServiceInUse') { const delete_button = row.querySelector('button[title="Delete"]'); delete_button.classList.add('error-icon'); - delete_button.title = 'The notification service is still in use by a reminder'; + delete_button.title = `The notification service is still in use by a ${e.result.type}`; } else { console.log(e); }; diff --git a/frontend/static/js/reminders.js b/frontend/static/js/reminders.js index 9cc5204..17c842f 100644 --- a/frontend/static/js/reminders.js +++ b/frontend/static/js/reminders.js @@ -1,11 +1,12 @@ function fillTable(result) { const table = document.getElementById('reminder-list'); - table.querySelectorAll('button:not(#add-entry)').forEach(e => e.remove()); + table.querySelectorAll('button.entry:not(#add-entry)').forEach(e => e.remove()); result.forEach(reminder => { const entry = document.createElement('button'); entry.classList.add('entry'); entry.dataset.id = reminder.id; + entry.addEventListener('click', e => showEdit(reminder.id)); const title = document.createElement('h2'); title.innerText = reminder.title; @@ -14,29 +15,26 @@ function fillTable(result) { const time = document.createElement('p'); var d = new Date(reminder.time * 1000); var formatted_date = d.toLocaleString('en-CA').slice(0,10) + ' ' + d.toTimeString().slice(0,5); + if (reminder.repeat_interval !== null) { + if (reminder.repeat_interval === 1) { + var quantity = reminder.repeat_quantity.endsWith('s') ? reminder.repeat_quantity.slice(0, -1) : reminder.repeat_quantity; + var interval_text = ` (each ${quantity})`; + } else { + var quantity = reminder.repeat_quantity.endsWith('s') ? reminder.repeat_quantity : reminder.repeat_quantity + 's'; + var interval_text = ` (every ${reminder.repeat_interval} ${quantity})`; + }; + formatted_date += interval_text; + }; time.innerText = formatted_date; entry.appendChild(time); - - const options = document.createElement('div'); - options.classList.add('entry-overlay'); - entry.appendChild(options); - - const edit_entry = document.createElement('button'); - edit_entry.addEventListener('click', e => showEdit(reminder.id)); - edit_entry.innerHTML = edit_icon; - edit_entry.title = 'Edit reminder'; - edit_entry.setAttribute('aria-label', 'Edit reminder'); - options.appendChild(edit_entry); - - const delete_entry = document.createElement('button'); - delete_entry.addEventListener('click', e => deleteReminder(reminder.id)); - delete_entry.innerHTML = delete_icon; - delete_entry.title = 'Delete reminder'; - delete_entry.setAttribute('aria-label', 'Delete reminder'); - options.appendChild(delete_entry); table.appendChild(entry); + + if (title.clientHeight < title.scrollHeight) { + entry.classList.add('expand'); + }; }); + table.querySelectorAll('button.entry:not(#add-entry)').forEach(reminder => reminder.classList.add('fit')); }; function fillList() { @@ -87,30 +85,6 @@ function clearSearch() { fillList(); } -function deleteReminder(id) { - const entry = document.querySelector(`button.entry[data-id="${id}"]`); - entry.remove(); - - fetch(`/api/reminders/${id}?api_key=${api_key}`, { - 'method': 'DELETE' - }) - .then(response => { - // catch errors - if (!response.ok) { - return Promise.reject(response.status); - }; - }) - .catch(e => { - if (e === 401) { - window.location.href = '/'; - } else if (e === 404) { - fillList(); - } else { - console.log(e); - }; - }); -}; - // code run on load fillList(); @@ -119,3 +93,4 @@ setInterval(fillList, 60000); document.getElementById('search-form').setAttribute('action', 'javascript:search();'); document.getElementById('clear-button').addEventListener('click', e => clearSearch()); document.getElementById('add-entry').addEventListener('click', e => showAdd()); +document.getElementById('add-template').addEventListener('click', e => showWindow('add-template')); diff --git a/frontend/static/js/templates.js b/frontend/static/js/templates.js new file mode 100644 index 0000000..cb9672f --- /dev/null +++ b/frontend/static/js/templates.js @@ -0,0 +1,217 @@ +const template_inputs = { + 'title': document.getElementById('title-template-input'), + 'notification-service': document.getElementById('notification-service-template-input'), + 'text': document.getElementById('text-template-input') +}; + +const edit_template_inputs = { + 'title': document.getElementById('title-template-edit-input'), + 'notification-service': document.getElementById('notification-service-template-edit-input'), + 'text': document.getElementById('text-template-edit-input') +}; + +function loadTemplates(force=true) { + const table = document.getElementById('template-list'); + if (!force && !!table.querySelector('button:not(#add-template)')) { + return + }; + + fetch(`/api/templates?api_key=${api_key}`) + .then(response => { + // catch errors + if (!response.ok) { + return Promise.reject(response.status); + }; + return response.json(); + }) + .then(json => { + const select_list = document.getElementById('template-selection'); + select_list.querySelectorAll('option:not([selected])').forEach(e => e.remove()); + json.result.forEach(template => { + const entry = document.createElement('option'); + entry.value = template.id; + entry.innerText = template.title; + + select_list.appendChild(entry); + }); + + table.querySelectorAll('button:not(#add-template)').forEach(e => e.remove()); + json.result.forEach(template => { + const entry = document.createElement('button'); + entry.classList.add('entry'); + entry.addEventListener('click', e => showEditTemplate(template.id)); + + const title = document.createElement('h2'); + title.innerText = template.title; + entry.appendChild(title); + + table.appendChild(entry); + + if (title.clientHeight < title.scrollHeight) { + entry.classList.add('expand'); + }; + }); + table.querySelectorAll('button:not(#add-template)').forEach(template => template.classList.add('fit')); + }) + .catch(e => { + if (e === 401) { + window.location.href = '/'; + } else { + console.log(e); + }; + }); +}; + +function loadTemplate() { + const id = document.getElementById('template-selection').value; + if (id === "0") { + inputs.title.value = ''; + inputs.notification_service.value = document.querySelector('#notification-service-input option[selected]').value; + inputs.text.value = ''; + } else { + fetch(`/api/templates/${id}?api_key=${api_key}`) + .then(response => { + // catch errors + if (!response.ok) { + return Promise.reject(response.status); + }; + return response.json(); + }) + .then(json => { + inputs.title.value = json.result.title; + inputs.notification_service.value = json.result.notification_service; + inputs.text.value = json.result.text; + }) + .catch(e => { + if (e === 401) { + window.location.href = '/'; + } else { + console.log(e); + }; + }); + }; +}; + +function addTemplate() { + const data = { + 'title': template_inputs.title.value, + 'notification_service': template_inputs["notification-service"].value, + 'text': template_inputs.text.value + }; + fetch(`/api/templates?api_key=${api_key}`, { + 'method': 'POST', + 'headers': {'Content-Type': 'application/json'}, + 'body': JSON.stringify(data) + }) + .then(response => { + // catch errors + if (!response.ok) { + return Promise.reject(response.status); + }; + + loadTemplates(); + closeAddTemplate(); + return + }) + .catch(e => { + if (e === 401) { + window.location.href = '/'; + } else { + console.log(e); + }; + }); +}; + +function closeAddTemplate() { + hideWindow(); + setTimeout(() => { + template_inputs.title.value = ''; + template_inputs['notification-service'].value = document.querySelector('#notification-service-template-input option[selected]').value; + template_inputs.text.value = ''; + }, 500); +}; + +function showEditTemplate(id) { + fetch(`/api/templates/${id}?api_key=${api_key}`) + .then(response => { + // catch errors + if (!response.ok) { + return Promise.reject(response.status); + }; + return response.json(); + }) + .then(json => { + document.getElementById('template-edit-form').dataset.id = id; + edit_template_inputs.title.value = json.result.title; + edit_template_inputs['notification-service'].value = json.result.notification_service; + edit_template_inputs.text.value = json.result.text; + showWindow('edit-template'); + }) + .catch(e => { + if (e === 401) { + window.location.href = '/'; + } else { + console.log(e); + }; + }); +}; + +function saveTemplate() { + const id = document.getElementById('template-edit-form').dataset.id; + const data = { + 'title': edit_template_inputs.title.value, + 'notification_service': edit_template_inputs['notification-service'].value, + 'text': edit_template_inputs.text.value + }; + fetch(`/api/templates/${id}?api_key=${api_key}`, { + 'method': 'PUT', + 'headers': {'Content-Type': 'application/json'}, + 'body': JSON.stringify(data) + }) + .then(response => { + // catch errors + if (!response.ok) { + return Promise.reject(response.status); + }; + loadTemplates(); + hideWindow(); + }) + .catch(e => { + if (e === 401) { + window.location.href = '/'; + } else { + console.log(e); + }; + }); +}; + +function deleteTemplate() { + const id = document.getElementById('template-edit-form').dataset.id; + fetch(`/api/templates/${id}?api_key=${api_key}`, { + 'method': 'DELETE' + }) + .then(response => { + // catch errors + if (!response.ok) { + return Promise.reject(response.status); + }; + + loadTemplates(); + hideWindow(); + }) + .catch(e => { + if (e === 401) { + window.location.href = '/'; + } else { + console.log(e); + }; + }); +}; + +// code run on load + +document.getElementById('template-form').setAttribute('action', 'javascript:addTemplate();'); +document.getElementById('close-template').addEventListener('click', e => closeAddTemplate()); +document.getElementById('template-edit-form').setAttribute('action', 'javascript:saveTemplate()'); +document.getElementById('close-edit-template').addEventListener('click', e => hideWindow()); +document.getElementById('delete-template').addEventListener('click', e => deleteTemplate()); diff --git a/frontend/templates/reminders.html b/frontend/templates/reminders.html index bd1e0cc..f3c0c2b 100644 --- a/frontend/templates/reminders.html +++ b/frontend/templates/reminders.html @@ -6,7 +6,7 @@ - + @@ -16,6 +16,7 @@ + Reminders - Noted @@ -74,54 +75,106 @@
-
- -
- -
+
+ +
-
- + + +
+ +
+
+ + +

Add a notification service first!

+ +
+
+

Add a reminder

+
+
+ + +
+
@@ -139,9 +192,40 @@
+
+ + +
+
+

Repeat every

+ + +
+
@@ -160,40 +244,42 @@ - - - - - - - - - - - - - + + + + + + + + +
TitleApprise URL - - - - - -
+
@@ -208,6 +294,35 @@
+
+

Add a template

+
+
+ + + +
+ + +
+
+
+
+
+

Edit a template

+
+
+ + + +
+ + + +
+
+
+
diff --git a/requirements.txt b/requirements.txt index 755b576..262de54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ apprise>=0.9.9 +python-dateutil>=2.8.2 Flask>=2.1.2 waitress>=2.1.2 \ No newline at end of file diff --git a/tests/Noted_test.py b/tests/Noted_test.py new file mode 100644 index 0000000..add799b --- /dev/null +++ b/tests/Noted_test.py @@ -0,0 +1,20 @@ +import unittest + +from flask import Flask + +from frontend.api import api +from frontend.ui import ui +from Noted import _create_app + +class Test_Noted(unittest.TestCase): + def test_create_app(self): + result = _create_app() + self.assertIsInstance(result, Flask) + + self.assertEqual(result.blueprints.get('ui'), ui) + self.assertEqual(result.blueprints.get('api'), api) + + handlers = result.error_handler_spec[None].keys() + required_handlers = 404, 400, 405, 500 + for handler in required_handlers: + self.assertIn(handler, handlers) diff --git a/tests/api_test.py b/tests/api_test.py new file mode 100644 index 0000000..c062c4d --- /dev/null +++ b/tests/api_test.py @@ -0,0 +1,62 @@ +import unittest + +from flask import Blueprint + +from backend.custom_exceptions import * +from frontend.api import api, auth, error_handler, extract_key, return_api + +class Test_API(unittest.TestCase): + def test_blueprint(self): + self.assertIsInstance(api, Blueprint) + + def test_return_api(self): + for case in ({'result': {}, 'error': 'Error', 'code': 201}, + {'result': ''}): + result = return_api(**case) + self.assertEqual(result[0]['result'], case['result']) + if case.get('error'): + self.assertEqual(result[0]['error'], case['error']) + else: + self.assertIsNone(result[0]['error']) + if case.get('code'): + 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 new file mode 100644 index 0000000..07547aa --- /dev/null +++ b/tests/custom_exceptions_test.py @@ -0,0 +1,18 @@ +import unittest +from inspect import getmembers, getmro, isclass +from sys import modules +from typing import List + +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)) + for defined_exception in defined_exceptions: + self.assertEqual(getmro(defined_exception)[1], Exception) + result = defined_exception().api_response + self.assertIsInstance(result, dict) + result['error'] + result['result'] + result['code'] + self.assertIsInstance(result['code'], int) diff --git a/tests/db_test.py b/tests/db_test.py new file mode 100644 index 0000000..3540b3e --- /dev/null +++ b/tests/db_test.py @@ -0,0 +1,12 @@ +import unittest + +from backend.db import DBConnection +from Noted import DB_FILENAME + +class Test_DB(unittest.TestCase): + def test_foreign_key(self): + DBConnection.file = 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 new file mode 100644 index 0000000..7611754 --- /dev/null +++ b/tests/reminders_test.py @@ -0,0 +1,29 @@ +import unittest +from threading import Thread + +from backend.reminders import filter_function, ReminderHandler + +class Test_Reminder_Handler(unittest.TestCase): + def test_starting_stopping(self): + context = 'test' + instance = ReminderHandler(context) + self.assertIs(context, instance.context) + + self.assertIsInstance(instance.thread, Thread) + + self.assertFalse(instance.stop) + with self.assertRaises(RuntimeError): + instance.stop_handling() + self.assertTrue(instance.stop) + + def test_filter_function(self): + p = { + 'title': 'TITLE', + 'text': 'TEXT', + 'notification_service_title': 'NOTIFICATION_SERVICE_TITLE' + } + for test_case in ('', 'title', 'service', 'ex'): + self.assertTrue(filter_function(test_case, p)) + for test_case in (' ', 'Hello'): + self.assertFalse(filter_function(test_case, p)) + diff --git a/tests/security_test.py b/tests/security_test.py new file mode 100644 index 0000000..3fcddc5 --- /dev/null +++ b/tests/security_test.py @@ -0,0 +1,10 @@ +import unittest + +from backend.security import generate_salt_hash, get_hash + +class Test_Security(unittest.TestCase): + def test_hash(self): + for test_case in ('test', ''): + result = generate_salt_hash(test_case) + self.assertEqual(result[1], get_hash(result[0], test_case)) + \ No newline at end of file diff --git a/tests/ui_test.py b/tests/ui_test.py new file mode 100644 index 0000000..2d611c7 --- /dev/null +++ b/tests/ui_test.py @@ -0,0 +1,20 @@ +import unittest + +from flask import Blueprint, Flask + +from frontend.ui import methods, ui + +class Test_UI(unittest.TestCase): + def test_methods(self): + self.assertEqual(len(methods), 1) + self.assertEqual(methods[0], 'GET') + + def test_blueprint(self): + self.assertIsInstance(ui, Blueprint) + + def test_route_methods(self): + temp_app = Flask(__name__) + temp_app.register_blueprint(ui) + for rule in temp_app.url_map.iter_rules(): + self.assertEqual(len(rule.methods), 3) + self.assertIn(methods[0], rule.methods) diff --git a/tests/users_test.py b/tests/users_test.py new file mode 100644 index 0000000..f00e0ae --- /dev/null +++ b/tests/users_test.py @@ -0,0 +1,14 @@ +import unittest + +from backend.custom_exceptions import UsernameInvalid +from backend.users import ONEPASS_INVALID_USERNAMES, _check_username + +class Test_Users(unittest.TestCase): + def test_username_check(self): + for test_case in ('', 'test'): + _check_username(test_case) + + for test_case in (' ', ' ', '0', 'api', *ONEPASS_INVALID_USERNAMES): + with self.assertRaises(UsernameInvalid): + _check_username(test_case) + \ No newline at end of file