initial commit

This commit is contained in:
2021-01-20 01:36:22 -08:00
commit 368f89750b
45 changed files with 2821 additions and 0 deletions

60
app/__init__.py Normal file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
import logging
from flask import Flask
from config import Config
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_mail import Mail
db = SQLAlchemy()
login = LoginManager()
mail = Mail()
def init_app():
app = Flask(__name__)
app.config.from_object(Config)
db.init_app(app)
login.init_app(app)
mail.init_app(app)
from app.sendxmpp_handler import SENDXMPPHandler
if app.config['LOGGING_XMPP_SERVER']:
sendxmpp_handler = SENDXMPPHandler(
logging_xmpp_server=(app.config['LOGGING_XMPP_SERVER']),
logging_xmpp_sender=(app.config['LOGGING_XMPP_SENDER']),
logging_xmpp_password=(app.config['LOGGING_XMPP_PASSWORD']),
logging_xmpp_recipient=(app.config['LOGGING_XMPP_RECIPIENT']),
logging_xmpp_command=(app.config['LOGGING_XMPP_COMMAND']),
logging_xmpp_use_tls=(app.config['LOGGING_XMPP_USE_TLS']),
)
sendxmpp_handler.setLevel(logging.ERROR)
app.logger.addHandler(sendxmpp_handler)
from .tasks import tasks, new_task, task_action, edit_task
from .tasks import delete_move_toggle_task, reorder_priorities
from .categories import hide_categories, delete_category
from .categories import new_category, categories
from .auth import auth, profile, reset_password, register, totp
with app.app_context():
app.register_blueprint(categories.cats)
app.register_blueprint(new_category.new_cat)
app.register_blueprint(hide_categories.hidecats)
app.register_blueprint(delete_category.delcat)
app.register_blueprint(auth.auths)
app.register_blueprint(profile.prof)
app.register_blueprint(reset_password.pwd)
app.register_blueprint(register.reg)
app.register_blueprint(totp.totps)
app.register_blueprint(tasks.tsks)
app.register_blueprint(new_task.newtask)
app.register_blueprint(task_action.taskaction)
app.register_blueprint(edit_task.edittask)
app.register_blueprint(delete_move_toggle_task.deletetask)
app.register_blueprint(delete_move_toggle_task.toggletaskdone)
app.register_blueprint(delete_move_toggle_task.movecat)
app.register_blueprint(reorder_priorities.reorderp)
return app

52
app/auth/auth.py Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
from flask import Blueprint, redirect, url_for, session, flash, render_template
from app.models import Contributor
from app.forms import LoginForm
from flask_login import current_user, login_user, logout_user
auths = Blueprint(
"auths", __name__, template_folder="templates"
)
@auths.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for('cats.index'))
navbar_links = (('cancel', url_for('cats.index')), )
form = LoginForm()
if form.validate_on_submit():
contributor_by_name = Contributor.query.filter_by(name=form.username.data).first()
contributor_by_email = Contributor.query.filter_by(email=form.email.data).first()
if contributor_by_name is not None and contributor_by_name.check_password(form.password.data):
if contributor_by_name.use_totp:
session['id'] = contributor_by_name.id
session['remember_me'] = form.remember_me.data
return redirect(url_for('totps.two_factor_input'))
else:
login_user(contributor_by_name, remember=form.remember_me.data)
flash("Congratulations, you are now logged in!")
return redirect(url_for('cats.index'))
elif contributor_by_email is not None and contributor_by_email.check_password(form.password.data):
if contributor_by_email.use_totp:
session['id'] = contributor_by_email.id
session['remember_me'] = form.remember_me.data
return redirect(url_for('totps.two_factor_input'))
else:
login_user(contributor_by_email, remember=form.remember_me.data)
flash("Congratulations, you are now logged in!")
return redirect(url_for('cats.index'))
else:
flash("Error Invalid Contributor (Username or Email) or Password")
return(redirect(url_for('auths.login')))
return render_template('login.html', title='Sign In', form=form, navbar_links=navbar_links)
@auths.route("/logout")
def logout():
is_authenticated = current_user.is_authenticated
logout_user()
if is_authenticated:
flash("Congratulations, you are now logged out!")
return redirect(url_for('cats.index'))

30
app/auth/email.py Normal file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
from flask import render_template
from flask_mail import Message
from flask import current_app as app
from .. import mail
from threading import Thread
def send_password_reset_email(contributor, external_url):
token = contributor.get_reset_password_token()
send_email('Todo App Reset Your Password',
sender=app.config['MAIL_ADMINS'][0],
recipients=[contributor.email],
text_body=render_template('email/reset_password_email_text.txt',
contributor=contributor, token=token, external_url=external_url),
html_body=render_template('email/reset_password_email_html.html',
contributor=contributor, token=token, external_url=external_url))
def send_async_email(app, msg):
with app.app_context():
mail.send(msg)
def send_email(subject, sender, recipients, text_body, html_body):
msg = Message(subject, sender=sender, recipients=recipients)
msg.body = text_body
msg.html = html_body
Thread(target=send_async_email, args=(app._get_current_object(), msg)).start()

67
app/auth/profile.py Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
from flask import Blueprint, redirect, url_for, request, flash, render_template
from flask_login import current_user
from app.models import Contributor
from app.forms import EditProfile, ChangePassword
from .. import db
prof = Blueprint(
"prof", __name__, template_folder="templates"
)
@prof.route("/edit-profile", methods=["GET", "POST"])
def edit_profile():
if current_user.is_anonymous:
return(redirect(url_for('cats.index')))
contributor = Contributor.query.get(current_user.id)
form = EditProfile()
navbar_links = (('cancel', url_for('cats.index')), )
if request.method == 'GET':
form.username.data = contributor.name
form.email.data = contributor.email
if form.validate_on_submit():
if contributor.check_password(form.password.data):
contributor.name = form.username.data
contributor.email = form.email.data
db.session.commit()
flash("Thanks for the update!")
return(redirect(url_for('cats.index')))
else:
flash("Error Invalid Password")
return(redirect(url_for('prof.edit_profile')))
return render_template(
'edit_profile.html',
title='Edit Profile', form=form,
contributor_use_totp=contributor.use_totp,
navbar_links=navbar_links
)
@prof.route("/change-password", methods=["GET", "POST"])
def change_password():
if not current_user.is_authenticated:
return(redirect(url_for('cats.index')))
contributor = Contributor.query.get(current_user.id)
form = ChangePassword()
nl = (('cancel', url_for('prof.edit_profile')), )
if form.validate_on_submit():
if contributor.check_password(form.password.data):
contributor.set_password(form.new_password.data)
db.session.commit()
flash("Thanks for the update!")
return(redirect(url_for('cats.index')))
else:
flash("Error Invalid Password")
return(redirect(url_for('prof.change_password')))
return render_template(
'change_password.html',
title='Change Password',
form=form, navbar_links=nl
)

