From 7bf6015ba446b38941ffd6ab5e8d3f13558078f5 Mon Sep 17 00:00:00 2001 From: Casvt <88994465+Casvt@users.noreply.github.com> Date: Fri, 13 Jan 2023 13:19:25 +0100 Subject: [PATCH 01/39] Update issue templates Added bug and feature request templates. --- .github/ISSUE_TEMPLATE/bug_report.md | 33 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 17 ++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9ac96ce --- /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 +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/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** + From 48153c3ebf979da3b588a57bdab4baf8c6b7f8be Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Fri, 13 Jan 2023 10:36:49 -0500 Subject: [PATCH 02/39] Update README.md --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5439543..ea6e67e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,9 @@ # Noted 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 +Core Features +* Basic auth +* Utilizes Apprise +* Create and delete reminders + +See the [wiki](https://github.com/Casvt/Noted/wiki) for instructions on how to install and setup Noted. From 9c0f33b2ca758f976399c36aaa402c418d6f70cb Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Fri, 13 Jan 2023 10:37:42 -0500 Subject: [PATCH 03/39] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ea6e67e..e88d4e6 100644 --- a/README.md +++ b/README.md @@ -5,5 +5,6 @@ Core Features * Basic auth * Utilizes Apprise * Create and delete reminders +* Schedule reminders See the [wiki](https://github.com/Casvt/Noted/wiki) for instructions on how to install and setup Noted. From 9acfab39e8496e087608c87ae45d7c54d3e46c49 Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Fri, 13 Jan 2023 10:38:01 -0500 Subject: [PATCH 04/39] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e88d4e6..a05018c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Noted A simple self hosted reminder platform that uses push to send notifications to your device. Set the reminder and forget about it! 📢 -Core Features +## Core Features * Basic auth * Utilizes Apprise * Create and delete reminders From ecaf1e0d8eea0c173bca28b47f26b31141f05a72 Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Fri, 13 Jan 2023 13:46:36 -0500 Subject: [PATCH 05/39] Updated README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a05018c..e18916f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# 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! 📢 +Noted is a simple self hosted application for creating reminders that are pushed to your device using the [Apprise](https://github.com/caronc/apprise) API. Using our Noted Reminders, you can send messages to just about every platform, including scheduled emails! + ## Core Features * Basic auth * Utilizes Apprise From df11278ed209c73ee7d89b7ae827a9c3813fbb9c Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Fri, 13 Jan 2023 13:47:19 -0500 Subject: [PATCH 06/39] Updated README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e18916f..e515d55 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Noted Reminders A simple self hosted reminder platform that uses push to send notifications to your device. Set the reminder and forget about it! 📢 -Noted is a simple self hosted application for creating reminders that are pushed to your device using the [Apprise](https://github.com/caronc/apprise) API. Using our Noted Reminders, you can send messages to just about every platform, including scheduled emails! +Noted is a simple self hosted application for creating reminders that are 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! ## Core Features * Basic auth From 39b5401ab79e52f2c2d7432c94a7b9aff5a9ff87 Mon Sep 17 00:00:00 2001 From: CasVT Date: Fri, 13 Jan 2023 21:24:57 +0100 Subject: [PATCH 07/39] Fixed save button disappearing for adding service --- frontend/static/css/notification.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/static/css/notification.css b/frontend/static/css/notification.css index 6a823f6..1d407f2 100644 --- a/frontend/static/css/notification.css +++ b/frontend/static/css/notification.css @@ -92,6 +92,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 From 7308c60e0a2c24041b80d67234c071df2c32594f Mon Sep 17 00:00:00 2001 From: CasVT Date: Sat, 14 Jan 2023 15:09:47 +0100 Subject: [PATCH 08/39] Fixed running Noted from outside folder --- Noted.py | 10 ++++++---- backend/db.py | 25 +++++++++++++------------ 2 files changed, 19 insertions(+), 16 deletions(-) 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/backend/db.py b/backend/db.py index 859209a..c47319d 100644 --- a/backend/db.py +++ b/backend/db.py @@ -6,21 +6,22 @@ from typing import Union from flask import g -NOTED_DB_FILE = 'Noted.db' - 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 +35,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: From 3e554cf49722474a278852f4cf2c36c365bad745 Mon Sep 17 00:00:00 2001 From: Casvt <88994465+Casvt@users.noreply.github.com> Date: Sat, 14 Jan 2023 16:10:44 +0100 Subject: [PATCH 09/39] Create FUNDING.yml --- .github/FUNDING.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/FUNDING.yml 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'] From e4d74b126b71ed08899db4e14905215a36ca6dac Mon Sep 17 00:00:00 2001 From: CasVT Date: Sat, 14 Jan 2023 16:16:24 +0100 Subject: [PATCH 10/39] Added notification service redirect on add button If no notification services are setup, clicking the add button on the home page will now redirect you to the notification services page instead of doing nothing. --- frontend/static/js/add.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/static/js/add.js b/frontend/static/js/add.js index 8e60c7c..8239a7e 100644 --- a/frontend/static/js/add.js +++ b/frontend/static/js/add.js @@ -39,6 +39,8 @@ function addReminder() { function showAdd() { if (!document.getElementById('add-entry').classList.contains('error')) { showWindow('add'); + } else { + showWindow('notification'); }; }; From 2dc5008bea3e72e3dea9b510964626e74c7b5d65 Mon Sep 17 00:00:00 2001 From: Casvt <88994465+Casvt@users.noreply.github.com> Date: Sat, 14 Jan 2023 16:43:22 +0100 Subject: [PATCH 11/39] Create CODE_OF_CONDUCT.md --- CODE_OF_CONDUCT.md | 128 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 CODE_OF_CONDUCT.md 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. From e27590da9da8589e67521c1cc0c27d63283daf39 Mon Sep 17 00:00:00 2001 From: Casvt <88994465+Casvt@users.noreply.github.com> Date: Sat, 14 Jan 2023 16:52:52 +0100 Subject: [PATCH 12/39] Added contribute request --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/ISSUE_TEMPLATE/contribute-request.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_TEMPLATE/contribute-request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9ac96ce..60dceea 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Create a report to help us improve title: '' -labels: bug +labels: bug, enhancement assignees: '' --- 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)** From 1877b044f62155f207c95404e3b260168b6c2d8c Mon Sep 17 00:00:00 2001 From: Casvt <88994465+Casvt@users.noreply.github.com> Date: Sat, 14 Jan 2023 17:26:00 +0100 Subject: [PATCH 13/39] Create CONTRIBUTING.md --- CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..daa651b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# 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. +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. Type hints are used as much as possible, avoiding importing functions or classes from other files _just_ for the type hinting. +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. From cf6aaa9d7e6e08dcc24cbd77f83ae34f14481081 Mon Sep 17 00:00:00 2001 From: CasVT Date: Sun, 15 Jan 2023 15:07:13 +0100 Subject: [PATCH 14/39] Improved table sizing --- frontend/static/css/notification.css | 50 +++++++++++----------- frontend/templates/reminders.html | 62 ++++++++++++++-------------- 2 files changed, 55 insertions(+), 57 deletions(-) diff --git a/frontend/static/css/notification.css b/frontend/static/css/notification.css index 1d407f2..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; } diff --git a/frontend/templates/reminders.html b/frontend/templates/reminders.html index bd1e0cc..ee1131e 100644 --- a/frontend/templates/reminders.html +++ b/frontend/templates/reminders.html @@ -160,40 +160,42 @@ - - - - - - - - - - - - - + + + + + + + + +
TitleApprise URL - - - - - -
+
From 4dc734af8369421b70cd7646f1b1822488d86f3a Mon Sep 17 00:00:00 2001 From: CasVT Date: Sun, 15 Jan 2023 15:43:17 +0100 Subject: [PATCH 15/39] Better support for long titles --- frontend/static/css/reminders.css | 17 +++++++++++++---- frontend/static/js/reminders.js | 9 +++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/static/css/reminders.css b/frontend/static/css/reminders.css index cce7875..b856401 100644 --- a/frontend/static/css/reminders.css +++ b/frontend/static/css/reminders.css @@ -47,12 +47,14 @@ /* REMINDER LIST */ #reminder-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; @@ -79,7 +81,7 @@ } .entry { - width: 13rem; + width: var(--entry-width); height: 6rem; position: relative; @@ -87,12 +89,19 @@ flex-direction: column; justify-content: space-between; - padding: 1rem; + border-radius: 4px; + padding: .75rem; background-color: var(--color-gray); } +.entry.expand { + width: calc(var(--entry-width) * 2 + var(--gap)); +} + .entry h2 { overflow-y: hidden; + + text-align: left; } .entry-overlay { diff --git a/frontend/static/js/reminders.js b/frontend/static/js/reminders.js index 9cc5204..24b2a68 100644 --- a/frontend/static/js/reminders.js +++ b/frontend/static/js/reminders.js @@ -1,9 +1,9 @@ function fillTable(result) { const table = document.getElementById('reminder-list'); - table.querySelectorAll('button:not(#add-entry)').forEach(e => e.remove()); + table.querySelectorAll('div:not(#add-entry)').forEach(e => e.remove()); result.forEach(reminder => { - const entry = document.createElement('button'); + const entry = document.createElement('div'); entry.classList.add('entry'); entry.dataset.id = reminder.id; @@ -36,6 +36,11 @@ function fillTable(result) { options.appendChild(delete_entry); table.appendChild(entry); + + // Increase size if title is overflowing + if (title.clientHeight < title.scrollHeight) { + entry.classList.add('expand'); + }; }); }; From 8262b6dc9457ba720f8304467f770bdf5d3a4ecd Mon Sep 17 00:00:00 2001 From: CasVT Date: Sun, 15 Jan 2023 16:08:43 +0100 Subject: [PATCH 16/39] Changed font --- frontend/static/css/general.css | 2 ++ frontend/static/css/reminders.css | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/frontend/static/css/general.css b/frontend/static/css/general.css index 9d354db..5fd9f80 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 { diff --git a/frontend/static/css/reminders.css b/frontend/static/css/reminders.css index b856401..3448113 100644 --- a/frontend/static/css/reminders.css +++ b/frontend/static/css/reminders.css @@ -88,6 +88,7 @@ display: flex; flex-direction: column; justify-content: space-between; + gap: .1rem; border-radius: 4px; padding: .75rem; @@ -100,8 +101,12 @@ .entry h2 { overflow-y: hidden; + height: 100%; text-align: left; + font-size: 1.25em; + line-height: 1.18; + font-weight: 500; } .entry-overlay { From 7f644719c8e6f7e0a1184906dc1420ce12fd1180 Mon Sep 17 00:00:00 2001 From: CasVT Date: Sun, 15 Jan 2023 17:40:27 +0100 Subject: [PATCH 17/39] Sped up search algorithm --- backend/reminders.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/reminders.py b/backend/reminders.py index f90ab5d..00dfeb7 100644 --- a/backend/reminders.py +++ b/backend/reminders.py @@ -2,7 +2,8 @@ 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 @@ -13,6 +14,12 @@ from backend.custom_exceptions import (InvalidTime, 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() +) + class ReminderHandler(): """Run in a thread to handle the set reminders """ @@ -249,16 +256,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 From c46f3327e67a97ec496336fe1a2443b8ebcaa8e2 Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Mon, 16 Jan 2023 14:24:45 -0500 Subject: [PATCH 18/39] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index e515d55..ea98357 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,7 @@ Noted is a simple self hosted application for creating reminders that are pushed * 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). + See the [wiki](https://github.com/Casvt/Noted/wiki) for instructions on how to install and setup Noted. From 691313f3ac129b9e9124a7f9c33ff2eee09b98c0 Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Mon, 16 Jan 2023 14:28:36 -0500 Subject: [PATCH 19/39] Update README.md --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ea98357..359e3f2 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,13 @@ Noted is a simple self hosted application for creating reminders that are pushed ## Planned Features You can see our planned features in our [Project board](https://github.com/users/Casvt/projects/3). -See the [wiki](https://github.com/Casvt/Noted/wiki) for instructions on how to install and setup Noted. +## 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. From 6ce8ec64386392ab0c5bf07850322a4495837e4b Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Mon, 16 Jan 2023 14:34:31 -0500 Subject: [PATCH 20/39] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 359e3f2..70a04a7 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ A simple self hosted reminder platform that uses push to send notifications to y Noted is a simple self hosted application for creating reminders that are 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! +![noted-reminders-dashboard](https://user-images.githubusercontent.com/57927413/212753936-8306b0bb-57a7-4912-a675-d92e25cd2220.png) + ## Core Features * Basic auth * Utilizes Apprise From 4118d2f398f1501982d529fd4fa8cc7322beabd8 Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Mon, 16 Jan 2023 14:35:22 -0500 Subject: [PATCH 21/39] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 70a04a7..f2dd42e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Noted Reminders A simple self hosted reminder platform that uses push to send notifications to your device. Set the reminder and forget about it! 📢 -Noted is a simple self hosted application for creating reminders that are 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! +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! ![noted-reminders-dashboard](https://user-images.githubusercontent.com/57927413/212753936-8306b0bb-57a7-4912-a675-d92e25cd2220.png) From ce37ae556201a9c686e75cb7c339acb6a3c913ca Mon Sep 17 00:00:00 2001 From: CasVT Date: Mon, 16 Jan 2023 20:44:34 +0100 Subject: [PATCH 22/39] Fixed deleting reminder from web-ui --- frontend/static/js/reminders.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/static/js/reminders.js b/frontend/static/js/reminders.js index 24b2a68..dc875ff 100644 --- a/frontend/static/js/reminders.js +++ b/frontend/static/js/reminders.js @@ -93,7 +93,7 @@ function clearSearch() { } function deleteReminder(id) { - const entry = document.querySelector(`button.entry[data-id="${id}"]`); + const entry = document.querySelector(`div.entry[data-id="${id}"]`); entry.remove(); fetch(`/api/reminders/${id}?api_key=${api_key}`, { From c8cb4fc406ba5a23f4f6a5b534d5ba7186f7d44a Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Mon, 16 Jan 2023 14:45:23 -0500 Subject: [PATCH 23/39] Update README.md --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f2dd42e..8896612 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,11 @@ A simple self hosted reminder platform that uses push to send notifications to y 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! -![noted-reminders-dashboard](https://user-images.githubusercontent.com/57927413/212753936-8306b0bb-57a7-4912-a675-d92e25cd2220.png) +![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 From 54b97bad0ce15d4e8840596db9a15fe745155ec5 Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Mon, 16 Jan 2023 14:46:10 -0500 Subject: [PATCH 24/39] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 8896612..adcb753 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ A simple self hosted reminder platform that uses push to send notifications to y 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) From 4350687ecc8fee3d3b5ca969c09b05295e243d3d Mon Sep 17 00:00:00 2001 From: Noted <57927413+ItsNoted@users.noreply.github.com> Date: Mon, 16 Jan 2023 14:46:23 -0500 Subject: [PATCH 25/39] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index adcb753..83ea8f4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A simple self hosted reminder platform that uses push to send notifications to y 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 +## Screenshots ![noted-reminders-dashboard-cards](https://user-images.githubusercontent.com/57927413/212755016-05b99226-3f6c-48b7-b99a-253e15c82947.png) From 4e2546027b74ac0fc57e542ecb5b205d4d180068 Mon Sep 17 00:00:00 2001 From: CasVT Date: Mon, 16 Jan 2023 21:40:11 +0100 Subject: [PATCH 26/39] Better support for long titles --- frontend/static/css/reminders.css | 4 ++++ frontend/static/js/reminders.js | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/static/css/reminders.css b/frontend/static/css/reminders.css index 3448113..4915b49 100644 --- a/frontend/static/css/reminders.css +++ b/frontend/static/css/reminders.css @@ -95,6 +95,10 @@ background-color: var(--color-gray); } +div.entry.fit { + flex-grow: 1; +} + .entry.expand { width: calc(var(--entry-width) * 2 + var(--gap)); } diff --git a/frontend/static/js/reminders.js b/frontend/static/js/reminders.js index dc875ff..8d8e0a9 100644 --- a/frontend/static/js/reminders.js +++ b/frontend/static/js/reminders.js @@ -1,6 +1,6 @@ function fillTable(result) { const table = document.getElementById('reminder-list'); - table.querySelectorAll('div:not(#add-entry)').forEach(e => e.remove()); + table.querySelectorAll('div.entry').forEach(e => e.remove()); result.forEach(reminder => { const entry = document.createElement('div'); @@ -37,11 +37,11 @@ function fillTable(result) { table.appendChild(entry); - // Increase size if title is overflowing if (title.clientHeight < title.scrollHeight) { entry.classList.add('expand'); }; }); + table.querySelectorAll('div.entry').forEach(reminder => reminder.classList.add('fit')); }; function fillList() { @@ -119,7 +119,7 @@ function deleteReminder(id) { // code run on load fillList(); -setInterval(fillList, 60000); +// setInterval(fillList, 60000); document.getElementById('search-form').setAttribute('action', 'javascript:search();'); document.getElementById('clear-button').addEventListener('click', e => clearSearch()); From 7a3b6c72d97efc0e449e70e8afc18beff297620a Mon Sep 17 00:00:00 2001 From: CasVT Date: Mon, 16 Jan 2023 22:43:38 +0100 Subject: [PATCH 27/39] Updated invalid usernames --- backend/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/users.py b/backend/users.py index 7b7388f..6a0d940 100644 --- a/backend/users.py +++ b/backend/users.py @@ -8,7 +8,7 @@ from backend.reminders import Reminders from backend.security import generate_salt_hash, get_hash ONEPASS_USERNAME_CHARACTERS = 'abcedfghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.!@$' -ONEPASS_INVALID_USERNAMES = ['users','api'] +ONEPASS_INVALID_USERNAMES = ['reminders','api'] class User: """Represents an user account From f025c403d33d1bbc6f4db2fba6f2b0919ee7d82a Mon Sep 17 00:00:00 2001 From: CasVT Date: Mon, 16 Jan 2023 23:30:18 +0100 Subject: [PATCH 28/39] Added tests --- .gitignore | 1 - .vscode/settings.json | 11 ++++++ backend/custom_exceptions.py | 6 ++-- backend/security.py | 2 +- backend/users.py | 2 +- frontend/api.py | 12 ++++--- tests/Noted_test.py | 20 +++++++++++ tests/api_test.py | 62 +++++++++++++++++++++++++++++++++ tests/custom_exceptions_test.py | 18 ++++++++++ tests/db_test.py | 12 +++++++ tests/reminders_test.py | 29 +++++++++++++++ tests/security_test.py | 10 ++++++ tests/ui_test.py | 20 +++++++++++ tests/users_test.py | 14 ++++++++ 14 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 tests/Noted_test.py create mode 100644 tests/api_test.py create mode 100644 tests/custom_exceptions_test.py create mode 100644 tests/db_test.py create mode 100644 tests/reminders_test.py create mode 100644 tests/security_test.py create mode 100644 tests/ui_test.py create mode 100644 tests/users_test.py diff --git a/.gitignore b/.gitignore index 3e4c422..585383e 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,3 @@ dmypy.json # VS code *.code-workspace -.vscode/ \ 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/backend/custom_exceptions.py b/backend/custom_exceptions.py index be9e831..49578ab 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} @@ -43,7 +45,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 +56,5 @@ class InvalidKeyValue(Exception): super().__init__(self.key) @property - def api_response(self): + def api_response(self) -> Dict[str, Any]: return {'error': 'KeyNotFound', 'result': {'key': self.key, 'value': self.value}, 'code': 400} 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/users.py b/backend/users.py index 6a0d940..5030ffe 100644 --- a/backend/users.py +++ b/backend/users.py @@ -106,7 +106,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..a4e8a8b 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -6,13 +6,15 @@ 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.users import User, register_user api = Blueprint('api', __name__) 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..9ac73d8 --- /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(NotificationServiceInUse), return_api(**NotificationServiceInUse.api_response)) + self.assertEqual(result(InvalidTime), return_api(**InvalidTime.api_response)) + self.assertEqual(result(InvalidURL), return_api(**InvalidURL.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 From bbf4c6a3892b2f686ab231fd3f98c0700831be2e Mon Sep 17 00:00:00 2001 From: Casvt <88994465+Casvt@users.noreply.github.com> Date: Mon, 16 Jan 2023 23:39:35 +0100 Subject: [PATCH 29/39] Added test command and improved type hint rule --- CONTRIBUTING.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index daa651b..082e749 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,10 @@ Once your request is accepted, you can start your local development. 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. +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 @@ -23,7 +26,7 @@ The code of Noted is written in such way that it follows the following rules. Yo 1. Compatible with python 3.7 . 2. Tabs (4 space size) are used for indentation. -3. Type hints are used as much as possible, avoiding importing functions or classes from other files _just_ for the type hinting. +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). From 64704e657726d1b1501271b7a8cd92301832db31 Mon Sep 17 00:00:00 2001 From: CasVT Date: Tue, 17 Jan 2023 01:40:44 +0100 Subject: [PATCH 30/39] Enabled refresh of library every minute --- frontend/static/js/reminders.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/static/js/reminders.js b/frontend/static/js/reminders.js index 8d8e0a9..037a4f7 100644 --- a/frontend/static/js/reminders.js +++ b/frontend/static/js/reminders.js @@ -119,7 +119,7 @@ function deleteReminder(id) { // code run on load fillList(); -// setInterval(fillList, 60000); +setInterval(fillList, 60000); document.getElementById('search-form').setAttribute('action', 'javascript:search();'); document.getElementById('clear-button').addEventListener('click', e => clearSearch()); From 55afdadc77ec7540da36753b96247857dfdccbf1 Mon Sep 17 00:00:00 2001 From: CasVT Date: Thu, 19 Jan 2023 15:50:26 +0100 Subject: [PATCH 31/39] Fixes issue #4 --- backend/custom_exceptions.py | 2 +- backend/db.py | 4 + backend/reminders.py | 165 ++++++++++++++++++++++-------- frontend/api.py | 25 ++++- frontend/static/css/add_edit.css | 65 +++++++++++- frontend/static/js/add.js | 34 ++++++ frontend/static/js/edit.js | 48 ++++++++- frontend/static/js/reminders.js | 12 ++- frontend/templates/reminders.html | 60 +++++++++++ requirements.txt | 1 + 10 files changed, 367 insertions(+), 49 deletions(-) diff --git a/backend/custom_exceptions.py b/backend/custom_exceptions.py index 49578ab..9ddad62 100644 --- a/backend/custom_exceptions.py +++ b/backend/custom_exceptions.py @@ -57,4 +57,4 @@ class InvalidKeyValue(Exception): @property def api_response(self) -> Dict[str, Any]: - return {'error': 'KeyNotFound', 'result': {'key': self.key, 'value': self.value}, 'code': 400} + return {'error': 'InvalidKeyValue', 'result': {'key': self.key, 'value': self.value}, 'code': 400} diff --git a/backend/db.py b/backend/db.py index c47319d..be316e7 100644 --- a/backend/db.py +++ b/backend/db.py @@ -85,6 +85,10 @@ 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) diff --git a/backend/reminders.py b/backend/reminders.py index 00dfeb7..3f999f8 100644 --- a/backend/reminders.py +++ b/backend/reminders.py @@ -1,5 +1,6 @@ #-*- coding: utf-8 -*- +from datetime import datetime from sqlite3 import IntegrityError from threading import Thread from time import sleep @@ -7,9 +8,10 @@ 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 @@ -20,6 +22,18 @@ filter_function = lambda query, p: ( 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 """ @@ -33,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;" @@ -65,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() @@ -122,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 @@ -140,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 @@ -151,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 @@ -164,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() @@ -230,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 @@ -278,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 @@ -287,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/frontend/api.py b/frontend/api.py index a4e8a8b..2087975 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -82,10 +82,22 @@ 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) else: if key == 'sort_by': @@ -405,11 +417,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']) @@ -484,11 +500,16 @@ 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': diff --git a/frontend/static/css/add_edit.css b/frontend/static/css/add_edit.css index 79c8faf..861e828 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,69 @@ } .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); + + 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; +} + +.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); diff --git a/frontend/static/js/add.js b/frontend/static/js/add.js index 8239a7e..238e260 100644 --- a/frontend/static/js/add.js +++ b/frontend/static/js/add.js @@ -5,6 +5,15 @@ 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() { const data = { 'title': inputs.title.value, @@ -12,6 +21,11 @@ function addReminder() { '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'}, @@ -50,11 +64,31 @@ function closeAdd() { 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('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..60095e4 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,26 @@ 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', ''); +}; + // 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()); diff --git a/frontend/static/js/reminders.js b/frontend/static/js/reminders.js index 8d8e0a9..8a82012 100644 --- a/frontend/static/js/reminders.js +++ b/frontend/static/js/reminders.js @@ -14,6 +14,16 @@ 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); @@ -119,7 +129,7 @@ function deleteReminder(id) { // code run on load fillList(); -// setInterval(fillList, 60000); +setInterval(fillList, 60000); document.getElementById('search-form').setAttribute('action', 'javascript:search();'); document.getElementById('clear-button').addEventListener('click', e => clearSearch()); diff --git a/frontend/templates/reminders.html b/frontend/templates/reminders.html index ee1131e..85b0a7d 100644 --- a/frontend/templates/reminders.html +++ b/frontend/templates/reminders.html @@ -122,6 +122,36 @@
+
+ + +
+
@@ -139,6 +169,36 @@
+
+ + +
+
+

