Fixes issue #4

This commit is contained in:
CasVT
2023-01-19 15:50:26 +01:00
parent bbf4c6a389
commit 55afdadc77
10 changed files with 367 additions and 49 deletions

View File

@@ -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}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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':

View File

@@ -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);

View File

@@ -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());

View File

@@ -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());

View File

@@ -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());

View File

@@ -122,6 +122,36 @@
<input type="datetime-local" id="time-input" required>
<select id="notification-service-input" required></select>
</div>
<div class="sub-inputs">
<button type="button" id="normal-button" data-selected="true">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 507.506 507.506" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M163.865 436.934a54.228 54.228 0 0 1-38.4-15.915L9.369 304.966c-12.492-12.496-12.492-32.752 0-45.248 12.496-12.492 32.752-12.492 45.248 0l109.248 109.248L452.889 79.942c12.496-12.492 32.752-12.492 45.248 0 12.492 12.496 12.492 32.752 0 45.248L202.265 421.019a54.228 54.228 0 0 1-38.4 15.915z"></path>
</g>
</svg>
Normal
</button>
<button type="button" id="repeat-button" data-selected="false">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 507.506 507.506" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M163.865 436.934a54.228 54.228 0 0 1-38.4-15.915L9.369 304.966c-12.492-12.496-12.492-32.752 0-45.248 12.496-12.492 32.752-12.492 45.248 0l109.248 109.248L452.889 79.942c12.496-12.492 32.752-12.492 45.248 0 12.492 12.496 12.492 32.752 0 45.248L202.265 421.019a54.228 54.228 0 0 1-38.4 15.915z"></path>
</g>
</svg>
Repeated
</button>
</div>
<div class="repeat-bar hidden">
<p>Repeat every </p>
<input type="number" id="repeat-interval" placeholder="interval" min="1" step="1" oninput="validity.valid || (value='');">
<select id="repeat-quantity">
<option value="minutes">Minute(s)</option>
<option value="hours">Hour(s)</option>
<option value="day" selected>Day(s)</option>
<option value="week">Week(s)</option>
<option value="month">Month(s)</option>
<option value="year">Year(s)</option>
</select>
</div>
<textarea id="text-input" cols="30" rows="10" placeholder="Text (optional)"></textarea>
<div class="options">
<button type="button" id="close-add">Cancel</button>
@@ -139,6 +169,36 @@
<input type="datetime-local" id="time-edit-input" required>
<select id="notification-service-edit-input" required></select>
</div>
<div class="sub-inputs">
<button type="button" id="normal-edit-button">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 507.506 507.506" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M163.865 436.934a54.228 54.228 0 0 1-38.4-15.915L9.369 304.966c-12.492-12.496-12.492-32.752 0-45.248 12.496-12.492 32.752-12.492 45.248 0l109.248 109.248L452.889 79.942c12.496-12.492 32.752-12.492 45.248 0 12.492 12.496 12.492 32.752 0 45.248L202.265 421.019a54.228 54.228 0 0 1-38.4 15.915z"></path>
</g>
</svg>
Normal
</button>
<button type="button" id="repeat-edit-button">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 507.506 507.506" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M163.865 436.934a54.228 54.228 0 0 1-38.4-15.915L9.369 304.966c-12.492-12.496-12.492-32.752 0-45.248 12.496-12.492 32.752-12.492 45.248 0l109.248 109.248L452.889 79.942c12.496-12.492 32.752-12.492 45.248 0 12.492 12.496 12.492 32.752 0 45.248L202.265 421.019a54.228 54.228 0 0 1-38.4 15.915z"></path>
</g>
</svg>
Repeated
</button>
</div>
<div class="repeat-edit-bar">
<p>Repeat every </p>
<input type="number" id="repeat-edit-interval" placeholder="interval" min="1" step="1" oninput="validity.valid || (value='');">
<select id="repeat-edit-quantity">
<option value="minutes">Minute(s)</option>
<option value="hours">Hour(s)</option>
<option value="day" selected>Day(s)</option>
<option value="week">Week(s)</option>
<option value="month">Month(s)</option>
<option value="year">Year(s)</option>
</select>
</div>
<textarea id="text-edit-input" cols="30" rows="10" placeholder="Text (optional)"></textarea>
<div class="options">
<button type="button" id="close-edit">Cancel</button>

View File

@@ -1,3 +1,4 @@
apprise>=0.9.9
python-dateutil>=2.8.2
Flask>=2.1.2
waitress>=2.1.2