44
app/auth/register.py Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
import psycopg2
from flask import Blueprint, redirect, url_for, flash, render_template
from flask_login import current_user
from app.forms import RegistrationForm
from app.models import Contributor
from flask import current_app as app
from .. import db
reg = Blueprint(
"reg", __name__, template_folder="templates"
)
@reg.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for('cats.index'))
form = RegistrationForm()
nl = (('cancel', url_for('auths.login')), )
if form.validate_on_submit():
set_contributor_id_seq()
contributor = Contributor(name=form.username.data, email=form.email.data)
contributor.set_password(form.password.data)
db.session.add(contributor)
db.session.commit()
flash("Congratulations, you are now a registered user!")
return redirect(url_for('auths.login'))
return render_template('register.html', title='Register', form=form, navbar_links=nl)
def set_contributor_id_seq():
conn = psycopg2.connect(
dbname=app.config['DATABASE_NAME'],
user=app.config['DATABASE_USER'],
host=app.config['DATABASE_HOST'],
password=app.config['DATABASE_PASSWORD']
)
cur = conn.cursor()
cur.execute("SELECT setval('contributor_id_seq', (SELECT MAX(id) FROM contributor))")
conn.commit()
conn.close()

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
from flask import Blueprint, redirect, url_for, flash, render_template
from flask_login import current_user
from app.forms import ResetPasswordRequestForm, ResetPasswordForm
from app.models import Contributor
from flask import current_app as app
from .. import db
from .email import send_password_reset_email
pwd = Blueprint(
"pwd", __name__, template_folder="templates"
)
@pwd.route('/reset-password-request', methods=['GET', 'POST'])
def reset_password_request():
if current_user.is_authenticated:
return(redirect(url_for('cats.index')))
nl = (('cancel', url_for('cats.index')), )
form = ResetPasswordRequestForm()
if form.validate_on_submit():
contributor = Contributor.query.filter_by(email=form.email.data).first()
if contributor:
send_password_reset_email(contributor, app.config['EXTERNAL_URL'])
flash('Check your email for the instructions to reset your password')
return redirect(url_for('auths.login'))
else:
flash('Sorry, invalid email')
return redirect(url_for('auths.login'))
return render_template('reset_password_request.html', title='Reset Password', form=form, navbar_links=nl)
@pwd.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('cats.index'))
nl = (('cancel', url_for('cats.index')), )
contributor = Contributor.verify_reset_password_token(token)
if not contributor:
return redirect(url_for('cats.index'))
form = ResetPasswordForm()
if form.validate_on_submit():
contributor.set_password(form.password.data)
db.session.commit()
flash('Your password has been reset.')
return redirect(url_for('auths.login'))
return render_template('reset_password.html', title="New Password?", form=form, navbar_links=nl)

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block content %}
<div class="formContainer">
<h1>Change Password</h1>
<form action='' method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.password.label }}<br>
{{ form.password(size=24) }}
{% for error in form.password.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>
{{ form.new_password.label }}<br>
<span class="inputInfo">
min 15 chars and at least somewhat random
</span><br>
{{ form.new_password(size=24) }}
{% for error in form.new_password.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>
{{ form.new_password2.label }}<br>
{{ form.new_password2(size=24) }}
{% for error in form.new_password2.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
<style>
.formContainer {
align-items: center;
margin-top: 100px;
}
#submitContainer {
margin-top: 100px;
}
</style>
{% extends "base.html" %}
{% block content %}
<div class='formContainer'>
<form action="" method="post" novalidate>
<h2>Disable 2FA?</h2>
{{ form.hidden_tag() }}
<p id="submitContainer">{{ form.submit() }}</p>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block content %}
<div class="formContainer">
<h1>Edit Profile</h1>
<form action='' method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
<span class="inputInfo">
letters and digits only (i.e. no spaces)
</span><br>
{{ form.username(size=24) }}
{% for error in form.username.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>
{{ form.email.label }}<br>
{{ form.email(size=24) }}
{% for error in form.email.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>
{{ form.password.label }}<br>
{{ form.password(size=24) }}
{% for error in form.password.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
{% if current_user.is_authenticated %}
<div>
<button class="formButton" onclick="location.href='{{ url_for('prof.change_password') }}';">Change Password</button>
</div>
{% if contributor_use_totp %}
<div>
<button class="formButton" onclick="location.href='{{ url_for('totps.disable_totp') }}';">Disable 2 Factor</button>
</div>
{% else %}
<div>
<button class="formButton" onclick="location.href='{{ url_for('totps.enable_totp') }}';">Enable 2 Factor</button>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block content %}
<div class="formContainer">
<h1>Sign In</h1>
<h3>(username Or email)</h3>
<form action='' method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
{{ form.username(size=24) }}
{% for error in form.username.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>
{{ form.email.label }}<br>
{{ form.email(size=24) }}
{% for error in form.email.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>
{{ form.password.label }}<br>
{{ form.password(size=24) }}
{% for error in form.password.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>{{ form.remember_me() }} {{ form.remember_me.label }}</p>
<p>{{ form.submit() }}</p>
</form>
<p>New User? <a class="extraFormLink" href="{{ url_for('reg.register') }}">Click to Register!</a></p>
<p>
Forgot Your Password? <a class="extraFormLink" href="{{ url_for('pwd.reset_password_request') }}">Click to Reset It</a>
</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,53 @@
<style>
.formWarning {
margin-bottom: 20px;
}
#totpp {
margin-top: 80px;
margin-bottom: 50px;
display: flex;
flex-direction: column;
align-items: center;
height: 50px;
justify-content: space-between;
}
#submitContainer {
display: flex;
justify-content: center;
}
#submit {
margin-top: 30px;
}
#svg_container {
margin-top: 100px;
align-items: center;
}
svg {
transform: scale(1.5);
}
</style>
{% extends "base.html" %}
{% block content %}
<div id='svg_container' class='formContainer'>
<form action='' method="post" novalidate>
{{ form.hidden_tag() }}
<p>{{ qr | safe }}</p>
<p id="totpp">
{{ form.totp_code.label }}<br>
{% for error in form.totp_code.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
{{ form.totp_code(size=6) }}
</p>
<p id="submitContainer">{{ form.submit() }}</p>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block content %}
<div class="formContainer">
<h1>Register</h1>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
<span class="inputInfo">
letters and digits only (i.e. no spaces)
</span><br>
{{ form.username(size=24) }}
{% for error in form.username.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>
{{ form.email.label }}<br>
{{ form.email(size=24) }}
{% for error in form.email.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>
{{ form.password.label }}<br>
<span class="inputInfo">
min 15 chars and at least somewhat random
</span><br>
{{ form.password(size=24) }}
{% for error in form.password.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>
{{ form.password2.label }}<br>
{{ form.password2(size=24) }}
{% for error in form.password2.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
<div class="formContainer">
<h1>Reset Your Password</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.password.label }}<br>
<span class="inputInfo">
min 15 chars and at least somewhat random
</span><br>
{{ form.password(size=24) }}<br>
{% for error in form.password.errors %}
<span class="formWarning">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.password2.label }}<br>
{{ form.password2(size=24) }}<br>
{% for error in form.password2.errors %}
<span class="formWarning">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<div class="formContainer">
<h1>{{ title }}</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.email.label }}<br>
{{ form.email(size=24) }}<br>
{% for error in form.email.errors %}
<span class="formWarning">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
<style>
#totpp {
margin-top: 10%;
margin-bottom: 50px;
display: flex;
flex-direction: column;
align-items: center;
height: 50px;
justify-content: space-between;
}
#submitContainer {
display: flex;
justify-content: center;
}
#submit {
margin-top: 30px;
}
h3 {
align-self: center;
margin-top: 10%;
}
</style>
{% extends "base.html" %}
{% block content %}
<h3>{{ inst }}</h3>
<div class='formContainer'>
<form action={{ url_for('totps.two_factor_input') }} method="post" novalidate>
{{ form.hidden_tag() }}
<p id="totpp">
{{ form.totp_code.label }}<br>
{% for error in form.totp_code.errors %}
<span class="formWarning">[{{error}}]</span>
{% endfor %}
{{ form.totp_code(size=6) }}
</p>
<p id="submitContainer">{{ form.submit() }}</p>
</form>
</div>
{% endblock %}