Repeat every

+ + +
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 From 1fe057950c75a1c9f457386918a4c95e5e587c7c Mon Sep 17 00:00:00 2001 From: CasVT Date: Thu, 19 Jan 2023 16:21:58 +0100 Subject: [PATCH 32/39] Added db migration system --- backend/db.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/backend/db.py b/backend/db.py index be316e7..a8598f1 100644 --- a/backend/db.py +++ b/backend/db.py @@ -6,6 +6,8 @@ from typing import Union from flask import g +__DATABASE_VERSION__ = 1 + class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): @@ -58,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 """ @@ -93,6 +105,25 @@ def setup_db() -> None: 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 From a0288291a3e4cd6a2e6d936abbc4b560595f3485 Mon Sep 17 00:00:00 2001 From: CasVT Date: Thu, 19 Jan 2023 21:29:15 +0100 Subject: [PATCH 33/39] Fixes issue #8 --- backend/custom_exceptions.py | 12 +- backend/db.py | 10 + backend/notification_service.py | 12 +- backend/templates.py | 151 ++++++++++++ backend/users.py | 12 + frontend/api.py | 108 ++++++++- frontend/static/css/add_edit.css | 2 + frontend/static/css/general.css | 21 ++ ...{reminders.css => reminders_templates.css} | 17 +- frontend/static/js/add.js | 3 + frontend/static/js/general.js | 20 ++ frontend/static/js/notification.js | 21 +- frontend/static/js/reminders.js | 1 + frontend/static/js/templates.js | 217 ++++++++++++++++++ frontend/templates/reminders.html | 122 +++++++--- tests/api_test.py | 2 +- 16 files changed, 679 insertions(+), 52 deletions(-) create mode 100644 backend/templates.py rename frontend/static/css/{reminders.css => reminders_templates.css} (91%) create mode 100644 frontend/static/js/templates.js diff --git a/backend/custom_exceptions.py b/backend/custom_exceptions.py index 9ddad62..9534954 100644 --- a/backend/custom_exceptions.py +++ b/backend/custom_exceptions.py @@ -28,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""" @@ -58,3 +64,7 @@ class InvalidKeyValue(Exception): @property 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 a8598f1..d63c297 100644 --- a/backend/db.py +++ b/backend/db.py @@ -105,6 +105,16 @@ def setup_db() -> None: 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 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/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 5030ffe..f5a313f 100644 --- a/backend/users.py +++ b/backend/users.py @@ -6,6 +6,7 @@ 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 = ['reminders','api'] @@ -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 diff --git a/frontend/api.py b/frontend/api.py index 2087975..fd3cf60 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -15,6 +15,7 @@ from backend.custom_exceptions import (AccessUnauthorized, InvalidKeyValue, from backend.notification_service import (NotificationService, NotificationServices) from backend.reminders import Reminders, reminder_handler +from backend.templates import Template, Templates from backend.users import User, register_user api = Blueprint('api', __name__) @@ -451,7 +452,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): @@ -515,3 +516,108 @@ def api_get_reminder(r_id: int): 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 861e828..966a0e5 100644 --- a/frontend/static/css/add_edit.css +++ b/frontend/static/css/add_edit.css @@ -40,6 +40,7 @@ gap: .75rem; border: 2px solid var(--color-gray); + box-shadow: var(--default-shadow); font-size: 1.1rem; } @@ -62,6 +63,7 @@ border: 2px solid var(--color-gray); border-radius: 4px; + box-shadow: var(--default-shadow); } .repeat-bar > *, diff --git a/frontend/static/css/general.css b/frontend/static/css/general.css index 5fd9f80..dabaaa5 100644 --- a/frontend/static/css/general.css +++ b/frontend/static/css/general.css @@ -264,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/reminders.css b/frontend/static/css/reminders_templates.css similarity index 91% rename from frontend/static/css/reminders.css rename to frontend/static/css/reminders_templates.css index 4915b49..b5d1e7b 100644 --- a/frontend/static/css/reminders.css +++ b/frontend/static/css/reminders_templates.css @@ -46,7 +46,8 @@ } /* REMINDER LIST */ -#reminder-list { +#reminder-list, +#template-list { --gap: 1rem; --entry-width: 13rem; max-width: 43rem; @@ -60,13 +61,15 @@ 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; } @@ -95,7 +98,8 @@ background-color: var(--color-gray); } -div.entry.fit { +div.entry.fit, +button.entry.fit { flex-grow: 1; } @@ -160,6 +164,11 @@ div.entry.fit { opacity: 1; } +#delete-template { + border-color: var(--color-error); + color: var(--color-error); +} + @media (max-width: 543px) { header > div { transform: translateX(0); diff --git a/frontend/static/js/add.js b/frontend/static/js/add.js index 238e260..e400f9c 100644 --- a/frontend/static/js/add.js +++ b/frontend/static/js/add.js @@ -52,6 +52,7 @@ function addReminder() { function showAdd() { if (!document.getElementById('add-entry').classList.contains('error')) { + loadTemplates(force=false); showWindow('add'); } else { showWindow('notification'); @@ -61,6 +62,7 @@ function showAdd() { 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; @@ -89,6 +91,7 @@ function toggleRepeated() { // 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/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 8a82012..484f80f 100644 --- a/frontend/static/js/reminders.js +++ b/frontend/static/js/reminders.js @@ -134,3 +134,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 85b0a7d..3d4eee6 100644 --- a/frontend/templates/reminders.html +++ b/frontend/templates/reminders.html @@ -6,7 +6,7 @@ - + @@ -16,6 +16,7 @@ + Reminders - Noted @@ -74,49 +75,71 @@
-
- -
- -
+
+ +
-
- + + +
+ +
+
+ + +