82
app/auth/totp.py Normal file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
from flask import Blueprint, session, redirect, url_for, flash, render_template
from flask import current_app as app
from app.models import Contributor
from app.forms import GetTotp, DisableTotp, ConfirmTotp
from flask_login import current_user, login_user
from pyotp.totp import TOTP
from .totp_utils import disable_2fa, validate_totp, get_totp_qr
totps = Blueprint(
"totps", __name__, template_folder="templates"
)
@totps.route("/two-factor-input", methods=["GET", "POST"])
def two_factor_input():
if current_user.is_authenticated or 'id' not in session:
return redirect(url_for('cats.index'))
nl = (('cancel', url_for('cats.index')), )
contributor = Contributor.query.get(session['id'])
if contributor is None:
return redirect(url_for('cats.index'))
form = GetTotp()
if form.validate_on_submit():
if TOTP(contributor.totp_key).verify(int(form.totp_code.data), valid_window=5):
login_user(contributor, remember=session['remember_me'])
flash("Congratulations, you are now logged in!")
return redirect(url_for('cats.index'))
else:
flash("Oops, the pin was wrong")
form.totp_code.data = None
return render_template('two_factor_input.html', form=form, inst="Code was wrong, try again?")
return render_template(
'two_factor_input.html', form=form,
inst="Enter Auth Code", navbar_links=nl
)
@totps.route('/disable-totp', methods=['GET', 'POST'])
def disable_totp():
if current_user.is_anonymous or not current_user.use_totp:
return(redirect(url_for('cats.index')))
nl = (('cancel', url_for('prof.edit_profile')), )
contributor = Contributor.query.get(current_user.id)
form = DisableTotp()
if form.validate_on_submit():
if disable_2fa(contributor, app.config):
flash('2FA Now Disabled')
return(redirect(url_for('prof.edit_profile')))
else:
flash('2FA Not Disabled')
return(redirect(url_for('prof.edit_profile')))
return render_template(
'disable_2fa.html', form=form,
title="Disable 2FA", navbar_links=nl
)
@totps.route('/enable-totp', methods=['GET', 'POST'])
def enable_totp():
if current_user.is_anonymous or current_user.use_totp:
return(redirect(url_for('cats.index')))
nl = (('cancel', url_for('prof.edit_profile')), )
contributor = Contributor.query.get(current_user.id)
form = ConfirmTotp()
qr = get_totp_qr(contributor, app.config)
if form.validate_on_submit():
if contributor.use_totp:
flash('2FA Already Enabled')
return(redirect(url_for('prof.edit_profile')))
if validate_totp(contributor, form.totp_code.data, app.config):
flash('2FA Now Enabled')
return(redirect(url_for('prof.edit_profile')))
else:
flash("TOTP Code didn't validate, rescan and try again")
return(redirect(url_for('prof.edit_profile')))
return render_template(
'qr.html', qr=qr, form=form,
title="Aunthentication Code",
navbar_links=nl
)

59
app/auth/totp_utils.py Normal file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
import psycopg2
import pyotp
import qrcode
import qrcode.image.svg
from io import BytesIO
def get_totp_qr(contributor, app_config):
totp_key = pyotp.random_base32() if contributor.totp_key is None else contributor.totp_key
if contributor.totp_key is None:
conn = psycopg2.connect(
dbname=app_config['DATABASE_NAME'],
user=app_config['DATABASE_USER'],
host=app_config['DATABASE_HOST'],
password=app_config['DATABASE_PASSWORD']
)
cur = conn.cursor()
cur.execute("UPDATE contributor SET totp_key=%s WHERE id=%s", (totp_key, contributor.id))
conn.commit()
conn.close()
totp_uri = pyotp.totp.TOTP(totp_key).provisioning_uri(name=contributor.email, issuer_name='Todo App')
img = qrcode.make(totp_uri, image_factory=qrcode.image.svg.SvgPathImage)
f = BytesIO()
img.save(f)
return(f.getvalue().decode('utf-8'))
def validate_totp(contributor, totp_code, app_config):
if pyotp.TOTP(contributor.totp_key).verify(int(totp_code), valid_window=5):
conn = psycopg2.connect(
dbname=app_config['DATABASE_NAME'],
user=app_config['DATABASE_USER'],
host=app_config['DATABASE_HOST'],
password=app_config['DATABASE_PASSWORD']
)
cur = conn.cursor()
cur.execute("UPDATE contributor SET use_totp='1' WHERE id=%s", (contributor.id, ))
conn.commit()
conn.close()
return True
else:
return False
def disable_2fa(contributor, app_config):
conn = psycopg2.connect(
dbname=app_config['DATABASE_NAME'],
user=app_config['DATABASE_USER'],
host=app_config['DATABASE_HOST'],
password=app_config['DATABASE_PASSWORD']
)
cur = conn.cursor()
cur.execute("UPDATE contributor SET use_totp='0', totp_key=Null WHERE id=%s", (contributor.id, ))
conn.commit()
conn.close()
return True

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
from flask import Blueprint, url_for, render_template, redirect
from flask_login import current_user
from app.models import Category, Task
cats = Blueprint(
"cats", __name__, template_folder="templates"
)
@cats.route("/move-categories/<int:taskid>")
def move_categories(taskid):
task = Task.query.get(taskid)
if task is None:
return(redirect(url_for('cats.index')))
if current_user.is_anonymous or current_user.id != task.contributor_id:
return(redirect(url_for('cats.index')))
cu = 'tsks.{}tasks'.format('hidden_' if task.done else '')
cancel_nav_link = ('cancel', url_for(cu, category_id=task.catid))
nl = (cancel_nav_link, )
categories = Category.query.filter(Category.contributor_id == current_user.id, Category.id != task.catid).all()
for cat in categories:
cat.href = url_for('movecat.move_cat', taskid=taskid, catid=cat.id)
cat.open = Task.query.filter_by(catid=cat.id, contributor_id=cat.contributor_id, done=False).count()
cat.done = Task.query.filter_by(catid=cat.id, contributor_id=cat.contributor_id, done=True).count()
return render_template(
'categories.html',
title="Move to",
navbar_links=nl,
heading='move to?',
categories=categories
)
@cats.route("/index")
@cats.route("/")
def index():
if current_user.is_anonymous:
navbar_links = (('login', url_for('auths.login')), )
return render_template('categories.html', title="Todo", navbar_links=navbar_links)
nl = (
('prof', url_for('prof.edit_profile')),
('new', url_for('new_cat.new_category')),
('hide', url_for('hidecats.hide_categories')),
('logout', url_for('auths.logout'))
)
categories = Category.query.filter_by(contributor_id=current_user.id, hidden=False).all()
for cat in categories:
cat.href = url_for('tsks.tasks', category_id=cat.id)
cat.open = Task.query.filter_by(catid=cat.id, contributor_id=cat.contributor_id, done=False).count()
cat.done = Task.query.filter_by(catid=cat.id, contributor_id=cat.contributor_id, done=True).count()
return render_template(
'categories.html',
title="Categories",
navbar_links=nl,
heading='categories',
categories=categories
)

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
from flask import Blueprint, url_for, redirect, render_template, flash
from flask_login import current_user
from app.models import Category, Task
from .. import db
delcat = Blueprint(
"delcat", __name__, template_folder="templates"
)
@delcat.route("/delete_category/<int:catid>")
def delete_category(catid):
category = Category.query.get(catid)
if category is None:
return(redirect(url_for('cats.index')))
if current_user.is_anonymous or current_user.id != category.contributor_id:
return(redirect(url_for('cats.index')))
db.session.delete(category)
db.session.commit()
flash("category {} is deleted".format(category.name))
return(redirect(url_for('hidecats.unhide_categories')))
@delcat.route("/delete-categories")
def delete_categories():
if current_user.is_anonymous:
return(redirect(url_for('cats.index')))
nl = (
('cancel', url_for('hidecats.unhide_categories')),
('logout', url_for('auths.logout'))
)
categories = Category.query.filter_by(contributor_id=current_user.id, hidden=True).all()
for cat in categories:
cat.href = url_for('delcat.delete_category', catid=cat.id)
cat.open = Task.query.filter_by(catid=cat.id, contributor_id=cat.contributor_id, done=False).count()
cat.done = Task.query.filter_by(catid=cat.id, contributor_id=cat.contributor_id, done=True).count()
return render_template(
'categories.html',
title="Category To Delete",
navbar_links=nl,
heading='category to delete?',
categories=categories
)

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
from flask import Blueprint, redirect, url_for, render_template, flash
from flask_login import current_user
from app.models import Category, Task
from .. import db
hidecats = Blueprint(
"hidecats", __name__, template_folder="templates"
)
@hidecats.route("/category-toggle-hidden/<int:catid>")
def category_toggle_hidden(catid):
category = Category.query.get(catid)
if category is None:
return(redirect(url_for('cats.index')))
if current_user.is_anonymous or current_user.id != category.contributor_id:
return(redirect(url_for('cats.index')))
category.hidden = not category.hidden
db.session.commit()
if category.hidden:
flash("category {} is now hidden".format(category.name))
return(redirect(url_for('hidecats.hide_categories')))
else:
flash("category {} is now unhidden".format(category.name))
return(redirect(url_for('hidecats.unhide_categories')))
@hidecats.route("/unhide-categories")
def unhide_categories():
if current_user.is_anonymous:
return(redirect(url_for('cats.index')))
if Category.query.filter_by(contributor_id=current_user.id, hidden=True).count() == 0:
return(redirect(url_for('hidecats.hide_categories')))
nl = (
('cancel', url_for('cats.index')),
('hide', url_for('hidecats.hide_categories')),
('delete', url_for('delcat.delete_categories'))
)
categories = Category.query.filter_by(contributor_id=current_user.id, hidden=True).all()
for cat in categories:
cat.href = url_for('hidecats.category_toggle_hidden', catid=cat.id)
cat.open = Task.query.filter_by(catid=cat.id, contributor_id=cat.contributor_id, done=False).count()
cat.done = Task.query.filter_by(catid=cat.id, contributor_id=cat.contributor_id, done=True).count()
return render_template(
'categories.html',
title="Category To unHide",
navbar_links=nl,
heading='category to unhide?',
categories=categories
)
@hidecats.route("/hide-categories")
def hide_categories():
if current_user.is_anonymous:
return(redirect(url_for('cats.index')))
num_hidden_cats = Category.query.filter_by(contributor_id=current_user.id, hidden=True).count()
nl = [('cancel', url_for('cats.index')), ]
if num_hidden_cats > 0:
nl.append(('unhide', url_for('hidecats.unhide_categories')), )
nl.append(('logout', url_for('auths.logout')))
categories = Category.query.filter_by(contributor_id=current_user.id, hidden=False).all()
for cat in categories:
cat.href = url_for('hidecats.category_toggle_hidden', catid=cat.id)
cat.open = Task.query.filter_by(catid=cat.id, contributor_id=cat.contributor_id, done=False).count()
cat.done = Task.query.filter_by(catid=cat.id, contributor_id=cat.contributor_id, done=True).count()
return render_template(
'categories.html',
title="Category To Hide",
navbar_links=nl,
heading='category to hide?',
categories=categories
)

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
import psycopg2
from flask import Blueprint, redirect, url_for, flash, render_template
from flask import current_app as app
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Regexp
from app.models import Category
new_cat = Blueprint(
"new_cat", __name__, template_folder="templates"
)
def insert_category(category):
conn = psycopg2.connect(
dbname=app.config['DATABASE_NAME'],
user=app.config['DATABASE_USER'],
host=app.config['DATABASE_HOST'],
password=app.config['DATABASE_PASSWORD']
)
cur = conn.cursor()
cur.execute("SELECT setval('category_id_seq', (SELECT MAX(id) FROM category))")
conn.commit()
cur.execute("SELECT count(id) FROM category WHERE name=%s AND contributor_id=%s", (category.name, category.contributor_id))
if cur.fetchone()[0] == 0:
cur.execute("INSERT INTO category(name, contributor_id) VALUES(%s, %s)", (category.name, category.contributor_id))
conn.commit()
conn.close
@new_cat.route("/new-category", methods=["GET", "POST"])
def new_category():
if current_user.is_anonymous:
return(redirect(url_for('cats.index')))
form = NewCategory()
nl = (('cancel', url_for('cats.index')), )
if form.validate_on_submit():
category = Category(name=form.name.data, contributor_id=current_user.id)
insert_category(category)
flash("Thanks for the new category!")
return(redirect(url_for('cats.index')))
return render_template('new_category.html', title='New Category', form=form, navbar_links=nl)
class NewCategory(FlaskForm):
name = StringField(
'New Task Category',
validators=[
DataRequired(),
Regexp('^[a-zA-Z0-9\\s-]+$', message='dashes, digits, and spaces are ok')
],
render_kw={'autofocus': True}
)
submit = SubmitField('Save')

View File