Add a notification service first!

+ +
+
+

Add a reminder

+
@@ -270,6 +293,35 @@
+
+

Add a template

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

Edit a template

+
+
+ + + +
+ + + +
+
+
+
diff --git a/tests/api_test.py b/tests/api_test.py index 9ac73d8..c062c4d 100644 --- a/tests/api_test.py +++ b/tests/api_test.py @@ -40,9 +40,9 @@ class Test_API(unittest.TestCase): 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(NotificationServiceInUse), return_api(**NotificationServiceInUse.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): From e0dc2ddda68aed7f13b8c7775905489c45890865 Mon Sep 17 00:00:00 2001 From: CasVT Date: Thu, 19 Jan 2023 21:46:43 +0100 Subject: [PATCH 34/39] Moved delete button to edit page --- frontend/static/css/add_edit.css | 5 +++ frontend/static/css/reminders_templates.css | 48 -------------------- frontend/static/js/edit.js | 27 ++++++++++++ frontend/static/js/reminders.js | 49 ++------------------- frontend/templates/reminders.html | 1 + 5 files changed, 37 insertions(+), 93 deletions(-) diff --git a/frontend/static/css/add_edit.css b/frontend/static/css/add_edit.css index 966a0e5..d374b70 100644 --- a/frontend/static/css/add_edit.css +++ b/frontend/static/css/add_edit.css @@ -107,6 +107,11 @@ div.options > button { 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/reminders_templates.css b/frontend/static/css/reminders_templates.css index b5d1e7b..5ec7de9 100644 --- a/frontend/static/css/reminders_templates.css +++ b/frontend/static/css/reminders_templates.css @@ -98,7 +98,6 @@ background-color: var(--color-gray); } -div.entry.fit, button.entry.fit { flex-grow: 1; } @@ -117,53 +116,6 @@ button.entry.fit { font-weight: 500; } -.entry-overlay { - --height: 1.7rem; - - 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; -} - -.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); diff --git a/frontend/static/js/edit.js b/frontend/static/js/edit.js index 60095e4..b296c40 100644 --- a/frontend/static/js/edit.js +++ b/frontend/static/js/edit.js @@ -112,9 +112,36 @@ function toggleEditRepeated() { 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/reminders.js b/frontend/static/js/reminders.js index 484f80f..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('div.entry').forEach(e => e.remove()); + table.querySelectorAll('button.entry:not(#add-entry)').forEach(e => e.remove()); result.forEach(reminder => { - const entry = document.createElement('div'); + 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; @@ -26,24 +27,6 @@ function fillTable(result) { }; 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); @@ -51,7 +34,7 @@ function fillTable(result) { entry.classList.add('expand'); }; }); - table.querySelectorAll('div.entry').forEach(reminder => reminder.classList.add('fit')); + table.querySelectorAll('button.entry:not(#add-entry)').forEach(reminder => reminder.classList.add('fit')); }; function fillList() { @@ -102,30 +85,6 @@ function clearSearch() { fillList(); } -function deleteReminder(id) { - const entry = document.querySelector(`div.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(); diff --git a/frontend/templates/reminders.html b/frontend/templates/reminders.html index 3d4eee6..f3c0c2b 100644 --- a/frontend/templates/reminders.html +++ b/frontend/templates/reminders.html @@ -225,6 +225,7 @@
+
From 18a637a5bfb26626805e5d493d8567e29c04ed82 Mon Sep 17 00:00:00 2001 From: CasVT Date: Thu, 19 Jan 2023 21:51:44 +0100 Subject: [PATCH 35/39] Corrected some doc strings --- backend/reminders.py | 2 +- frontend/api.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/reminders.py b/backend/reminders.py index 3f999f8..cd7a111 100644 --- a/backend/reminders.py +++ b/backend/reminders.py @@ -264,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), diff --git a/frontend/api.py b/frontend/api.py index fd3cf60..da83835 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -373,7 +373,7 @@ def api_notification_service(n_id: int): return return_api({}) #=================== -# Vault endpoints +# Library endpoints #=================== @api.route('/reminders', methods=['GET','POST']) @@ -391,7 +391,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 and notification_service_title of each reminder POST: Description: Add a reminder Parameters (body (content-type: application/json)): From f620e517e2e096aebbff4f816ff3641a807e489c Mon Sep 17 00:00:00 2001 From: CasVT Date: Thu, 19 Jan 2023 21:55:45 +0100 Subject: [PATCH 36/39] Improved input validation of API --- frontend/api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/api.py b/frontend/api.py index da83835..1d9af49 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -99,6 +99,11 @@ def extract_key(values: dict, key: str, check_existence: bool=True) -> Any: 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': From 0dfe6724cc4b73426d52f049a604dccc2b2f4155 Mon Sep 17 00:00:00 2001 From: CasVT Date: Thu, 19 Jan 2023 22:10:10 +0100 Subject: [PATCH 37/39] Updated API documentation --- frontend/api.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/api.py b/frontend/api.py index 1d9af49..89b646f 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -396,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, text, time, notification_service and notification_service_title of each 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)): @@ -404,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 """ @@ -482,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 From b7f689afa02c53aca4ed39a83297cbbc0ccb4eaf Mon Sep 17 00:00:00 2001 From: CasVT Date: Thu, 19 Jan 2023 22:40:48 +0100 Subject: [PATCH 38/39] Added error message for time set to past --- frontend/static/js/add.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/static/js/add.js b/frontend/static/js/add.js index e400f9c..09b5299 100644 --- a/frontend/static/js/add.js +++ b/frontend/static/js/add.js @@ -15,6 +15,9 @@ const type_buttons = { }; 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, @@ -44,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); }; From afb706cd44dc0c05564f2e8637f1a2ae13ece1fd Mon Sep 17 00:00:00 2001 From: CasVT Date: Thu, 19 Jan 2023 22:54:44 +0100 Subject: [PATCH 39/39] Excluded Dockerfile --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 585383e..f1306e4 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ dmypy.json # VS code *.code-workspace + +# Docker +Dockerfile \ No newline at end of file