@@ -0,0 +1,42 @@
<style>
#main {
align-items: center;
}
h1 {
margin-top: 40px;
font-size: 2.3rem;
}
.buttonLink {
width: 95%;
max-width: 700px;
text-decoration: none;
}
.buttonLink button {
width: 100%;
color: white;
background-color: black;
padding: 10px 20px 10px 20px;
margin-bottom: 10px;
border-radius: 9px;
font-size: 1.3rem;
display: flex;
font-weight: bold;
justify-content: space-between;
}
</style>
{% extends "base.html" %}
{% block content %}
<h1>{{ heading }}</h1>
{% for category in categories %}
<a href={{ category.href }} class="buttonLink">
<button>
{{ category.name }}
<div style="display: flex;">
<div>{{ category.open }},</div>
<div style="color: grey;">{{ category.done }}</div>
</div>
</button>
</a>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,28 @@
<style>
#name {
font-size: 1.2rem;
}
form {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
{% extends "base.html" %}
{% block content %}
<div class="formContainer">
<h1>{{ titile }}</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.name.label }}<br>
{{ form.name(size=24) }}<br>
{% for error in form.name.errors %}
<span class="formWarning">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
</div>
{% endblock %}

101
app/forms.py Normal file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Optional, Regexp, Length, EqualTo, ValidationError
from app.models import Contributor, EmailWhiteList
from zxcvbn import zxcvbn
class DisableTotp(FlaskForm):
submit = SubmitField('Disable 2FA')
class GetTotp(FlaskForm):
totp_code = StringField('6-Digit Code?', validators=[DataRequired(), Length(min=6, max=6, message="6 Digits")], render_kw={'autofocus': True})
submit = SubmitField('OK')
class ConfirmTotp(FlaskForm):
totp_code = StringField('6-Digit Code?', validators=[DataRequired(), Length(min=6, max=6, message="Rescan And Try Again")], render_kw={'autofocus': True})
submit = SubmitField('Enable 2FA')
class LoginForm(FlaskForm):
username = StringField('Username', validators=[Optional()], render_kw={'autofocus': True})
email = StringField('Email', validators=[Optional(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Remember Me')
submit = SubmitField('Sign In')
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Regexp('^[a-zA-Z0-9]+$', message='letters and digits only (no spaces)')], render_kw={'autofocus': True})
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=15, )])
password2 = PasswordField('Repeat Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
def validate_password(self, password):
if zxcvbn(password.data)['score'] < 3:
raise ValidationError('needs to be stronger')
def validate_username(self, username):
user = Contributor.query.filter_by(name=username.data).first()
if user is not None:
raise ValidationError('Please use a different username.')
def validate_email(self, email):
white_listed_user = EmailWhiteList.query.filter_by(email=email.data).first()
if white_listed_user is None:
raise ValidationError('This email address is not authorized.')
user = Contributor.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('Please use a different email address.')
class ChangePassword(FlaskForm):
password = PasswordField('Confirm Password', validators=[DataRequired()], render_kw={'autofocus': True})
new_password = PasswordField('New Password', validators=[DataRequired(), Length(min=15, )])
new_password2 = PasswordField('Repeat New Password', validators=[DataRequired(), EqualTo('new_password')])
submit = SubmitField('Save')
def validate_password(self, password):
if zxcvbn(password.data)['score'] < 3:
raise ValidationError('needs to be stronger')
class ResetPasswordForm(FlaskForm):
password = PasswordField('Password', validators=[DataRequired(), Length(min=15, )], render_kw={'autofocus': True})
password2 = PasswordField('Repeat Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Request Password Reset')
def validate_password(self, password):
if zxcvbn(password.data)['score'] < 3:
raise ValidationError('needs to be stronger')
class EditProfile(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Regexp('^[a-zA-Z0-9]+$', message='letters and digits only (no spaces)')], render_kw={'autofocus': True})
email = StringField('Email', validators=[Optional(), Email()])
password = PasswordField('Confirm Password', validators=[DataRequired()])
submit = SubmitField('Update Name/Email')
def validate_username(self, username):
from flask_login import current_user
if username.data != current_user.name:
user = Contributor.query.filter_by(name=username.data).first()
if user is not None:
raise ValidationError('Please pick a different username.')
def validate_email(self, email):
from flask_login import current_user
if email.data != current_user.email:
user = Contributor.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('Please pick a different email address.')
class ResetPasswordRequestForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()], render_kw={'autofocus': True})
submit = SubmitField('Request Password Reset')

73
app/models.py Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import jwt
from flask import current_app as app
from . import db, login
from time import time
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
@login.user_loader
def load_contributor(id):
return Contributor.query.get(int(id))
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
contributor_id = db.Column(db.Integer, nullable=False)
catid = db.Column(db.Integer, nullable=False)
done = db.Column(db.Boolean, default=False)
priority = db.Column(db.Integer)
timestamp = db.Column(db.DateTime)
def __repr__(self):
return '<Task {}>'.format(self.id)
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), nullable=False, unique=True)
contributor_id = db.Column(db.Integer, nullable=False)
hidden = db.Column(db.Boolean, default=False)
def __repr__(self):
return '<Category {}>'.format(self.name)
class Contributor(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), nullable=False, unique=True)
email = db.Column(db.String(120), nullable=False, unique=True)
password_hash = db.Column(db.String(128))
totp_key = db.Column(db.String(16))
use_totp = db.Column(db.Boolean, default=False)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return '<Contributor {}>'.format(self.name)
def get_reset_password_token(self, expires_in=1800):
return jwt.encode({'reset_password': self.id, 'exp': time() + expires_in}, app.config['SECRET_KEY'], algorithm='HS256').decode('utf-8')
@staticmethod
def verify_reset_password_token(token):
try:
id = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])['reset_password']
except BaseException as error:
print('An exception occurred: {}'.format(error))
return Contributor.query.get(id)
class EmailWhiteList(db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), nullable=False, unique=True)
def __repr__(self):
return '<EmailWhiteList {}>'.format(self.email)

51
app/sendxmpp_handler.py Normal file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
# app/sendxmpp_handler.py
from logging import Handler
from os import popen
class SENDXMPPHandler(Handler):
def __init__(
self,
logging_xmpp_server,
logging_xmpp_sender,
logging_xmpp_password,
logging_xmpp_recipient,
logging_xmpp_command,
logging_xmpp_use_tls
):
Handler.__init__(self)
self.logging_xmpp_server = logging_xmpp_server
self.logging_xmpp_sender = logging_xmpp_sender
self.logging_xmpp_password = logging_xmpp_password
self.logging_xmpp_recipient = logging_xmpp_recipient
self.logging_xmpp_command = logging_xmpp_command
self.logging_xmpp_use_tls = logging_xmpp_use_tls
'''
This works on Debian 10 with flask running under gunicorn3 as a systemd service, hack as necessary
echo '<message>' | /usr/bin/sendxmpp -t -u <sender> -j <server> -p <password> <recipient@example.com>
'''
def emit(self, record):
try:
message = self.format(record)
shell_command = "echo '{}' | {} -u {} -j {} -p {} {}".format(
message,
self.logging_xmpp_command,
self.logging_xmpp_sender,
self.logging_xmpp_server,
self.logging_xmpp_password,
self.logging_xmpp_recipient
)
if self.logging_xmpp_use_tls == '1':
shell_command += ' -t'
p = popen(shell_command, "w")
status = p.close()
if status:
print("sendxmpp_handler exit status", status)
except Exception:
self.handleError(record)

118
app/static/css/todo.css Normal file
View File

@@ -0,0 +1,118 @@
html {
overflow: scroll;
scrollbar-width: none;
}
:root {
font-size: 16px;
font-family: sans-serif;
}
body {
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
}
#main {
width: 800px;
min-width: 70vw;
max-width: 100vw;
display: flex;
flex-direction: column;
}
#userGreeting {
padding-right: 20px;
line-height: 0;
height: 0;
position: absolute;
align-self: flex-end;
font-style: italic;
color: grey;
font-size: 1.2rem;
}
#navbarContainer {
background-color: black;
display: flex;
justify-content: center;
width: 100vw;
}
#navbar {
display: flex;
justify-content: space-between;
padding: 5px;
width: 100%;
max-width: 700px;
}
#navbar button {
font-size: 1.5rem;
padding: 5px 15px 5px 15px;
border-radius: 5px;
border: none;
background-color: black;
color: white;
}
input[type=submit] {
padding: 10px;
background-color: black;
color: white;
border-radius: 9px;
font-size: 1.4rem;
font-weight: bolder;
}
.extraFormLink {
white-space: nowrap;
color: black;
}
.formContainer {
font-size: 1.2rem;
display: flex;
flex-direction: column;
align-self: center;
width: 60%;
min-width: 280px;
}
.formWarning {
color: red;
white-space: nowrap;
line-height: 150%;
font-size: .9rem;
font-style: italic;
font-weight: bold;
}
.flashContainer {
margin-top: 40px;
padding: 10px;
background-color: grey;
color: black;
border-radius: 9px;
font-size: 1.1rem;
align-self: center;
}
.inputInfo {
color: grey;
font-style: italic;
font-size: 1rem;
}
.formButton {
padding: 10px;
margin-bottom: 20px;
border-radius: 9px;
font-size: 1.4rem;
font-weight: bolder;
color: white;
background-color: black;
}

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
from flask import Blueprint, redirect, url_for, flash
from flask_login import current_user
from flask import current_app
from app.models import Task, Category
from .. import db
from .reorder_priorities import reorder_priorities
movecat = Blueprint(
"movecat", __name__, template_folder="templates"
)
toggletaskdone = Blueprint(
"toggletaskdone", __name__, template_folder="templates"
)
deletetask = Blueprint(
"deletetask", __name__, template_folder="templates"
)
@movecat.route("/move-cat/<int:taskid>/<int:catid>")
def move_cat(taskid, catid):
task = Task.query.get(taskid)
if current_user.is_anonymous or current_user.id != task.contributor_id:
return(redirect(url_for('cats.index')))
if bool(Category.query.get(catid)):
category = Category.query.get(catid)
if category.contributor_id == task.contributor_id:
previous_catid = task.catid
task.catid = catid
db.session.commit()
flash("Task {} moved!".format(taskid))
reorder_priorities(catid, task.contributor_id, task.done, current_app.config)
reorder_priorities(previous_catid, task.contributor_id, task.done, current_app.config)
if task.done:
return(redirect(url_for('tsks.hidden_tasks', category_id=task.catid)))
else:
return(redirect(url_for('tsks.tasks', category_id=task.catid)))
@deletetask.route("/delete-task/<int:taskid>")
def delete_task(taskid):
task = Task.query.get(taskid)
if current_user.is_anonymous or current_user.id != task.contributor_id:
return(redirect(url_for('cats.index')))
db.session.delete(task)
db.session.commit()
flash("Task {} deleted!".format(taskid))
reorder_priorities(task.catid, task.contributor_id, task.done, current_app.config)
if task.done:
return(redirect(url_for('tsks.hidden_tasks', category_id=task.catid)))
else:
return(redirect(url_for('tsks.tasks', category_id=task.catid)))
@toggletaskdone.route("/toggle-task-done/<int:taskid>")
def toggle_task_done(taskid):
task = Task.query.get(taskid)
if current_user.is_anonymous or current_user.id != task.contributor_id:
return(redirect(url_for('cats.index')))
task.done = not task.done
db.session.commit()
reorder_priorities(task.catid, task.contributor_id, True, current_app.config)
reorder_priorities(task.catid, task.contributor_id, False, current_app.config)
if task.done:
flash("Task {} unmarked done!".format(taskid))
return(redirect(url_for('tsks.tasks', category_id=task.catid)))
else:
flash("Task {} marked done!".format(taskid))
return(redirect(url_for('tsks.hidden_tasks', category_id=task.catid)))

46
app/tasks/edit_task.py Normal file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
from flask import Blueprint, redirect, url_for, flash, render_template, request
from flask_login import current_user
from app.models import Task
from flask_wtf import FlaskForm
from wtforms import SubmitField, TextAreaField
from wtforms.validators import DataRequired
from .. import db
edittask = Blueprint(
"edittask", __name__, template_folder="templates"
)
@edittask.route("/edit-task/<int:taskid>", methods=["GET", "POST"])
def edit_task(taskid):
if current_user.is_anonymous:
return(redirect(url_for('cats.index')))
task = Task.query.get(taskid)
form = EditTask()
if task.done:
cancel_nav_link = ('cancel', url_for('tsks.hidden_tasks', category_id=task.catid))
else:
cancel_nav_link = ('cancel', url_for('tsks.tasks', category_id=task.catid))
nl = (cancel_nav_link, )
if request.method == 'GET':
form.content.data = task.content
if form.validate_on_submit():
task.content = form.content.data
db.session.commit()
flash("Thanks for the task edit!")
if task.done:
return(redirect(url_for('tsks.hidden_tasks', category_id=task.catid)))
else:
return(redirect(url_for('tsks.tasks', category_id=task.catid)))
return render_template('edit_task.html', title='Edit Task', form=form, navbar_links=nl, task=task)
class EditTask(FlaskForm):
content = TextAreaField(
'Edit Task - Markdown Supported',
validators=[DataRequired(), ],
render_kw={'autofocus': True}
)
submit = SubmitField('Save')

73
app/tasks/new_task.py Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import psycopg2
from datetime import datetime
from flask import Blueprint, redirect, url_for, flash, render_template
from flask import current_app as app
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import SubmitField, TextAreaField
from wtforms.validators import DataRequired
from app.models import Task, Category
newtask = Blueprint(
"newtask", __name__, template_folder="templates"
)
def insert_task(task):
conn = psycopg2.connect(
dbname=app.config['DATABASE_NAME'],
user=app.config['DATABASE_USER'],
host=app.config['DATABASE_HOST'],
password=app.config['DATABASE_PASSWORD']
)
cur = conn.cursor()
cur.execute("SELECT setval('task_id_seq', (SELECT MAX(id) FROM task))")
conn.commit()
cur.execute(
"SELECT MAX(priority) FROM task WHERE catid=%s AND contributor_id=%s AND done=%s",
(task.catid, task.contributor_id, False)
)
max_priority = cur.fetchone()[0]
task.priority = 1 if max_priority is None else max_priority + 1
cur.execute(
"SELECT count(id) FROM task WHERE content=%s AND contributor_id=%s",
(task.content, task.contributor_id)
)
if cur.fetchone()[0] == 0:
cur.execute(
"INSERT INTO task(content, contributor_id, catid, priority, timestamp) VALUES(%s,%s,%s,%s,%s)",
(task.content, task.contributor_id, task.catid, task.priority, task.timestamp)
)
conn.commit()
conn.close
@newtask.route("/new-task/<int:category_id>", methods=["GET", "POST"])
def new_task(category_id):
if current_user.is_anonymous:
return(redirect(url_for('cats.index')))
form = NewTask()
category = Category.query.get(category_id)
nl = (('cancel', url_for('tsks.tasks', category_id=category_id)), )
if form.validate_on_submit():
task = Task(
content=form.content.data,
contributor_id=current_user.id,
catid=category_id,
timestamp=str(datetime.utcnow()),
)
insert_task(task)
flash("Thanks for the new task!")
return(redirect(url_for('tsks.tasks', category_id=category_id)))
return render_template('new_task.html', title='New Task', form=form, navbar_links=nl, category=category)
class NewTask(FlaskForm):
content = TextAreaField(
'New Task - Markdown Supported',
validators=[DataRequired(), ],
render_kw={'autofocus': True}
)
submit = SubmitField('Save')

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import psycopg2
from flask import Blueprint, redirect, url_for, request, flash
from flask import current_app
from app.models import Task
from flask_login import current_user
from .task_action import make_move_up_down_bools
from .. import db
reorderp = Blueprint(
"reorderp", __name__, template_folder="templates"
)
@reorderp.route('/move-task/<int:taskid>')
def move_task(taskid):
task = Task.query.get(taskid)
if task is None or current_user.is_anonymous or task.contributor_id != current_user.id:
return(redirect(url_for('cats.index')))
make_move_up_down_bools(task)
priority = task.priority
if request.args['move'] == 'up' and task.can_move_up:
other_task = Task.query.filter(
Task.catid == task.catid,
Task.contributor_id == task.contributor_id,
Task.done == task.done, Task.priority > task.priority).order_by(Task.priority).first()
other_priority = other_task.priority
other_task.priority = task.priority
task.priority = other_priority
if request.args['move'] == 'down' and task.can_move_down:
other_task = Task.query.filter(
Task.catid == task.catid,
Task.contributor_id == task.contributor_id,
Task.done == task.done, Task.priority < task.priority).order_by(Task.priority.desc()).first()
other_priority = other_task.priority
other_task.priority = task.priority
task.priority = other_priority
if request.args['move'] == 'top' and task.can_move_top:
other_task = Task.query.filter(
Task.catid == task.catid,
Task.contributor_id == task.contributor_id,
Task.done == task.done).order_by(Task.priority.desc()).first()
task.priority = other_task.priority + 1
if request.args['move'] == 'end' and task.can_move_end:
task.priority = 0
db.session.commit()
if request.args['move'] == 'end' or request.args['move'] == 'top':
reorder_priorities(task.catid, task.contributor_id, task.done, current_app.config)
flash("task {} is moved {}".format(priority, request.args['move']))
ru = 'tsks.{}tasks'.format('hidden_' if task.done else '')
return(redirect(url_for(ru, category_id=task.catid)))
def reorder_priorities(catid, conid, done, app_config):
conn = psycopg2.connect(
dbname=app_config['DATABASE_NAME'],
user=app_config['DATABASE_USER'],
host=app_config['DATABASE_HOST'],
password=app_config['DATABASE_PASSWORD']
)
cur = conn.cursor()
cur.execute(
"SELECT COUNT(id) FROM task WHERE catid=%s AND contributor_id=%s AND done=%s",
(catid, conid, done))
if cur.fetchone()[0] > 1:
cur.execute(
"SELECT id FROM task WHERE catid=%s AND contributor_id=%s AND done=%s ORDER BY priority",
(catid, conid, done))
ids = [x[0] for x in cur.fetchall()]
for i, task in enumerate(ids, 1):
cur.execute("UPDATE task SET priority=%s WHERE id=%s", (i, task))
conn.commit()
conn.close()

64
app/tasks/task_action.py Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
from flask import Blueprint, redirect, url_for, render_template
from flask_login import current_user
from app.models import Task
taskaction = Blueprint(
"taskaction", __name__, template_folder="templates"
)
@taskaction.route('/task-action/<int:taskid>')
def task_action(taskid):
if current_user.is_anonymous:
return(redirect(url_for('cats.index')))
task = Task.query.get(taskid)
if task.done:
cancel_nav_link = ('cancel', url_for('tsks.hidden_tasks', category_id=task.catid))
else:
cancel_nav_link = ('cancel', url_for('tsks.tasks', category_id=task.catid))
nl = (
cancel_nav_link,
('categories', url_for('cats.index')),
('logout', url_for('auths.logout'))
)
make_move_up_down_bools(task)
return(render_template(
'task_action.html',
title="Task Actions",
navbar_links=nl,
task=task
))
def make_move_up_down_bools(task):
num_higher = Task.query.filter(
Task.catid == task.catid,
Task.contributor_id == task.contributor_id,
Task.done == task.done,
Task.priority > task.priority).count()
num_lower = Task.query.filter(
Task.catid == task.catid,
Task.contributor_id == task.contributor_id,
Task.done == task.done,
Task.priority < task.priority).count()
print(num_higher, num_lower)
if num_higher > 1:
task.can_move_top = True
task.can_move_up = True
elif num_higher == 1:
task.can_move_top = False
task.can_move_up = True
else:
task.can_move_top = False
task.can_move_up = False
if num_lower > 1:
task.can_move_end = True
task.can_move_down = True
elif num_lower == 1:
task.can_move_end = False
task.can_move_down = True
else:
task.can_move_end = False
task.can_move_down = False

72
app/tasks/tasks.py Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
from flask import Blueprint, redirect, url_for, render_template
from flask_login import current_user
from app.models import Category, Task
from markdown import markdown
tsks = Blueprint(
"tsks", __name__, template_folder="templates"
)
@tsks.route("/hidden-tasks/<int:category_id>")
def hidden_tasks(category_id):
category = Category.query.get(category_id)
if current_user.is_anonymous or current_user.id != category.contributor_id:
return(redirect(url_for('cats.index')))
tasks = Task.query.filter_by(
catid=category_id,
contributor_id=current_user.id,
done=True
).order_by(Task.priority.desc()).all()
for task in tasks:
task.markup = markdown(task.content)
task.href = url_for('taskaction.task_action', taskid=task.id)
task.time = task.timestamp.strftime("%Y-%m-%d %H:%M")
nl = (
('new', url_for('newtask.new_task', category_id=category_id)),
('categories', url_for('cats.index')),
('open', url_for('tsks.tasks', category_id=category_id))
)
return render_template(
'tasks.html',
title="Completed Tasks",
navbar_links=nl,
tasks=tasks,
heading="{}:completed tasks".format(category.name)
)
@tsks.route("/tasks/<int:category_id>")
def tasks(category_id):
category = Category.query.get(category_id)
if current_user.is_anonymous or current_user.id != category.contributor_id:
return(redirect(url_for('cats.index')))
tasks = Task.query.filter_by(
catid=category_id,
contributor_id=current_user.id,
done=False
).order_by(Task.priority.desc()).all()
for task in tasks:
task.markup = markdown(task.content)
task.href = url_for('taskaction.task_action', taskid=task.id)
task.time = task.timestamp.strftime("%Y-%m-%d %H:%M")
nl = (
('new', url_for('newtask.new_task', category_id=category_id)),
('categories', url_for('cats.index')),
('done', url_for('tsks.hidden_tasks', category_id=category_id))
)
return render_template(
'tasks.html',
title="Tasks",
navbar_links=nl,
tasks=tasks,
heading="{}:tasks".format(category.name)
)

View File

@@ -0,0 +1,37 @@
<style>
#content {
font-size: 1.2rem;
width: 100%;
}
form {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
form p {
width: 100%;
}
h1 {
align-self: center;
margin-top: 50px;
}
</style>
{% extends "base.html" %}
{% block content %}
<div class="formContainer">
<h1>Edit {{ task.content[0:10] }}</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.content.label }}<br>
{{ form.content(rows=20) }}<br>
{% for error in form.content.errors %}
<span class="formWarning">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,37 @@
<style>
#content {
font-size: 1.2rem;
width: 100%;
}
form {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
form p {
width: 100%;
}
h1 {
align-self: center;
margin-top: 50px;
}
</style>
{% extends "base.html" %}
{% block content %}
<div class="formContainer">
<h1>New Task For {{ category.name }}</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.content.label }}<br>
{{ form.content(rows=20) }}<br>
{% for error in form.content.errors %}
<span class="formWarning">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,70 @@
<style>
#main {
align-items: center;
}
.heading {
margin-top: 40px;
}
.buttonLink {
width: 95%;
max-width: 700px;
text-decoration: none;
}
.buttonLink button {
width: 100%;
background-color: black;
padding-top: 10px;
padding-bottom: 10px;
padding-left: 20px;
margin-bottom: 10px;
border-radius: 9px;
font-size: 1.3rem;
display: flex;
font-weight: bold;
}
</style>
{% extends "base.html" %}
{% if task.done == True %}
{% set textColor = "color: grey;" %}
{% else %}
{% set textColor = "color: white;" %}
{% endif %}
{% block content %}
<h1 class="heading">Actions For {{ task.content[0:10] }}...</h1>
<a href="{{ url_for('toggletaskdone.toggle_task_done', taskid=task.id) }}" class="buttonLink">
<button style="{{ textColor }}">
{% if task.done == True %} UnMark Done {% else %} Mark Done {% endif %}
</button>
</a>
<a href="{{ url_for('edittask.edit_task', taskid=task.id) }}" class="buttonLink">
<button style="{{ textColor }}">Edit</button>
</a>
<a href="{{ url_for('deletetask.delete_task', taskid=task.id) }}" class="buttonLink">
<button style="{{ textColor }}">Delete</button>
</a>
<a href="{{ url_for('cats.move_categories', taskid=task.id) }}" class="buttonLink">
<button style="{{ textColor }}">Move-Category</button>
</a>
{% if task.can_move_top == True %}
<a href="{{ url_for('reorderp.move_task', taskid=task.id, move='top') }}" class="buttonLink">
<button style="{{ textColor }}">Move-Top</button>
</a>
{% endif %}
{% if task.can_move_up == True %}
<a href="{{ url_for('reorderp.move_task', taskid=task.id, move='up') }}" class="buttonLink">
<button style="{{ textColor }}">Move-Up</button>
</a>
{% endif %}
{% if task.can_move_down == True %}
<a href="{{ url_for('reorderp.move_task', taskid=task.id, move='down') }}" class="buttonLink">
<button style="{{ textColor }}">Move-Down</button>
</a>
{% endif %}
{% if task.can_move_end == True %}
<a href="{{ url_for('reorderp.move_task', taskid=task.id, move='end') }}" class="buttonLink">
<button style="{{ textColor }}">Move-End</button>
</a>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,58 @@
<style>
#main {
align-items: center;
}
.heading {
margin-top: 40px;
font-size: 2.3rem;
}
.taskContainer {
width: 99%;
max-width: 600px;
margin-bottom: 10px;
border-radius: 9px;
font-size: 1.3rem;
font-weight: bold;
border: 1px solid;
display: flex;
justify-content: space-between;
}
.task {
padding-left: 20px;
padding-right: 20px;
max-width: calc(100% - 70px);
word-wrap: break-word;
}
.taskAction {
background-color: black;
height: 100%;
border-top-right-radius: 9px;
border-bottom-right-radius: 9px;
width: 30px;
}
.taskActionLink {
text-decoration: none;
}
</style>
{% extends "base.html" %}
{% block content %}
<h1 class="heading">{{ heading }}</h1>
{% for task in tasks %}
{% if task.done == True %}
{% set textColor = "color: grey;" %}
{% else %}
{% set textColor = "color: black;" %}
{% endif %}
<div class="taskContainer">
<div class="task" style="{{ textColor }}">
<p>{{ task.time }}</p> {{ task.markup|safe }}
</div>
<a href="{{ task.href }}" class="taskActionLink">
<button class="taskAction" style="color: white;">{{ task.priority }}</button>
</a>
</div>
{% endfor %}
{% endblock %}

38
app/templates/base.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="author" content="TrentSPalmer">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href='{{url_for('static', filename='css/todo.css')}}'>
{% if title %}
<title>{{ title }}</title>
{% else %}
<title>Todo</title>
{% endif %}
</head>
<body>
<div id="navbarContainer">
<div id="navbar">
{% for navbar_link in navbar_links %}
<a href="{{ navbar_link[1] }}"><button>{{ navbar_link[0] }}</button></a>
{% endfor %}
</div>
</div>
<div id="main">
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="flashContainer">{{message}}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if current_user.is_authenticated %}
<p id="userGreeting">Logged in as: {{ current_user.name }}</p>
{% else %}
<p id="userGreeting"> viewing as guest</p>
{% endif %}
{% block content %}{% endblock %}
</div>
</body>
</html>

View File

@@ -0,0 +1,11 @@
<p>Dear {{ contributor.name }},</p>
<p>To reset your password
<a href="{{ external_url + '/reset-password/' + token }}">click here</a>.
</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ external_url + '/reset-password/' + token }}</p>
<p>If you have not requested a password reset simply ignore this message.</p>
<p>Sincerely,</p>
<p>todo app</p>

View File

@@ -0,0 +1,13 @@
Dear {{ contributor.name }},
To reset your password click on the following link:
{{ external_url + '/reset-password/' + token }}
If you have not requested a password reset simply ignore this message.
Sincerely,
todo app