├── .env ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── errors.py │ └── views.py ├── auth │ ├── __init__.py │ ├── forms.py │ └── views.py ├── decorators.py ├── main │ ├── __init__.py │ ├── forms.py │ └── views.py ├── models.py ├── static │ ├── css │ │ ├── custom.css │ │ └── table.css │ ├── images │ │ ├── asc.gif │ │ ├── bg.gif │ │ ├── desc.gif │ │ └── favicon.png │ └── js │ │ ├── jquery.tablesorter.min.js │ │ ├── site.js │ │ └── todolist.js ├── templates │ ├── 403.html │ ├── 404.html │ ├── 500.html │ ├── base.html │ ├── error.html │ ├── index.html │ ├── login.html │ ├── overview.html │ ├── register.html │ └── todolist.html └── utils │ ├── __init__.py │ ├── errors.py │ └── filters.py ├── config.py ├── docker-compose.yml ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── eff90419b076_.py ├── requirements.txt ├── test-requirements.txt ├── tests ├── __init__.py ├── test_api.py ├── test_basics.py └── test_client.py ├── todolist.py └── utils ├── __init__.py └── fake_generator.py /.env: -------------------------------------------------------------------------------- 1 | FLASK_APP=todolist.py 2 | SECRET_KEY='this should not be checked into git' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python related files 2 | *.pyc 3 | *.pyo 4 | *.egg 5 | *.egg-info 6 | __pycache__ 7 | 8 | # sqlite db 9 | *.db 10 | 11 | # test, build, etc 12 | build 13 | docs/_build 14 | dist 15 | results 16 | venv 17 | 18 | # editor 19 | .ropeproject 20 | .vscode 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.3.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - repo: https://github.com/asottile/pyupgrade 9 | rev: v2.7.3 10 | hooks: 11 | - id: pyupgrade 12 | args: [--py38-plus] 13 | - repo: https://github.com/python/black 14 | rev: 20.8b1 15 | hooks: 16 | - id: black 17 | exclude: migrations 18 | - repo: https://github.com/pycqa/isort 19 | rev: 5.6.4 20 | hooks: 21 | - id: isort 22 | args: [--profile=black, --virtual-env=venv] 23 | exclude: migrations 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | language: python 3 | python: 4 | - "3.8" 5 | env: 6 | - FLASK_APP=todolist.py 7 | install: 8 | - "pip install -r requirements.txt" 9 | - "pip install -r test-requirements.txt" 10 | script: "flask test" 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | 3 | RUN apk add build-base 4 | 5 | ADD . /code 6 | WORKDIR /code 7 | 8 | RUN pip install gunicorn 9 | RUN pip install -r requirements.txt 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Christian Rotzoll 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask-Todolist 2 | 3 | [![License][license-image]][license-url] 4 | [![Build Status][travis-image]][travis-url] 5 | 6 | Flask-Todolist is a simple To Do List web application with the most basic 7 | features of most web apps, i.e. accounts/login, API and (somewhat) interactive 8 | UI. 9 | 10 | --- 11 | CSS | [Skeleton](http://getskeleton.com/) 12 | JS | [jQuery](https://jquery.com/) 13 | 14 | I've also build a quite similar app in Django: 15 | https://github.com/rtzll/django-todolist 16 | 17 | 18 | ## Explore 19 | Try it out! 20 | ### Docker 21 | Using `docker-compose` you can simple run: 22 | 23 | docker-compose build 24 | docker-compose up 25 | 26 | And the application will run on http://localhost:8000/ 27 | 28 | (It's serving the app using [gunicorn](http://gunicorn.org/) which you would 29 | use for deployment, instead of just running `flask run`.) 30 | 31 | ### Manually 32 | If you prefer to run it directly on your local machine, I suggest using 33 | [venv](https://docs.python.org/3/library/venv.html). 34 | 35 | pip install -r requirements.txt 36 | FLASK_APP=todolist.py flask run 37 | 38 | To add some 'play' data you can run 39 | 40 | pip install -r test-requirements.txt 41 | flask fill-db 42 | 43 | Now you can browse the API: 44 | http://localhost:5000/api/users 45 | 46 | Pick a user, login as the user. Default password after `fill-db` is 47 | *correcthorsebatterystaple*. 48 | Click around, there is not too much, but I like the overview under: 49 | http://localhost:5000/todolists 50 | (You must be logged in to see it.) 51 | 52 | 53 | ## Extensions 54 | In the process of this project I used a couple of extensions. 55 | 56 | Usage | Flask-Extension 57 | ------------------- | ----------------------- 58 | Model & ORM | [Flask-SQLAlchemy](http://flask-sqlalchemy.pocoo.org/latest/) 59 | Migration | [Flaks-Migrate](http://flask-migrate.readthedocs.io/en/latest/) 60 | Forms | [Flask-WTF](https://flask-wtf.readthedocs.org/en/latest/) 61 | Login | [Flask-Login](https://flask-login.readthedocs.org/en/latest/) 62 | Testing | [Flask-Testing](https://pythonhosted.org/Flask-Testing/) 63 | 64 | I tried out some more, but for the scope of this endeavor the above mentioned extensions sufficed. 65 | 66 | [license-url]: https://github.com/rtzll/flask-todolist/blob/master/LICENSE 67 | [license-image]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat 68 | 69 | [travis-url]: https://travis-ci.org/rtzll/flask-todolist 70 | [travis-image]: https://travis-ci.org/rtzll/flask-todolist.svg?branch=master 71 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_login import LoginManager 3 | from flask_migrate import Migrate 4 | from flask_sqlalchemy import SQLAlchemy 5 | 6 | from config import config 7 | 8 | db = SQLAlchemy() 9 | migrate = Migrate() 10 | 11 | login_manager = LoginManager() 12 | login_manager.session_protection = "strong" 13 | login_manager.login_view = "auth.login" 14 | 15 | 16 | def create_app(config_name): 17 | app = Flask(__name__) 18 | app.config.from_object(config[config_name]) 19 | config[config_name].init_app(app) 20 | 21 | db.init_app(app) 22 | migrate.init_app(app, db=db) 23 | login_manager.init_app(app) 24 | 25 | from .main import main as main_blueprint 26 | 27 | app.register_blueprint(main_blueprint) 28 | 29 | from .auth import auth as auth_blueprint 30 | 31 | app.register_blueprint(auth_blueprint, url_prefix="/auth") 32 | 33 | from .api import api as api_blueprint 34 | 35 | app.register_blueprint(api_blueprint, url_prefix="/api") 36 | 37 | from .utils import utils as utils_blueprint 38 | 39 | app.register_blueprint(utils_blueprint) 40 | 41 | return app 42 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | api = Blueprint("api", __name__) 4 | 5 | from . import errors, views 6 | -------------------------------------------------------------------------------- /app/api/errors.py: -------------------------------------------------------------------------------- 1 | from flask import make_response 2 | 3 | from app.api import api 4 | 5 | 6 | @api.errorhandler(400) 7 | def bad_request(error): 8 | return make_response({"error": "Bad Request"}), 400 9 | 10 | 11 | @api.errorhandler(401) 12 | def unauthorized(error): 13 | return make_response({"error": "Unauthorized"}), 401 14 | 15 | 16 | @api.errorhandler(403) 17 | def forbidden(error): 18 | return make_response({"error": "Forbidden"}), 403 19 | 20 | 21 | @api.errorhandler(404) 22 | def not_found(error): 23 | return make_response({"error": "Not found"}), 404 24 | 25 | 26 | def internal_server_error(error): 27 | return make_response({"error": "Internal Server Error"}), 500 28 | -------------------------------------------------------------------------------- /app/api/views.py: -------------------------------------------------------------------------------- 1 | from flask import abort, request, url_for 2 | 3 | from app.api import api 4 | from app.decorators import admin_required 5 | from app.models import Todo, TodoList, User 6 | 7 | 8 | @api.route("/") 9 | def get_routes(): 10 | return { 11 | "users": url_for("api.get_users", _external=True), 12 | "todolists": url_for("api.get_todolists", _external=True), 13 | } 14 | 15 | 16 | @api.route("/users/") 17 | def get_users(): 18 | return {"users": [user.to_dict() for user in User.query.all()]} 19 | 20 | 21 | @api.route("/user//") 22 | def get_user(username): 23 | user = User.query.filter_by(username=username).first_or_404() 24 | return user.to_dict() 25 | 26 | 27 | @api.route("/user/", methods=["POST"]) 28 | def add_user(): 29 | try: 30 | user = User( 31 | username=request.json.get("username"), 32 | email=request.json.get("email"), 33 | password=request.json.get("password"), 34 | ).save() 35 | except: 36 | abort(400) 37 | return user.to_dict(), 201 38 | 39 | 40 | @api.route("/user//todolists/") 41 | def get_user_todolists(username): 42 | user = User.query.filter_by(username=username).first_or_404() 43 | todolists = user.todolists 44 | return {"todolists": [todolist.to_dict() for todolist in todolists]} 45 | 46 | 47 | @api.route("/user//todolist//") 48 | def get_user_todolist(username, todolist_id): 49 | user = User.query.filter_by(username=username).first() 50 | todolist = TodoList.query.get_or_404(todolist_id) 51 | if not user or username != todolist.creator: 52 | abort(404) 53 | return todolist.to_dict() 54 | 55 | 56 | @api.route("/user//todolist/", methods=["POST"]) 57 | def add_user_todolist(username): 58 | user = User.query.filter_by(username=username).first_or_404() 59 | try: 60 | todolist = TodoList( 61 | title=request.json.get("title"), creator=user.username 62 | ).save() 63 | except: 64 | abort(400) 65 | return todolist.to_dict(), 201 66 | 67 | 68 | @api.route("/todolists/") 69 | def get_todolists(): 70 | todolists = TodoList.query.all() 71 | return {"todolists": [todolist.to_dict() for todolist in todolists]} 72 | 73 | 74 | @api.route("/todolist//") 75 | def get_todolist(todolist_id): 76 | todolist = TodoList.query.get_or_404(todolist_id) 77 | return todolist.to_dict() 78 | 79 | 80 | @api.route("/todolist/", methods=["POST"]) 81 | def add_todolist(): 82 | try: 83 | todolist = TodoList(title=request.json.get("title")).save() 84 | except: 85 | abort(400) 86 | return todolist.to_dict(), 201 87 | 88 | 89 | @api.route("/todolist//todos/") 90 | def get_todolist_todos(todolist_id): 91 | todolist = TodoList.query.get_or_404(todolist_id) 92 | return {"todos": [todo.to_dict() for todo in todolist.todos]} 93 | 94 | 95 | @api.route("/user//todolist//todos/") 96 | def get_user_todolist_todos(username, todolist_id): 97 | todolist = TodoList.query.get_or_404(todolist_id) 98 | if todolist.creator != username: 99 | abort(404) 100 | return {"todos": [todo.to_dict() for todo in todolist.todos]} 101 | 102 | 103 | @api.route("/user//todolist//", methods=["POST"]) 104 | def add_user_todolist_todo(username, todolist_id): 105 | user = User.query.filter_by(username=username).first_or_404() 106 | todolist = TodoList.query.get_or_404(todolist_id) 107 | try: 108 | todo = Todo( 109 | description=request.json.get("description"), 110 | todolist_id=todolist.id, 111 | creator=user.username, 112 | ).save() 113 | except: 114 | abort(400) 115 | return todo.to_dict(), 201 116 | 117 | 118 | @api.route("/todolist//", methods=["POST"]) 119 | def add_todolist_todo(todolist_id): 120 | todolist = TodoList.query.get_or_404(todolist_id) 121 | try: 122 | todo = Todo( 123 | description=request.json.get("description"), todolist_id=todolist.id 124 | ).save() 125 | except: 126 | abort(400) 127 | return todo.to_dict(), 201 128 | 129 | 130 | @api.route("/todo//") 131 | def get_todo(todo_id): 132 | todo = Todo.query.get_or_404(todo_id) 133 | return todo.to_dict() 134 | 135 | 136 | @api.route("/todo//", methods=["PUT"]) 137 | def update_todo_status(todo_id): 138 | todo = Todo.query.get_or_404(todo_id) 139 | try: 140 | if request.json.get("is_finished"): 141 | todo.finished() 142 | else: 143 | todo.reopen() 144 | except: 145 | abort(400) 146 | return todo.to_dict() 147 | 148 | 149 | @api.route("/todolist//", methods=["PUT"]) 150 | def change_todolist_title(todolist_id): 151 | todolist = TodoList.query.get_or_404(todolist_id) 152 | try: 153 | todolist.title = request.json.get("title") 154 | todolist.save() 155 | except: 156 | abort(400) 157 | return todolist.to_dict() 158 | 159 | 160 | @api.route("/user//", methods=["DELETE"]) 161 | @admin_required 162 | def delete_user(username): 163 | user = User.query.get_or_404(username=username) 164 | try: 165 | if username == request.json.get("username"): 166 | user.delete() 167 | return {} 168 | else: 169 | abort(400) 170 | except: 171 | abort(400) 172 | 173 | 174 | @api.route("/todolist//", methods=["DELETE"]) 175 | @admin_required 176 | def delete_todolist(todolist_id): 177 | todolist = TodoList.query.get_or_404(todolist_id) 178 | try: 179 | if todolist_id == request.json.get("todolist_id"): 180 | todolist.delete() 181 | return jsonify() 182 | else: 183 | abort(400) 184 | except: 185 | abort(400) 186 | 187 | 188 | @api.route("/todo//", methods=["DELETE"]) 189 | @admin_required 190 | def delete_todo(todo_id): 191 | todo = Todo.query.get_or_404(todo_id) 192 | try: 193 | if todo_id == request.json.get("todo_id"): 194 | todo.delete() 195 | return jsonify() 196 | else: 197 | abort(400) 198 | except: 199 | abort(400) 200 | -------------------------------------------------------------------------------- /app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | auth = Blueprint("auth", __name__) 4 | 5 | from . import views 6 | -------------------------------------------------------------------------------- /app/auth/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import PasswordField, StringField, SubmitField, ValidationError 3 | from wtforms.validators import Email, EqualTo, Length, Regexp, InputRequired 4 | 5 | from app.models import User 6 | 7 | 8 | class LoginForm(FlaskForm): 9 | email_or_username = StringField( 10 | "Email or Username", validators=[InputRequired(), Length(1, 64)] 11 | ) 12 | password = PasswordField("Password", validators=[InputRequired()]) 13 | submit = SubmitField("Log In") 14 | 15 | 16 | class RegistrationForm(FlaskForm): 17 | email = StringField("Email", validators=[InputRequired(), Length(1, 64), Email()]) 18 | username = StringField( 19 | "Username", 20 | validators=[ 21 | InputRequired(), 22 | Length(1, 64), 23 | Regexp( 24 | "^[A-Za-z][A-Za-z0-9_.]*$", 25 | 0, 26 | "Usernames must have only letters, " "numbers, dots or underscores", 27 | ), 28 | ], 29 | ) 30 | password = PasswordField( 31 | "Password", 32 | validators=[ 33 | InputRequired(), 34 | EqualTo("password_confirmation", message="Passwords must match."), 35 | ], 36 | ) 37 | password_confirmation = PasswordField("Confirm password", validators=[InputRequired()]) 38 | submit = SubmitField("Register") 39 | 40 | def validate_email(self, field): 41 | if User.query.filter_by(email=field.data).first(): 42 | raise ValidationError("Email already registered.") 43 | 44 | def validate_username(self, field): 45 | if User.query.filter_by(username=field.data).first(): 46 | raise ValidationError("Username already in use.") 47 | -------------------------------------------------------------------------------- /app/auth/views.py: -------------------------------------------------------------------------------- 1 | from flask import redirect, render_template, request, url_for 2 | from flask_login import login_user, logout_user 3 | 4 | from app.auth import auth 5 | from app.auth.forms import LoginForm, RegistrationForm 6 | from app.models import User 7 | 8 | 9 | @auth.route("/login", methods=["GET", "POST"]) 10 | def login(): 11 | form = LoginForm() 12 | if form.validate_on_submit(): 13 | user_by_email = User.query.filter_by(email=form.email_or_username.data).first() 14 | user_by_name = User.query.filter_by( 15 | username=form.email_or_username.data 16 | ).first() 17 | if user_by_email is not None and user_by_email.verify_password( 18 | form.password.data 19 | ): 20 | login_user(user_by_email.seen()) 21 | return redirect(request.args.get("next") or url_for("main.index")) 22 | if user_by_name is not None and user_by_name.verify_password( 23 | form.password.data 24 | ): 25 | login_user(user_by_name.seen()) 26 | return redirect(request.args.get("next") or url_for("main.index")) 27 | return render_template("login.html", form=form) 28 | 29 | 30 | @auth.route("/logout") 31 | def logout(): 32 | logout_user() 33 | return redirect(url_for("main.index")) 34 | 35 | 36 | @auth.route("/register", methods=["GET", "POST"]) 37 | def register(): 38 | form = RegistrationForm() 39 | if form.validate_on_submit(): 40 | User( 41 | email=form.email.data, 42 | username=form.username.data, 43 | password=form.password.data, 44 | ).save() 45 | return redirect(url_for("auth.login")) 46 | return render_template("register.html", form=form) 47 | -------------------------------------------------------------------------------- /app/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from flask import abort 4 | 5 | 6 | def admin_required(f): 7 | @wraps(f) 8 | def decorated_function(*args, **kwargs): 9 | from flask_login import current_user 10 | 11 | if not current_user.is_authenticated or not current_user.is_admin: 12 | abort(403) 13 | return f(*args, **kwargs) 14 | 15 | return decorated_function 16 | -------------------------------------------------------------------------------- /app/main/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | main = Blueprint("main", __name__) 4 | 5 | from . import views 6 | -------------------------------------------------------------------------------- /app/main/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, SubmitField 3 | from wtforms.validators import Length, InputRequired 4 | 5 | 6 | class TodoForm(FlaskForm): 7 | todo = StringField("Enter your todo", validators=[InputRequired(), Length(1, 128)]) 8 | submit = SubmitField("Submit") 9 | 10 | 11 | class TodoListForm(FlaskForm): 12 | title = StringField( 13 | "Enter your todolist title", validators=[InputRequired(), Length(1, 128)] 14 | ) 15 | submit = SubmitField("Submit") 16 | -------------------------------------------------------------------------------- /app/main/views.py: -------------------------------------------------------------------------------- 1 | from flask import redirect, render_template, request, url_for 2 | from flask_login import current_user, login_required 3 | 4 | from app.main import main 5 | from app.main.forms import TodoForm, TodoListForm 6 | from app.models import Todo, TodoList 7 | 8 | 9 | @main.route("/") 10 | def index(): 11 | form = TodoForm() 12 | if form.validate_on_submit(): 13 | return redirect(url_for("main.new_todolist")) 14 | return render_template("index.html", form=form) 15 | 16 | 17 | @main.route("/todolists/", methods=["GET", "POST"]) 18 | @login_required 19 | def todolist_overview(): 20 | form = TodoListForm() 21 | if form.validate_on_submit(): 22 | return redirect(url_for("main.add_todolist")) 23 | return render_template("overview.html", form=form) 24 | 25 | 26 | def _get_user(): 27 | return current_user.username if current_user.is_authenticated else None 28 | 29 | 30 | @main.route("/todolist//", methods=["GET", "POST"]) 31 | def todolist(id): 32 | todolist = TodoList.query.filter_by(id=id).first_or_404() 33 | form = TodoForm() 34 | if form.validate_on_submit(): 35 | Todo(form.todo.data, todolist.id, _get_user()).save() 36 | return redirect(url_for("main.todolist", id=id)) 37 | return render_template("todolist.html", todolist=todolist, form=form) 38 | 39 | 40 | @main.route("/todolist/new/", methods=["POST"]) 41 | def new_todolist(): 42 | form = TodoForm(todo=request.form.get("todo")) 43 | if form.validate(): 44 | todolist = TodoList(creator=_get_user()).save() 45 | Todo(form.todo.data, todolist.id).save() 46 | return redirect(url_for("main.todolist", id=todolist.id)) 47 | return redirect(url_for("main.index")) 48 | 49 | 50 | @main.route("/todolist/add/", methods=["POST"]) 51 | def add_todolist(): 52 | form = TodoListForm(todo=request.form.get("title")) 53 | if form.validate(): 54 | todolist = TodoList(form.title.data, _get_user()).save() 55 | return redirect(url_for("main.todolist", id=todolist.id)) 56 | return redirect(url_for("main.index")) 57 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | 4 | from flask import url_for 5 | from flask_login import UserMixin 6 | from sqlalchemy.orm import synonym 7 | from werkzeug.security import check_password_hash, generate_password_hash 8 | 9 | from app import db, login_manager 10 | 11 | EMAIL_REGEX = re.compile(r"^\S+@\S+\.\S+$") 12 | USERNAME_REGEX = re.compile(r"^\S+$") 13 | 14 | 15 | def check_length(attribute, length): 16 | """Checks the attribute's length.""" 17 | try: 18 | return bool(attribute) and len(attribute) <= length 19 | except: 20 | return False 21 | 22 | 23 | class BaseModel: 24 | """Base for all models, providing save, delete and from_dict methods.""" 25 | 26 | def __commit(self): 27 | """Commits the current db.session, does rollback on failure.""" 28 | from sqlalchemy.exc import IntegrityError 29 | 30 | try: 31 | db.session.commit() 32 | except IntegrityError: 33 | db.session.rollback() 34 | 35 | def delete(self): 36 | """Deletes this model from the db (through db.session)""" 37 | db.session.delete(self) 38 | self.__commit() 39 | 40 | def save(self): 41 | """Adds this model to the db (through db.session)""" 42 | db.session.add(self) 43 | self.__commit() 44 | return self 45 | 46 | @classmethod 47 | def from_dict(cls, model_dict): 48 | return cls(**model_dict).save() 49 | 50 | 51 | class User(UserMixin, db.Model, BaseModel): 52 | __tablename__ = "user" 53 | id = db.Column(db.Integer, primary_key=True) 54 | _username = db.Column("username", db.String(64), unique=True) 55 | _email = db.Column("email", db.String(64), unique=True) 56 | password_hash = db.Column(db.String(128)) 57 | member_since = db.Column(db.DateTime, default=datetime.utcnow) 58 | last_seen = db.Column(db.DateTime, default=datetime.utcnow) 59 | is_admin = db.Column(db.Boolean, default=False) 60 | 61 | todolists = db.relationship("TodoList", backref="user", lazy="dynamic") 62 | 63 | def __init__(self, **kwargs): 64 | super().__init__(**kwargs) 65 | 66 | def __repr__(self): 67 | if self.is_admin: 68 | return f"" 69 | return f"" 70 | 71 | @property 72 | def username(self): 73 | return self._username 74 | 75 | @username.setter 76 | def username(self, username): 77 | is_valid_length = check_length(username, 64) 78 | if not is_valid_length or not bool(USERNAME_REGEX.match(username)): 79 | raise ValueError(f"{username} is not a valid username") 80 | self._username = username 81 | 82 | username = synonym("_username", descriptor=username) 83 | 84 | @property 85 | def email(self): 86 | return self._email 87 | 88 | @email.setter 89 | def email(self, email): 90 | if not check_length(email, 64) or not bool(EMAIL_REGEX.match(email)): 91 | raise ValueError(f"{email} is not a valid email address") 92 | self._email = email 93 | 94 | email = synonym("_email", descriptor=email) 95 | 96 | @property 97 | def password(self): 98 | raise AttributeError("password is not a readable attribute") 99 | 100 | @password.setter 101 | def password(self, password): 102 | if not bool(password): 103 | raise ValueError("no password given") 104 | 105 | hashed_password = generate_password_hash(password) 106 | if not check_length(hashed_password, 128): 107 | raise ValueError("not a valid password, hash is too long") 108 | self.password_hash = hashed_password 109 | 110 | def verify_password(self, password): 111 | return check_password_hash(self.password_hash, password) 112 | 113 | def seen(self): 114 | self.last_seen = datetime.utcnow() 115 | return self.save() 116 | 117 | def to_dict(self): 118 | return { 119 | "username": self.username, 120 | "user_url": url_for("api.get_user", username=self.username, _external=True), 121 | "member_since": self.member_since, 122 | "last_seen": self.last_seen, 123 | "todolists": url_for( 124 | "api.get_user_todolists", username=self.username, _external=True 125 | ), 126 | "todolist_count": self.todolists.count(), 127 | } 128 | 129 | def promote_to_admin(self): 130 | self.is_admin = True 131 | return self.save() 132 | 133 | 134 | @login_manager.user_loader 135 | def load_user(user_id): 136 | return User.query.get(int(user_id)) 137 | 138 | 139 | class TodoList(db.Model, BaseModel): 140 | __tablename__ = "todolist" 141 | id = db.Column(db.Integer, primary_key=True) 142 | _title = db.Column("title", db.String(128)) 143 | created_at = db.Column(db.DateTime, default=datetime.utcnow) 144 | creator = db.Column(db.String(64), db.ForeignKey("user.username")) 145 | todos = db.relationship("Todo", backref="todolist", lazy="dynamic") 146 | 147 | def __init__(self, title=None, creator=None, created_at=None): 148 | self.title = title or "untitled" 149 | self.creator = creator 150 | self.created_at = created_at or datetime.utcnow() 151 | 152 | def __repr__(self): 153 | return f"" 154 | 155 | @property 156 | def title(self): 157 | return self._title 158 | 159 | @title.setter 160 | def title(self, title): 161 | if not check_length(title, 128): 162 | raise ValueError(f"{title} is not a valid title") 163 | self._title = title 164 | 165 | title = synonym("_title", descriptor=title) 166 | 167 | @property 168 | def todos_url(self): 169 | url = None 170 | kwargs = dict(todolist_id=self.id, _external=True) 171 | if self.creator: 172 | kwargs["username"] = self.creator 173 | url = "api.get_user_todolist_todos" 174 | return url_for(url or "api.get_todolist_todos", **kwargs) 175 | 176 | def to_dict(self): 177 | return { 178 | "title": self.title, 179 | "creator": self.creator, 180 | "created_at": self.created_at, 181 | "total_todo_count": self.todo_count, 182 | "open_todo_count": self.open_count, 183 | "finished_todo_count": self.finished_count, 184 | "todos": self.todos_url, 185 | } 186 | 187 | @property 188 | def todo_count(self): 189 | return self.todos.order_by(None).count() 190 | 191 | @property 192 | def finished_count(self): 193 | return self.todos.filter_by(is_finished=True).count() 194 | 195 | @property 196 | def open_count(self): 197 | return self.todos.filter_by(is_finished=False).count() 198 | 199 | 200 | class Todo(db.Model, BaseModel): 201 | __tablename__ = "todo" 202 | id = db.Column(db.Integer, primary_key=True) 203 | description = db.Column(db.String(128)) 204 | created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) 205 | finished_at = db.Column(db.DateTime, index=True, default=None) 206 | is_finished = db.Column(db.Boolean, default=False) 207 | creator = db.Column(db.String(64), db.ForeignKey("user.username")) 208 | todolist_id = db.Column(db.Integer, db.ForeignKey("todolist.id")) 209 | 210 | def __init__(self, description, todolist_id, creator=None, created_at=None): 211 | self.description = description 212 | self.todolist_id = todolist_id 213 | self.creator = creator 214 | self.created_at = created_at or datetime.utcnow() 215 | 216 | def __repr__(self): 217 | return "<{} Todo: {} by {}>".format( 218 | self.status, self.description, self.creator or "None" 219 | ) 220 | 221 | @property 222 | def status(self): 223 | return "finished" if self.is_finished else "open" 224 | 225 | def finished(self): 226 | self.is_finished = True 227 | self.finished_at = datetime.utcnow() 228 | self.save() 229 | 230 | def reopen(self): 231 | self.is_finished = False 232 | self.finished_at = None 233 | self.save() 234 | 235 | def to_dict(self): 236 | return { 237 | "description": self.description, 238 | "creator": self.creator, 239 | "created_at": self.created_at, 240 | "status": self.status, 241 | } 242 | -------------------------------------------------------------------------------- /app/static/css/custom.css: -------------------------------------------------------------------------------- 1 | .open-todos ul, 2 | .finished-todos ul { 3 | list-style: none; 4 | text-align: left; 5 | word-wrap: break-word; 6 | } 7 | 8 | h2.title { 9 | word-wrap: break-word; 10 | } 11 | 12 | th.todolist-title, 13 | tr td.todolist-title { 14 | width: 50%; 15 | word-break: break-word; 16 | } 17 | 18 | .finished-todos li { 19 | color: #aaa; 20 | padding-left: 5pt; 21 | text-decoration: line-through; 22 | } 23 | 24 | .container { 25 | max-width: 800px; 26 | } 27 | 28 | .header { 29 | margin-top: 6rem; 30 | text-align: center; 31 | } 32 | 33 | .value-prop { 34 | margin-top: 1rem; 35 | } 36 | 37 | .value-props { 38 | margin-top: 4rem; 39 | margin-bottom: 4rem; 40 | } 41 | 42 | .docs-header { 43 | text-transform: uppercase; 44 | font-size: 1.4rem; 45 | letter-spacing: .2rem; 46 | font-weight: 600; 47 | } 48 | 49 | .docs-section { 50 | border-top: 1px solid #eee; 51 | padding: 4rem 0; 52 | margin-bottom: 0; 53 | } 54 | 55 | .value-img { 56 | display: block; 57 | text-align: center; 58 | margin: 2.5rem auto 0; 59 | } 60 | 61 | .heading-font-size { 62 | font-size: 1.2rem; 63 | color: #999; 64 | letter-spacing: normal; 65 | } 66 | 67 | .navbar { 68 | display: none; 69 | } 70 | 71 | /* Larger than phone */ 72 | @media (min-width: 550px) { 73 | .header { 74 | margin-top: 18rem; 75 | } 76 | .value-props { 77 | margin-top: 9rem; 78 | margin-bottom: 7rem; 79 | } 80 | .value-img { 81 | margin-bottom: 1rem; 82 | } 83 | .example-grid .column, 84 | .example-grid .columns { 85 | margin-bottom: 1.5rem; 86 | } 87 | .docs-section { 88 | padding: 6rem 0; 89 | } 90 | } 91 | 92 | /* Larger than tablet */ 93 | @media (min-width: 750px) { 94 | .navbar + .docs-section { 95 | border-top-width: 0; 96 | } 97 | .navbar, 98 | .navbar-spacer { 99 | display: block; 100 | width: 100%; 101 | height: 6.5rem; 102 | background: #fff; 103 | z-index: 99; 104 | border-top: 1px solid #eee; 105 | border-bottom: 1px solid #eee; 106 | } 107 | .navbar-spacer { 108 | display: none; 109 | } 110 | .navbar > .container { 111 | width: 100%; 112 | } 113 | .navbar-list { 114 | list-style: none; 115 | margin-bottom: 0; 116 | } 117 | .navbar-item { 118 | position: relative; 119 | float: left; 120 | margin-bottom: 0; 121 | } 122 | .navbar-item:last-child { 123 | float: right; 124 | } 125 | .navbar-link { 126 | text-transform: uppercase; 127 | font-size: 11px; 128 | font-weight: 600; 129 | letter-spacing: .2rem; 130 | margin-right: 35px; 131 | text-decoration: none; 132 | line-height: 6.5rem; 133 | color: #222; 134 | } 135 | .navbar-link.active { 136 | color: #33C3F0; 137 | } 138 | .has-docked-nav .navbar { 139 | position: fixed; 140 | top: 0; 141 | left: 0; 142 | } 143 | .has-docked-nav .navbar-spacer { 144 | display: block; 145 | } 146 | 147 | /* Re-overiding the width 100% declaration to match size of % based container */ 148 | .has-docked-nav .navbar > .container { 149 | width: 80%; 150 | } 151 | 152 | /* Popover */ 153 | .popover.open { 154 | display: block; 155 | } 156 | .popover { 157 | display: none; 158 | position: absolute; 159 | top: 0; 160 | left: 0; 161 | background: #fff; 162 | border: 1px solid #eee; 163 | border-radius: 4px; 164 | top: 92%; 165 | left: -50%; 166 | -webkit-filter: drop-shadow(0 0 6px rgba(0, 0, 0, .1)); 167 | -moz-filter: drop-shadow(0 0 6px rgba(0, 0, 0, .1)); 168 | filter: drop-shadow(0 0 6px rgba(0, 0, 0, .1)); 169 | } 170 | .popover-item:first-child .popover-link:after, 171 | .popover-item:first-child .popover-link:before { 172 | bottom: 100%; 173 | left: 50%; 174 | border: solid transparent; 175 | content: " "; 176 | height: 0; 177 | width: 0; 178 | position: absolute; 179 | pointer-events: none; 180 | } 181 | .popover-item:first-child .popover-link:after { 182 | border-color: rgba(255, 255, 255, 0); 183 | border-bottom-color: #fff; 184 | border-width: 10px; 185 | margin-left: -10px; 186 | } 187 | .popover-item:first-child .popover-link:before { 188 | border-color: rgba(238, 238, 238, 0); 189 | border-bottom-color: #eee; 190 | border-width: 11px; 191 | margin-left: -11px; 192 | } 193 | .popover-list { 194 | padding: 0; 195 | margin: 0; 196 | list-style: none; 197 | } 198 | .popover-item { 199 | padding: 0; 200 | margin: 0; 201 | } 202 | .popover-link { 203 | position: relative; 204 | color: #222; 205 | display: block; 206 | padding: 8px 20px; 207 | border-bottom: 1px solid #eee; 208 | text-decoration: none; 209 | text-transform: uppercase; 210 | font-size: 1.0rem; 211 | font-weight: 600; 212 | text-align: center; 213 | letter-spacing: .1rem; 214 | } 215 | .popover-item:first-child .popover-link { 216 | border-radius: 4px 4px 0 0; 217 | } 218 | .popover-item:last-child .popover-link { 219 | border-radius: 0 0 4px 4px; 220 | border-bottom-width: 0; 221 | } 222 | .popover-link:hover { 223 | color: #fff; 224 | background: #33C3F0; 225 | } 226 | .popover-link:hover, 227 | .popover-item:first-child .popover-link:hover:after { 228 | border-bottom-color: #33C3F0; 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /app/static/css/table.css: -------------------------------------------------------------------------------- 1 | /* tables */ 2 | 3 | table.tablesorter { 4 | background-color: #CDCDCD; 5 | margin: 10px 0pt 15px; 6 | width: 100%; 7 | text-align: left; 8 | } 9 | 10 | table.tablesorter thead tr th, 11 | table.tablesorter tbody tr td, 12 | table.tablesorter tfoot tr th { 13 | padding: 10px; 14 | text-align: center; 15 | } 16 | 17 | table.tablesorter th:first-child, 18 | table.tablesorter tbody tr td:first-child { 19 | text-align: left; 20 | } 21 | 22 | table.tablesorter thead tr th, 23 | table.tablesorter tfoot tr th { 24 | border: 1px solid #FFF; 25 | } 26 | 27 | table.tablesorter th:last-child { 28 | border-right: 0px solid #FFF; 29 | } 30 | 31 | table.tablesorter thead tr .header { 32 | background-image: url(../images/bg.gif); 33 | background-repeat: no-repeat; 34 | background-position: center right; 35 | cursor: pointer; 36 | } 37 | 38 | table.tablesorter tbody td { 39 | color: #3D3D3D; 40 | padding: 4px; 41 | background-color: #FFF; 42 | vertical-align: top; 43 | } 44 | 45 | table.tablesorter thead tr .headerSortUp { 46 | background-image: url(../images/asc.gif); 47 | } 48 | 49 | table.tablesorter thead tr .headerSortDown { 50 | background-image: url(../images/desc.gif); 51 | } 52 | 53 | table.tablesorter thead tr .headerSortDown, 54 | table.tablesorter thead tr .headerSortUp { 55 | background-color: #A4A4A4; 56 | } 57 | -------------------------------------------------------------------------------- /app/static/images/asc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wissen-snake/todo-list-flask/96769ad0d7e3db6c2defeab54d16de54e0966423/app/static/images/asc.gif -------------------------------------------------------------------------------- /app/static/images/bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wissen-snake/todo-list-flask/96769ad0d7e3db6c2defeab54d16de54e0966423/app/static/images/bg.gif -------------------------------------------------------------------------------- /app/static/images/desc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wissen-snake/todo-list-flask/96769ad0d7e3db6c2defeab54d16de54e0966423/app/static/images/desc.gif -------------------------------------------------------------------------------- /app/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wissen-snake/todo-list-flask/96769ad0d7e3db6c2defeab54d16de54e0966423/app/static/images/favicon.png -------------------------------------------------------------------------------- /app/static/js/jquery.tablesorter.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * TableSorter 2.0 - Client-side table sorting with ease! 4 | * Version 2.0.5b 5 | * @requires jQuery v1.2.3 6 | * 7 | * Copyright (c) 2007 Christian Bach 8 | * Examples and docs at: http://tablesorter.com 9 | * Dual licensed under the MIT and GPL licenses: 10 | * http://www.opensource.org/licenses/mit-license.php 11 | * http://www.gnu.org/licenses/gpl.html 12 | * 13 | */ 14 | 15 | (function($){$.extend({tablesorter:new 16 | function(){var parsers=[],widgets=[];this.defaults={cssHeader:"header",cssAsc:"headerSortUp",cssDesc:"headerSortDown",cssChildRow:"expand-child",sortInitialOrder:"asc",sortMultiSortKey:"shiftKey",sortForce:null,sortAppend:null,sortLocaleCompare:true,textExtraction:"simple",parsers:{},widgets:[],widgetZebra:{css:["even","odd"]},headers:{},widthFixed:false,cancelSelection:true,sortList:[],headerList:[],dateFormat:"us",decimal:'/\.|\,/g',onRenderHeader:null,selectorHeaders:'thead th',debug:false};function benchmark(s,d){log(s+","+(new Date().getTime()-d.getTime())+"ms");}this.benchmark=benchmark;function log(s){if(typeof console!="undefined"&&typeof console.debug!="undefined"){console.log(s);}else{alert(s);}}function buildParserCache(table,$headers){if(table.config.debug){var parsersDebug="";}if(table.tBodies.length==0)return;var rows=table.tBodies[0].rows;if(rows[0]){var list=[],cells=rows[0].cells,l=cells.length;for(var i=0;i1){arr=arr.concat(checkCellColSpan(table,headerArr,row++));}else{if(table.tHead.length==1||(cell.rowSpan>1||!r[row+1])){arr.push(cell);}}}return arr;};function checkHeaderMetadata(cell){if(($.metadata)&&($(cell).metadata().sorter===false)){return true;};return false;}function checkHeaderOptions(table,i){if((table.config.headers[i])&&(table.config.headers[i].sorter===false)){return true;};return false;}function checkHeaderOptionsSortingLocked(table,i){if((table.config.headers[i])&&(table.config.headers[i].lockedOrder))return table.config.headers[i].lockedOrder;return false;}function applyWidget(table){var c=table.config.widgets;var l=c.length;for(var i=0;i');$("tr:first td",table.tBodies[0]).each(function(){colgroup.append($('').css('width',$(this).width()));});$(table).prepend(colgroup);};}function updateHeaderSortCount(table,sortList){var c=table.config,l=sortList.length;for(var i=0;i b["+i+"]) ? 1 : 0));";};function makeSortTextDesc(i){return"((b["+i+"] < a["+i+"]) ? -1 : ((b["+i+"] > a["+i+"]) ? 1 : 0));";};function makeSortNumeric(i){return"a["+i+"]-b["+i+"];";};function makeSortNumericDesc(i){return"b["+i+"]-a["+i+"];";};function sortText(a,b){if(table.config.sortLocaleCompare)return a.localeCompare(b);return((ab)?1:0));};function sortTextDesc(a,b){if(table.config.sortLocaleCompare)return b.localeCompare(a);return((ba)?1:0));};function sortNumeric(a,b){return a-b;};function sortNumericDesc(a,b){return b-a;};function getCachedSortType(parsers,i){return parsers[i].type;};this.construct=function(settings){return this.each(function(){if(!this.tHead||!this.tBodies)return;var $this,$document,$headers,cache,config,shiftDown=0,sortOrder;this.config={};config=$.extend(this.config,$.tablesorter.defaults,settings);$this=$(this);$.data(this,"tablesorter",config);$headers=buildHeaders(this);this.config.parsers=buildParserCache(this,$headers);cache=buildCache(this);var sortCSS=[config.cssDesc,config.cssAsc];fixColumnWidth(this);$headers.click(function(e){var totalRows=($this[0].tBodies[0]&&$this[0].tBodies[0].rows.length)||0;if(!this.sortDisabled&&totalRows>0){$this.trigger("sortStart");var $cell=$(this);var i=this.column;this.order=this.count++%2;if(this.lockedOrder)this.order=this.lockedOrder;if(!e[config.sortMultiSortKey]){config.sortList=[];if(config.sortForce!=null){var a=config.sortForce;for(var j=0;j0){$this.trigger("sorton",[config.sortList]);}applyWidget(this);});};this.addParser=function(parser){var l=parsers.length,a=true;for(var i=0;i 0) { 47 | $('.popover').removeClass('open'); 48 | } 49 | } 50 | 51 | $('#button').click(function() { 52 | $('html, body').animate({ 53 | scrollTop: $('#elementtoScrollToID').offset().top 54 | }, 2000); 55 | }); 56 | 57 | function resize() { 58 | $body.removeClass('has-docked-nav'); 59 | navOffsetTop = $nav.offset().top; 60 | onScroll(); 61 | } 62 | 63 | function onScroll() { 64 | if (navOffsetTop < $window.scrollTop() && !$body.hasClass('has-docked-nav')) { 65 | $body.addClass('has-docked-nav') 66 | } 67 | if (navOffsetTop > $window.scrollTop() && $body.hasClass('has-docked-nav')) { 68 | $body.removeClass('has-docked-nav') 69 | } 70 | } 71 | 72 | init(); 73 | 74 | }); 75 | -------------------------------------------------------------------------------- /app/static/js/todolist.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $(':checkbox').on('click', changeTodoStatus); 3 | }); 4 | 5 | function changeTodoStatus() { 6 | if ($(this).is(':checked')) { 7 | putNewStatus($(this).data('todo-id'), true); 8 | } else { 9 | putNewStatus($(this).data('todo-id'), false); 10 | } 11 | } 12 | 13 | function csrfSafeMethod(method) { 14 | // these HTTP methods do not require CSRF protection 15 | return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); 16 | } 17 | 18 | // function from the django docs 19 | function getCookie(name) { 20 | var cookieValue = null; 21 | if (document.cookie && document.cookie != '') { 22 | var cookies = document.cookie.split(';'); 23 | for (var i = 0; i < cookies.length; i++) { 24 | var cookie = jQuery.trim(cookies[i]); 25 | // Does this cookie string begin with the name we want? 26 | if (cookie.substring(0, name.length + 1) == (name + '=')) { 27 | cookieValue = decodeURIComponent( 28 | cookie.substring(name.length + 1) 29 | ); 30 | break; 31 | } 32 | } 33 | } 34 | return cookieValue; 35 | } 36 | 37 | function putNewStatus(todoID, isFinished) { 38 | 39 | // setup ajax to csrf token 40 | var csrftoken = getCookie('csrftoken'); 41 | $.ajaxSetup({ 42 | beforeSend: function(xhr, settings) { 43 | if (!csrfSafeMethod(settings.type) && !this.crossDomain) { 44 | xhr.setRequestHeader("X-CSRFToken", csrftoken); 45 | } 46 | } 47 | }); 48 | // send put request using the todo of the get for the same id 49 | var todoURL = '/api/todo/' + todoID + '/' 50 | $.getJSON(todoURL, function(todo) { 51 | todo.is_finished = isFinished; 52 | $.ajax({ 53 | url: todoURL, 54 | type: 'PUT', 55 | contentType: 'application/json', 56 | data: JSON.stringify(todo), 57 | success: function() { 58 | location.reload(); 59 | } 60 | }); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /app/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends "error.html" %} 2 | 3 | {% block error_message %}Forbidden{% endblock %} 4 | {% block error_code %}403{% endblock %} 5 | -------------------------------------------------------------------------------- /app/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "error.html" %} 2 | 3 | {% block error_message %}Not Found{% endblock %} 4 | {% block error_code %}404{% endblock %} 5 | -------------------------------------------------------------------------------- /app/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "error.html" %} 2 | 3 | {% block error_message %}Internal Server Error{% endblock %} 4 | {% block error_code %}500{% endblock %} 5 | -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Flodolist 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% block css %}{% endblock %} 16 | 17 | 18 | 19 | {% block js %}{% endblock %} 20 | 21 | 22 | 23 | 24 |
25 | 26 | 52 | {% block body %}{% endblock %} 53 |
54 | 55 | 56 | -------------------------------------------------------------------------------- /app/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 |

{% block error_message %}{% endblock %}

6 |
7 |
8 |
{% block error_code %}{% endblock %}
9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 |

Dead simple Todolists.

6 |
7 |
8 |
9 | {% if form.errors %} 10 |
Todos should neither be empty nor be longer than 128 characters.
11 | {% endif %} 12 |
13 | {{ form.hidden_tag() }} 14 |
15 |
{{ form.todo(class_="u-full-width", placeholder="Enter your todo", maxlength=128) }} 16 |
{{ form.submit(class="button button-primary", value="Start one now") }} 17 |
18 |
19 |
20 |
21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /app/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 |

Login

6 |
7 |
8 |
9 | {% if form.errors %} 10 |
Unable to login. Typo?
11 | {% endif %} 12 |
13 | {{ form.hidden_tag() }} 14 |
15 |
{{ form.email_or_username(class_="u-full-width", placeholder="Email or Username", maxlength=128)}} 16 |
{{ form.password(class_="u-full-width", placeholder="Password") }} 17 |
{{ form.submit }} 18 |
19 |
20 |

New user? Click here to register.

21 |
22 |
23 |
24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /app/templates/overview.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block js %} 4 | 5 | 19 | {% endblock %} 20 | 21 | {% block css %} 22 | 23 | {% endblock %} 24 | 25 | {% block body %} 26 |
27 |

Todolist overview

28 |
29 |
30 |
31 | {% if form.errors %} 32 |
Todos should neither be empty nor be longer than 128 characters.
33 | {% endif %} 34 |
35 | {{ form.hidden_tag() }} 36 |
37 |
{{ form.title(class_="u-full-width", placeholder="Enter a title to start a new todolist", value="", maxlength=128) }} 38 |
{{ form.submit }} 39 |
40 |
41 |
42 |
43 |
44 |
45 |
All your todolists
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | {% for todolist in current_user.todolists %} 57 | 58 | 59 | 60 | 61 | 62 | 63 | {% endfor %} 64 | 65 |
Todolist title# Open# FinishedCreated at
{{ todolist.title }}{{ todolist.open_count }}{{ todolist.finished_count }}{{ todolist.created_at|humanize }}
66 |
67 |
68 |
69 | {% endblock %} 70 | -------------------------------------------------------------------------------- /app/templates/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 |

Register

6 |
7 |
8 |
9 | {% if form.errors %} 10 |
Your registration contains errors.
11 | {% endif %} 12 |
13 | {{ form.hidden_tag() }} 14 |
15 |
{{ form.username(class_="u-full-width", placeholder="Username", maxlength=64) }} 16 |
{{ form.email(class_="u-full-width", placeholder="Email", maxlength=64) }} 17 |
{{ form.password(class_="u-full-width", placeholder="Password") }} 18 |
{{ form.password_confirmation(class_="u-full-width", placeholder="Confirm your Password") }} 19 |
{{ form.submit }} 20 |
21 |
22 |

Already registerd? Click here to login.

23 |
24 |
25 |
26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /app/templates/todolist.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block js %} 4 | 5 | {% endblock %} 6 | 7 | {% block body %} 8 |
9 |

{{todolist.title|title}}

10 |
11 |
12 |
13 | {% if form.errors %} 14 |
Todos should neither be empty nor be longer than 128 characters.
15 | {% endif %} 16 |
17 | {{ form.hidden_tag() }} 18 |
19 |
{{ form.todo(class_="u-full-width", placeholder="Enter your todo", value="", maxlength=128) }} 20 |
{{ form.submit }} 21 |
22 |
23 |
24 |
25 |
26 |
{{ todolist.open_count }} open
27 |
    28 | {% for todo in todolist.todos %} 29 | {% if not todo.is_finished %} 30 |
  • {{ todo.description }}
  • 31 | {% endif %} 32 | {% endfor %} 33 |
34 |
35 |
36 |
{{ todolist.finished_count }} finished
37 |
    38 | {% for todo in todolist.todos %} 39 | {% if todo.is_finished %} 40 |
  • {{ todo.description }}
  • 41 | {% endif %} 42 | {% endfor %} 43 |
44 |
45 |
46 |
47 |
48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | utils = Blueprint("utils", __name__) 4 | 5 | from . import errors, filters 6 | -------------------------------------------------------------------------------- /app/utils/errors.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, request 2 | 3 | from .. import api 4 | from . import utils 5 | 6 | 7 | @utils.app_errorhandler(403) 8 | def forbidden(error): 9 | if request.path.startswith("/api"): 10 | return api.errors.forbidden(error) 11 | return render_template("403.html"), 403 12 | 13 | 14 | @utils.app_errorhandler(404) 15 | def page_not_found(error): 16 | if request.path.startswith("/api"): 17 | return api.errors.not_found(error) 18 | return render_template("404.html"), 404 19 | 20 | 21 | @utils.app_errorhandler(500) 22 | def internal_server_error(error): 23 | if request.path.startswith("/api"): 24 | return api.errors.internal_server_error(error) 25 | return render_template("500.html"), 500 26 | -------------------------------------------------------------------------------- /app/utils/filters.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | 4 | from . import utils 5 | 6 | 7 | # Thanks to Dan Jacob and Sean Vieira for making the following snippet 8 | # available at http://flask.pocoo.org/snippets/33/ 9 | @utils.app_template_filter("humanize") 10 | def humanize_time(dt, past_="ago", future_="from now", default="just now"): 11 | """ 12 | Returns string representing 'time since' 13 | or 'time until' e.g. 14 | 3 days ago, 5 hours from now etc. 15 | """ 16 | 17 | now = datetime.utcnow() 18 | # remove tzinfo 19 | dt = dt.replace(tzinfo=None) 20 | if now > dt: 21 | diff = now - dt 22 | dt_is_past = True 23 | else: 24 | diff = dt - now 25 | dt_is_past = False 26 | 27 | periods = ( 28 | (diff.days // 365, "year", "years"), 29 | (diff.days // 30, "month", "months"), 30 | (diff.days // 7, "week", "weeks"), 31 | (diff.days, "day", "days"), 32 | (diff.seconds // 3600, "hour", "hours"), 33 | (diff.seconds // 60, "minute", "minutes"), 34 | (diff.seconds, "second", "seconds"), 35 | ) 36 | 37 | for period, singular, plural in periods: 38 | 39 | if period: 40 | return "%d %s %s" % ( 41 | period, 42 | singular if period == 1 else plural, 43 | past_ if dt_is_past else future_, 44 | ) 45 | 46 | return default 47 | 48 | 49 | @utils.app_template_filter("in_seconds") 50 | def in_seconds(dt): 51 | # return int((dt - datetime(1970, 1, 1)).total_seconds()) 52 | return int(time.mktime(dt.timetuple())) 53 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASEDIR = os.path.abspath(os.path.dirname(__file__)) 4 | 5 | 6 | def create_sqlite_uri(db_name): 7 | return "sqlite:///" + os.path.join(BASEDIR, db_name) 8 | 9 | 10 | class Config: 11 | SECRET_KEY = os.environ.get("SECRET_KEY") or "secret key, just for testing" 12 | SQLALCHEMY_COMMIT_ON_TEARDOWN = True 13 | SQLALCHEMY_RECORD_QUERIES = True 14 | SQLALCHEMY_TRACK_MODIFICATIONS = False 15 | 16 | @staticmethod 17 | def init_app(app): 18 | pass 19 | 20 | 21 | class DevelopmentConfig(Config): 22 | DEBUG = True 23 | SQLALCHEMY_DATABASE_URI = create_sqlite_uri("todolist-dev.db") 24 | 25 | 26 | class TestingConfig(Config): 27 | TESTING = True 28 | SQLALCHEMY_DATABASE_URI = create_sqlite_uri("todolist-test.db") 29 | WTF_CSRF_ENABLED = False 30 | import logging 31 | 32 | logging.basicConfig(format="%(asctime)s:%(levelname)s:%(name)s:%(message)s") 33 | logging.getLogger().setLevel(logging.DEBUG) 34 | 35 | 36 | class ProductionConfig(Config): 37 | SQLALCHEMY_DATABASE_URI = create_sqlite_uri("todolist.db") 38 | 39 | 40 | config = { 41 | "development": DevelopmentConfig, 42 | "testing": TestingConfig, 43 | "production": ProductionConfig, 44 | "default": DevelopmentConfig, 45 | } 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | todolist: 4 | container_name: todolist 5 | image: todolist 6 | build: . 7 | env_file: .env 8 | command: sh -c "flask db upgrade && gunicorn todolist:app -w 2 -b :8000" 9 | ports: 10 | - "8000:8000" 11 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from alembic import context 2 | from sqlalchemy import engine_from_config, pool 3 | from logging.config import fileConfig 4 | import logging 5 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | config = context.config 9 | 10 | # Interpret the config file for Python logging. 11 | # This line sets up loggers basically. 12 | fileConfig(config.config_file_name) 13 | logger = logging.getLogger("alembic.env") 14 | 15 | # add your model's MetaData object here 16 | # for 'autogenerate' support 17 | # from myapp import mymodel 18 | # target_metadata = mymodel.Base.metadata 19 | from flask import current_app 20 | 21 | config.set_main_option( 22 | "sqlalchemy.url", current_app.config.get("SQLALCHEMY_DATABASE_URI") 23 | ) 24 | target_metadata = current_app.extensions["migrate"].db.metadata 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | 31 | 32 | def run_migrations_offline(): 33 | """Run migrations in 'offline' mode. 34 | 35 | This configures the context with just a URL 36 | and not an Engine, though an Engine is acceptable 37 | here as well. By skipping the Engine creation 38 | we don't even need a DBAPI to be available. 39 | 40 | Calls to context.execute() here emit the given string to the 41 | script output. 42 | 43 | """ 44 | url = config.get_main_option("sqlalchemy.url") 45 | context.configure(url=url) 46 | 47 | with context.begin_transaction(): 48 | context.run_migrations() 49 | 50 | 51 | def run_migrations_online(): 52 | """Run migrations in 'online' mode. 53 | 54 | In this scenario we need to create an Engine 55 | and associate a connection with the context. 56 | 57 | """ 58 | 59 | # this callback is used to prevent an auto-migration from being generated 60 | # when there are no changes to the schema 61 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 62 | def process_revision_directives(context, revision, directives): 63 | if getattr(config.cmd_opts, "autogenerate", False): 64 | script = directives[0] 65 | if script.upgrade_ops.is_empty(): 66 | directives[:] = [] 67 | logger.info("No changes in schema detected.") 68 | 69 | engine = engine_from_config( 70 | config.get_section(config.config_ini_section), 71 | prefix="sqlalchemy.", 72 | poolclass=pool.NullPool, 73 | ) 74 | 75 | connection = engine.connect() 76 | context.configure( 77 | connection=connection, 78 | target_metadata=target_metadata, 79 | process_revision_directives=process_revision_directives, 80 | **current_app.extensions["migrate"].configure_args 81 | ) 82 | 83 | try: 84 | with context.begin_transaction(): 85 | context.run_migrations() 86 | finally: 87 | connection.close() 88 | 89 | 90 | if context.is_offline_mode(): 91 | run_migrations_offline() 92 | else: 93 | run_migrations_online() 94 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /migrations/versions/eff90419b076_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: eff90419b076 4 | Revises: None 5 | Create Date: 2016-09-24 20:15:22.786236 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = "eff90419b076" 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table( 20 | "user", 21 | sa.Column("id", sa.Integer(), nullable=False), 22 | sa.Column("username", sa.String(length=64), nullable=True), 23 | sa.Column("email", sa.String(length=64), nullable=True), 24 | sa.Column("password_hash", sa.String(length=128), nullable=True), 25 | sa.Column("member_since", sa.DateTime(), nullable=True), 26 | sa.Column("last_seen", sa.DateTime(), nullable=True), 27 | sa.Column("is_admin", sa.Boolean(), nullable=True), 28 | sa.PrimaryKeyConstraint("id"), 29 | sa.UniqueConstraint("email"), 30 | sa.UniqueConstraint("username"), 31 | ) 32 | op.create_table( 33 | "todolist", 34 | sa.Column("id", sa.Integer(), nullable=False), 35 | sa.Column("title", sa.String(length=128), nullable=True), 36 | sa.Column("created_at", sa.DateTime(), nullable=True), 37 | sa.Column("creator", sa.String(length=64), nullable=True), 38 | sa.ForeignKeyConstraint(["creator"], ["user.username"]), 39 | sa.PrimaryKeyConstraint("id"), 40 | ) 41 | op.create_table( 42 | "todo", 43 | sa.Column("id", sa.Integer(), nullable=False), 44 | sa.Column("description", sa.String(length=128), nullable=True), 45 | sa.Column("created_at", sa.DateTime(), nullable=True), 46 | sa.Column("finished_at", sa.DateTime(), nullable=True), 47 | sa.Column("is_finished", sa.Boolean(), nullable=True), 48 | sa.Column("creator", sa.String(length=64), nullable=True), 49 | sa.Column("todolist_id", sa.Integer(), nullable=True), 50 | sa.ForeignKeyConstraint(["creator"], ["user.username"]), 51 | sa.ForeignKeyConstraint(["todolist_id"], ["todolist.id"]), 52 | sa.PrimaryKeyConstraint("id"), 53 | ) 54 | op.create_index(op.f("ix_todo_created_at"), "todo", ["created_at"], unique=False) 55 | op.create_index(op.f("ix_todo_finished_at"), "todo", ["finished_at"], unique=False) 56 | ### end Alembic commands ### 57 | 58 | 59 | def downgrade(): 60 | ### commands auto generated by Alembic - please adjust! ### 61 | op.drop_index(op.f("ix_todo_finished_at"), table_name="todo") 62 | op.drop_index(op.f("ix_todo_created_at"), table_name="todo") 63 | op.drop_table("todo") 64 | op.drop_table("todolist") 65 | op.drop_table("user") 66 | ### end Alembic commands ### 67 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.1.2 2 | Flask-Login==0.6.1 3 | Flask-SQLAlchemy==2.5.1 4 | Flask-Migrate==3.1.0 5 | Flask-WTF==1.0.1 6 | email_validator==1.2.1 7 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | Flask-Testing==0.8.1 3 | blinker==1.4 4 | ForgeryPy==0.1 5 | python-dotenv==0.17.1 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wissen-snake/todo-list-flask/96769ad0d7e3db6c2defeab54d16de54e0966423/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | from flask import url_for 5 | from flask_login import login_user 6 | from flask_testing import TestCase 7 | 8 | from app import create_app, db 9 | from app.models import Todo, TodoList, User 10 | 11 | 12 | class TodolistAPITestCase(TestCase): 13 | def create_app(self): 14 | return create_app("testing") 15 | 16 | def setUp(self): 17 | db.create_all() 18 | self.username_alice = "alice" 19 | 20 | def tearDown(self): 21 | db.session.remove() 22 | db.drop_all() 23 | 24 | def assert404Response(self, response): 25 | self.assert_404(response) 26 | json_response = json.loads(response.data.decode("utf-8")) 27 | self.assertEqual(json_response["error"], "Not found") 28 | 29 | def assert400Response(self, response): 30 | self.assert_400(response) 31 | json_response = json.loads(response.data.decode("utf-8")) 32 | self.assertEqual(json_response["error"], "Bad Request") 33 | 34 | @staticmethod 35 | def setup_new_user(username): 36 | user_data = { 37 | "username": username, 38 | "email": username + "@example.com", 39 | "password": "correcthorsebatterystaple", 40 | } 41 | return user_data 42 | 43 | @staticmethod 44 | def get_headers(): 45 | return {"Accept": "application/json", "Content-Type": "application/json"} 46 | 47 | def add_user(self, username): 48 | user_data = self.setup_new_user(username) 49 | User.from_dict(user_data) 50 | return User.query.filter_by(username=username).first() 51 | 52 | @staticmethod 53 | def add_todolist(title, username=None): 54 | todolist = TodoList(title=title, creator=username).save() 55 | return TodoList.query.filter_by(id=todolist.id).first() 56 | 57 | def add_todo(self, description, todolist_id, username=None): 58 | todolist = TodoList.query.filter_by(id=todolist_id).first() 59 | todo = Todo( 60 | description=description, todolist_id=todolist.id, creator=username 61 | ).save() 62 | return Todo.query.filter_by(id=todo.id).first() 63 | 64 | def add_user_through_json_post(self, username): 65 | user_data = self.setup_new_user(username) 66 | return self.client.post( 67 | url_for("api.add_user"), 68 | headers=self.get_headers(), 69 | data=json.dumps(user_data), 70 | ) 71 | 72 | def create_admin(self): 73 | new_user = self.setup_new_user("admin") 74 | new_user["is_admin"] = True 75 | return User.from_dict(new_user) 76 | 77 | def test_main_route(self): 78 | response = self.client.get(url_for("api.get_routes")) 79 | self.assert_200(response) 80 | 81 | json_response = json.loads(response.data.decode("utf-8")) 82 | self.assertTrue("users" in json_response) 83 | self.assertTrue("todolists" in json_response) 84 | 85 | def test_not_found(self): 86 | response = self.client.get("/api/not/found") 87 | self.assert404Response(response) 88 | 89 | # test api post calls 90 | def test_add_user(self): 91 | post_response = self.add_user_through_json_post(self.username_alice) 92 | self.assertEqual(post_response.headers["Content-Type"], "application/json") 93 | self.assert_status(post_response, 201) 94 | 95 | response = self.client.get(url_for("api.get_users")) 96 | self.assert_200(response) 97 | 98 | json_response = json.loads(response.data.decode("utf-8")) 99 | users = json_response["users"] 100 | self.assertEqual(users[0]["username"], self.username_alice) 101 | 102 | def test_add_user_only_using_the_username(self): 103 | user_data = {"username": self.username_alice} 104 | response = self.client.post( 105 | url_for("api.add_user"), 106 | headers=self.get_headers(), 107 | data=json.dumps(user_data), 108 | ) 109 | self.assert400Response(response) 110 | 111 | def test_add_user_only_using_the_username_and_email(self): 112 | user_data = { 113 | "username": self.username_alice, 114 | "email": self.username_alice + "@example.com", 115 | } 116 | response = self.client.post( 117 | url_for("api.add_user"), 118 | headers=self.get_headers(), 119 | data=json.dumps(user_data), 120 | ) 121 | self.assert400Response(response) 122 | 123 | def test_add_user_with_to_long_username(self): 124 | user_data = { 125 | "username": 65 * "a", 126 | "email": self.username_alice + "@example.com", 127 | "password": "correcthorsebatterystaple", 128 | } 129 | response = self.client.post( 130 | url_for("api.add_user"), 131 | headers=self.get_headers(), 132 | data=json.dumps(user_data), 133 | ) 134 | self.assert400Response(response) 135 | 136 | def test_add_user_with_invalid_username(self): 137 | user_data = { 138 | "username": "not a valid username", 139 | "email": self.username_alice + "@example.com", 140 | "password": "correcthorsebatterystaple", 141 | } 142 | response = self.client.post( 143 | url_for("api.add_user"), 144 | headers=self.get_headers(), 145 | data=json.dumps(user_data), 146 | ) 147 | self.assert400Response(response) 148 | 149 | def test_add_user_without_username(self): 150 | user_data = { 151 | "username": "", 152 | "email": self.username_alice + "@example.com", 153 | "password": "correcthorsebatterystaple", 154 | } 155 | response = self.client.post( 156 | url_for("api.add_user"), 157 | headers=self.get_headers(), 158 | data=json.dumps(user_data), 159 | ) 160 | self.assert400Response(response) 161 | 162 | def test_add_user_with_invalid_email(self): 163 | user_data = { 164 | "username": self.username_alice, 165 | "email": self.username_alice + "example.com", 166 | "password": "correcthorsebatterystaple", 167 | } 168 | response = self.client.post( 169 | url_for("api.add_user"), 170 | headers=self.get_headers(), 171 | data=json.dumps(user_data), 172 | ) 173 | self.assert400Response(response) 174 | 175 | def test_add_user_withoout_email(self): 176 | user_data = { 177 | "username": self.username_alice, 178 | "email": "", 179 | "password": "correcthorsebatterystaple", 180 | } 181 | response = self.client.post( 182 | url_for("api.add_user"), 183 | headers=self.get_headers(), 184 | data=json.dumps(user_data), 185 | ) 186 | self.assert400Response(response) 187 | 188 | def test_add_user_with_too_long_email(self): 189 | user_data = { 190 | "username": self.username_alice, 191 | "email": 53 * "a" + "@example.com", 192 | "password": "correcthorsebatterystaple", 193 | } 194 | response = self.client.post( 195 | url_for("api.add_user"), 196 | headers=self.get_headers(), 197 | data=json.dumps(user_data), 198 | ) 199 | self.assert400Response(response) 200 | 201 | def test_add_user_without_password(self): 202 | user_data = { 203 | "username": self.username_alice, 204 | "email": self.username_alice + "@example.com", 205 | "password": "", 206 | } 207 | response = self.client.post( 208 | url_for("api.add_user"), 209 | headers=self.get_headers(), 210 | data=json.dumps(user_data), 211 | ) 212 | self.assert400Response(response) 213 | 214 | def test_add_user_with_extra_fields(self): 215 | user_data = { 216 | "username": self.username_alice, 217 | "email": self.username_alice + "@example.com", 218 | "password": "correcthorsebatterystaple", 219 | "extra-field": "will be ignored", 220 | } 221 | post_response = self.client.post( 222 | url_for("api.add_user"), 223 | headers=self.get_headers(), 224 | data=json.dumps(user_data), 225 | ) 226 | self.assertEqual(post_response.headers["Content-Type"], "application/json") 227 | self.assert_status(post_response, 201) 228 | 229 | response = self.client.get(url_for("api.get_users")) 230 | self.assert_200(response) 231 | 232 | json_response = json.loads(response.data.decode("utf-8")) 233 | self.assertEqual(json_response["users"][0]["username"], self.username_alice) 234 | 235 | def test_add_user_only_using_the_username_and_password(self): 236 | user_data = { 237 | "username": self.username_alice, 238 | "password": "correcthorsebatterystaple", 239 | } 240 | response = self.client.post( 241 | url_for("api.add_user"), 242 | headers=self.get_headers(), 243 | data=json.dumps(user_data), 244 | ) 245 | self.assert400Response(response) 246 | 247 | def test_add_todolist(self): 248 | post_response = self.client.post( 249 | url_for("api.add_todolist"), 250 | headers=self.get_headers(), 251 | data=json.dumps({"title": "todolist"}), 252 | ) 253 | self.assert_status(post_response, 201) 254 | 255 | # the expected id of the todolist is 1, as it is the first to be added 256 | response = self.client.get(url_for("api.get_todolist", todolist_id=1)) 257 | self.assert_200(response) 258 | 259 | json_response = json.loads(response.data.decode("utf-8")) 260 | self.assertEqual(json_response["title"], "todolist") 261 | 262 | def test_add_todolist_without_title(self): 263 | response = self.client.post( 264 | url_for("api.add_todolist"), headers=self.get_headers() 265 | ) 266 | # opposed to the form, the title is a required argument 267 | self.assert400Response(response) 268 | 269 | def test_add_todolist_with_too_long_title(self): 270 | response = self.client.post( 271 | url_for("api.add_todolist"), 272 | headers=self.get_headers(), 273 | data=json.dumps({"title": 129 * "t"}), 274 | ) 275 | self.assert400Response(response) 276 | 277 | def test_add_user_todolist(self): 278 | self.add_user(self.username_alice) 279 | post_response = self.client.post( 280 | url_for("api.add_user_todolist", username=self.username_alice), 281 | headers=self.get_headers(), 282 | data=json.dumps({"title": "todolist"}), 283 | ) 284 | self.assert_status(post_response, 201) 285 | 286 | response = self.client.get( 287 | url_for("api.get_user_todolists", username=self.username_alice) 288 | ) 289 | self.assert_200(response) 290 | json_response = json.loads(response.data.decode("utf-8")) 291 | 292 | # check title, creator are set correctly and a total of one todolist 293 | todolists = json_response["todolists"] 294 | self.assertEqual(todolists[0]["title"], "todolist") 295 | self.assertEqual(todolists[0]["creator"], self.username_alice) 296 | self.assertEqual(len(todolists), 1) 297 | 298 | def test_add_user_todolist_when_user_does_not_exist(self): 299 | post_response = self.client.post( 300 | url_for("api.add_user_todolist", username=self.username_alice), 301 | headers=self.get_headers(), 302 | data=json.dumps({"title": "todolist"}), 303 | ) 304 | self.assert404Response(post_response) 305 | 306 | def test_add_user_todolist_todo(self): 307 | todolist_title = "new todolist" 308 | self.add_user(self.username_alice) 309 | new_todolist = self.add_todolist(todolist_title, self.username_alice) 310 | 311 | post_response = self.client.post( 312 | url_for( 313 | "api.add_user_todolist_todo", 314 | username=self.username_alice, 315 | todolist_id=new_todolist.id, 316 | ), 317 | headers=self.get_headers(), 318 | data=json.dumps( 319 | { 320 | "description": "new todo", 321 | "creator": self.username_alice, 322 | "todolist_id": new_todolist.id, 323 | } 324 | ), 325 | ) 326 | self.assert_status(post_response, 201) 327 | 328 | response = self.client.get( 329 | url_for( 330 | "api.get_user_todolist_todos", 331 | username=self.username_alice, 332 | todolist_id=new_todolist.id, 333 | ) 334 | ) 335 | self.assert_200(response) 336 | json_response = json.loads(response.data.decode("utf-8")) 337 | 338 | # check title, creator are set correctly and a total of one todo 339 | todos = json_response["todos"] 340 | self.assertEqual(todos[0]["description"], "new todo") 341 | self.assertEqual(todos[0]["creator"], self.username_alice) 342 | self.assertEqual(len(todos), 1) 343 | 344 | def test_add_user_todolist_todo_when_todolist_does_not_exist(self): 345 | self.add_user(self.username_alice) 346 | post_response = self.client.post( 347 | url_for( 348 | "api.add_user_todolist_todo", 349 | username=self.username_alice, 350 | todolist_id=1, 351 | ), 352 | headers=self.get_headers(), 353 | data=json.dumps( 354 | { 355 | "description": "new todo", 356 | "creator": self.username_alice, 357 | "todolist_id": 1, 358 | } 359 | ), 360 | ) 361 | self.assert404Response(post_response) 362 | 363 | def test_add_user_todolist_todo_without_todo_data(self): 364 | todolist_title = "new todolist" 365 | self.add_user(self.username_alice) 366 | new_todolist = self.add_todolist(todolist_title, self.username_alice) 367 | 368 | post_response = self.client.post( 369 | url_for( 370 | "api.add_user_todolist_todo", 371 | username=self.username_alice, 372 | todolist_id=new_todolist.id, 373 | ), 374 | headers=self.get_headers(), 375 | ) 376 | self.assert400Response(post_response) 377 | 378 | def test_add_todolist_todo(self): 379 | new_todolist = TodoList().save() # todolist with default title 380 | 381 | post_response = self.client.post( 382 | url_for("api.add_todolist_todo", todolist_id=new_todolist.id), 383 | headers=self.get_headers(), 384 | data=json.dumps( 385 | { 386 | "description": "new todo", 387 | "creator": "null", 388 | "todolist_id": new_todolist.id, 389 | } 390 | ), 391 | ) 392 | self.assert_status(post_response, 201) 393 | response = self.client.get( 394 | url_for("api.get_todolist_todos", todolist_id=new_todolist.id) 395 | ) 396 | self.assert_200(response) 397 | json_response = json.loads(response.data.decode("utf-8")) 398 | 399 | # check title, creator are set correctly and a total of one todo 400 | todos = json_response["todos"] 401 | self.assertEqual(todos[0]["description"], "new todo") 402 | self.assertEqual(todos[0]["creator"], None) 403 | self.assertEqual(len(todos), 1) 404 | 405 | def test_add_todolist_todo_when_todolist_does_not_exist(self): 406 | post_response = self.client.post( 407 | url_for("api.add_todolist_todo", todolist_id=1), 408 | headers=self.get_headers(), 409 | data=json.dumps( 410 | {"description": "new todo", "creator": "null", "todolist_id": 1} 411 | ), 412 | ) 413 | self.assert404Response(post_response) 414 | 415 | def test_add_todolist_todo_without_todo_data(self): 416 | new_todolist = TodoList().save() 417 | post_response = self.client.post( 418 | url_for("api.add_todolist_todo", todolist_id=new_todolist.id), 419 | headers=self.get_headers(), 420 | ) 421 | self.assert400Response(post_response) 422 | 423 | # test api get calls 424 | def test_get_users(self): 425 | self.add_user(self.username_alice) 426 | response = self.client.get(url_for("api.get_users")) 427 | self.assert_200(response) 428 | 429 | json_response = json.loads(response.data.decode("utf-8")) 430 | self.assertEqual(json_response["users"][0]["username"], self.username_alice) 431 | 432 | def test_get_users_when_no_users_exist(self): 433 | response = self.client.get(url_for("api.get_users")) 434 | self.assert_200(response) 435 | 436 | json_response = json.loads(response.data.decode("utf-8")) 437 | self.assertEqual(json_response["users"], []) 438 | 439 | def test_get_user(self): 440 | self.add_user(self.username_alice) 441 | response = self.client.get( 442 | url_for("api.get_user", username=self.username_alice) 443 | ) 444 | self.assert_200(response) 445 | 446 | json_response = json.loads(response.data.decode("utf-8")) 447 | self.assertEqual(json_response["username"], self.username_alice) 448 | 449 | def test_get_user_when_user_does_not_exist(self): 450 | response = self.client.get( 451 | url_for("api.get_user", username=self.username_alice) 452 | ) 453 | self.assert404Response(response) 454 | 455 | def test_get_todolists(self): 456 | todolist_title = "new todolist " 457 | self.add_user(self.username_alice) 458 | self.add_todolist(todolist_title + "1", self.username_alice) 459 | self.add_todolist(todolist_title + "2", self.username_alice) 460 | 461 | response = self.client.get(url_for("api.get_todolists")) 462 | self.assert_200(response) 463 | 464 | json_response = json.loads(response.data.decode("utf-8")) 465 | todolists = json_response["todolists"] 466 | self.assertEqual(todolists[0]["title"], "new todolist 1") 467 | self.assertEqual(todolists[0]["creator"], self.username_alice) 468 | self.assertEqual(todolists[1]["title"], "new todolist 2") 469 | self.assertEqual(todolists[1]["creator"], self.username_alice) 470 | self.assertEqual(len(todolists), 2) 471 | 472 | def test_get_todolists_when_no_todolists_exist(self): 473 | response = self.client.get(url_for("api.get_todolists")) 474 | self.assert_200(response) 475 | 476 | todolists = json.loads(response.data.decode("utf-8"))["todolists"] 477 | self.assertEqual(todolists, []) 478 | 479 | def test_get_user_todolists(self): 480 | todolist_title = "new todolist " 481 | self.add_user(self.username_alice) 482 | self.add_todolist(todolist_title + "1", self.username_alice) 483 | self.add_todolist(todolist_title + "2", self.username_alice) 484 | 485 | response = self.client.get( 486 | url_for("api.get_user_todolists", username=self.username_alice) 487 | ) 488 | self.assert_200(response) 489 | 490 | json_response = json.loads(response.data.decode("utf-8")) 491 | todolists = json_response["todolists"] 492 | 493 | self.assertEqual(todolists[0]["title"], "new todolist 1") 494 | self.assertEqual(todolists[0]["creator"], self.username_alice) 495 | self.assertEqual(todolists[1]["title"], "new todolist 2") 496 | self.assertEqual(todolists[1]["creator"], self.username_alice) 497 | self.assertEqual(len(todolists), 2) 498 | 499 | def test_get_user_todolists_when_user_does_not_exist(self): 500 | response = self.client.get( 501 | url_for("api.get_user_todolists", username=self.username_alice) 502 | ) 503 | self.assert404Response(response) 504 | 505 | def test_get_user_todolists_when_user_has_no_todolists(self): 506 | self.add_user(self.username_alice) 507 | response = self.client.get( 508 | url_for("api.get_user_todolists", username=self.username_alice) 509 | ) 510 | self.assert_200(response) 511 | 512 | todolists = json.loads(response.data.decode("utf-8"))["todolists"] 513 | self.assertEqual(todolists, []) 514 | 515 | def test_get_todolist_todos(self): 516 | todolist_title = "new todolist" 517 | new_todolist = self.add_todolist(todolist_title) 518 | 519 | self.add_todo("first", new_todolist.id) 520 | self.add_todo("second", new_todolist.id) 521 | 522 | response = self.client.get( 523 | url_for("api.get_todolist_todos", todolist_id=new_todolist.id) 524 | ) 525 | self.assert_200(response) 526 | 527 | json_response = json.loads(response.data.decode("utf-8")) 528 | todos = json_response["todos"] 529 | self.assertEqual(todos[0]["description"], "first") 530 | self.assertEqual(todos[0]["creator"], None) 531 | self.assertEqual(todos[1]["description"], "second") 532 | self.assertEqual(todos[1]["creator"], None) 533 | self.assertEqual(len(todos), 2) 534 | 535 | def test_get_todolist_todos_when_todolist_does_not_exist(self): 536 | response = self.client.get(url_for("api.get_todolist_todos", todolist_id=1)) 537 | self.assert404Response(response) 538 | 539 | def test_get_todolist_todos_when_todolist_has_no_todos(self): 540 | todolist_title = "new todolist" 541 | new_todolist = self.add_todolist(todolist_title) 542 | response = self.client.get( 543 | url_for("api.get_todolist_todos", todolist_id=new_todolist.id) 544 | ) 545 | self.assert_200(response) 546 | 547 | todos = json.loads(response.data.decode("utf-8"))["todos"] 548 | self.assertEqual(todos, []) 549 | 550 | def test_get_user_todolist_todos(self): 551 | todolist_title = "new todolist" 552 | self.add_user(self.username_alice) 553 | new_todolist = self.add_todolist(todolist_title, self.username_alice) 554 | 555 | self.add_todo("first", new_todolist.id, self.username_alice) 556 | self.add_todo("second", new_todolist.id, self.username_alice) 557 | 558 | response = self.client.get( 559 | url_for( 560 | "api.get_user_todolist_todos", 561 | username=self.username_alice, 562 | todolist_id=new_todolist.id, 563 | ) 564 | ) 565 | self.assert_200(response) 566 | 567 | json_response = json.loads(response.data.decode("utf-8")) 568 | todos = json_response["todos"] 569 | self.assertEqual(todos[0]["description"], "first") 570 | self.assertEqual(todos[0]["creator"], self.username_alice) 571 | self.assertEqual(todos[1]["description"], "second") 572 | self.assertEqual(todos[1]["creator"], self.username_alice) 573 | self.assertEqual(len(todos), 2) 574 | 575 | def test_get_user_todolist_todos_when_user_does_not_exist(self): 576 | response = self.client.get( 577 | url_for( 578 | "api.get_user_todolist_todos", 579 | username=self.username_alice, 580 | todolist_id=1, 581 | ) 582 | ) 583 | self.assert404Response(response) 584 | 585 | def test_get_user_todolist_todos_when_todolist_does_not_exist(self): 586 | self.add_user(self.username_alice) 587 | 588 | response = self.client.get( 589 | url_for( 590 | "api.get_user_todolist_todos", 591 | username=self.username_alice, 592 | todolist_id=1, 593 | ) 594 | ) 595 | self.assert404Response(response) 596 | 597 | def test_get_user_todolist_todos_when_todolist_has_no_todos(self): 598 | todolist_title = "new todolist" 599 | self.add_user(self.username_alice) 600 | new_todolist = self.add_todolist(todolist_title, self.username_alice) 601 | 602 | response = self.client.get( 603 | url_for( 604 | "api.get_user_todolist_todos", 605 | username=self.username_alice, 606 | todolist_id=new_todolist.id, 607 | ) 608 | ) 609 | self.assert_200(response) 610 | 611 | todos = json.loads(response.data.decode("utf-8"))["todos"] 612 | self.assertEqual(todos, []) 613 | 614 | def test_get_different_user_todolist_todos(self): 615 | first_username = self.username_alice 616 | second_username = "bob" 617 | todolist_title = "new todolist" 618 | first_user = self.add_user(first_username) 619 | self.add_user(second_username) 620 | new_todolist = self.add_todolist(todolist_title, second_username) 621 | 622 | self.add_todo("first", new_todolist.id, second_username) 623 | self.add_todo("second", new_todolist.id, second_username) 624 | 625 | response = self.client.get( 626 | url_for( 627 | "api.get_user_todolist_todos", 628 | username=first_user, 629 | todolist_id=new_todolist.id, 630 | ) 631 | ) 632 | self.assert404Response(response) 633 | 634 | def test_get_user_todolist(self): 635 | todolist_title = "new todolist" 636 | self.add_user(self.username_alice) 637 | new_todolist = self.add_todolist(todolist_title, self.username_alice) 638 | 639 | response = self.client.get( 640 | url_for( 641 | "api.get_user_todolist", 642 | username=self.username_alice, 643 | todolist_id=new_todolist.id, 644 | ) 645 | ) 646 | self.assert_200(response) 647 | 648 | json_response = json.loads(response.data.decode("utf-8")) 649 | 650 | self.assertEqual(json_response["title"], todolist_title) 651 | self.assertEqual(json_response["creator"], self.username_alice) 652 | 653 | def test_get_user_todolist_when_user_does_not_exist(self): 654 | response = self.client.get( 655 | url_for( 656 | "api.get_user_todolist", username=self.username_alice, todolist_id=1 657 | ) 658 | ) 659 | self.assert404Response(response) 660 | 661 | def test_get_user_todolist_when_todolist_does_not_exist(self): 662 | self.add_user(self.username_alice) 663 | response = self.client.get( 664 | url_for( 665 | "api.get_user_todolist", username=self.username_alice, todolist_id=1 666 | ) 667 | ) 668 | self.assert404Response(response) 669 | 670 | # test api put call 671 | def test_update_todo_status_to_finished(self): 672 | todolist = self.add_todolist("new todolist") 673 | todo = self.add_todo("first", todolist.id) 674 | self.assertFalse(todo.is_finished) 675 | 676 | self.client.put( 677 | url_for("api.update_todo_status", todo_id=todo.id), 678 | headers=self.get_headers(), 679 | data=json.dumps({"is_finished": True}), 680 | ) 681 | 682 | todo = Todo.query.get(todo.id) 683 | self.assertTrue(todo.is_finished) 684 | 685 | def test_update_todo_status_to_open(self): 686 | todolist = self.add_todolist("new todolist") 687 | todo = self.add_todo("first", todolist.id) 688 | todo.finished() 689 | self.assertTrue(todo.is_finished) 690 | 691 | self.client.put( 692 | url_for("api.update_todo_status", todo_id=todo.id), 693 | headers=self.get_headers(), 694 | data=json.dumps({"is_finished": False}), 695 | ) 696 | todo = Todo.query.get(todo.id) 697 | self.assertFalse(todo.is_finished) 698 | self.assertTrue(todo.finished_at is None) 699 | 700 | def test_change_todolist_title(self): 701 | todolist = self.add_todolist("new todolist") 702 | 703 | response = self.client.put( 704 | url_for("api.change_todolist_title", todolist_id=todolist.id), 705 | headers=self.get_headers(), 706 | data=json.dumps({"title": "changed title"}), 707 | ) 708 | self.assert_200(response) 709 | 710 | json_response = json.loads(response.data.decode("utf-8")) 711 | self.assertEqual(json_response["title"], "changed title") 712 | 713 | def test_change_todolist_title_too_long_title(self): 714 | todolist = self.add_todolist("new todolist") 715 | 716 | response = self.client.put( 717 | url_for("api.change_todolist_title", todolist_id=todolist.id), 718 | headers=self.get_headers(), 719 | data=json.dumps({"title": 129 * "t"}), 720 | ) 721 | self.assert_400(response) 722 | 723 | def test_change_todolist_title_empty_title(self): 724 | todolist = self.add_todolist("new todolist") 725 | 726 | response = self.client.put( 727 | url_for("api.change_todolist_title", todolist_id=todolist.id), 728 | headers=self.get_headers(), 729 | data=json.dumps({"title": ""}), 730 | ) 731 | self.assert_400(response) 732 | 733 | def test_change_todolist_title_without_title(self): 734 | todolist = self.add_todolist("new todolist") 735 | 736 | response = self.client.put( 737 | url_for("api.change_todolist_title", todolist_id=todolist.id), 738 | headers=self.get_headers(), 739 | ) 740 | self.assert_400(response) 741 | 742 | # test api delete calls 743 | @unittest.skip("because acquiring admin rights is currently an issue") 744 | def test_delete_user(self): 745 | admin = self.create_admin() 746 | login_user(admin) 747 | 748 | user = self.add_user(self.username_alice) 749 | user_id = user.id 750 | 751 | response = self.client.delete( 752 | url_for("api.delete_user", user_id=user_id), 753 | headers=self.get_headers(), 754 | data=json.dumps({"user_id": user_id}), 755 | ) 756 | self.assert_200(response) 757 | 758 | response = self.client.get(url_for("api.get_user", user_id=user_id)) 759 | self.assert_404(response) 760 | 761 | @unittest.skip("because acquiring admin rights is currently an issue") 762 | def test_delete_todolist(self): 763 | admin = self.create_admin() 764 | login_user(admin) 765 | 766 | todolist = self.add_todolist("new todolist") 767 | todolist_id = todolist.id 768 | 769 | response = self.client.delete( 770 | url_for("api.delete_todolist", todolist_id=todolist_id), 771 | headers=self.get_headers(), 772 | data=json.dumps({"todolist_id": todolist_id}), 773 | ) 774 | self.assert_200(response) 775 | 776 | response = self.client.get(url_for("api.get_todolist", todolist_id=todolist_id)) 777 | self.assert_404(response) 778 | 779 | @unittest.skip("because acquiring admin rights is currently an issue") 780 | def test_delete_todo(self): 781 | admin = self.create_admin() 782 | login_user(admin) 783 | 784 | todolist = self.add_todolist("new todolist") 785 | todo = self.add_todo("new todo", todolist.id) 786 | todo_id = todo.id 787 | 788 | response = self.client.delete( 789 | url_for("api.delete_todo", todo_id=todo_id), 790 | headers=self.get_headers(), 791 | data=json.dumps({"todo_id": todo_id}), 792 | ) 793 | self.assert_200(response) 794 | 795 | response = self.client.get(url_for("api.get_todo", todo_id=todo_id)) 796 | self.assert_404(response) 797 | -------------------------------------------------------------------------------- /tests/test_basics.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from flask import current_app 4 | 5 | from app import create_app, db 6 | from app.models import Todo, TodoList, User 7 | 8 | 9 | class TodolistTestCase(unittest.TestCase): 10 | def setUp(self): 11 | self.app = create_app("testing") 12 | self.app_context = self.app.app_context() 13 | self.app_context.push() 14 | db.create_all() 15 | 16 | self.username_adam = "adam" 17 | self.shopping_list_title = "shopping list" 18 | self.read_todo_description = "Read a book about TDD" 19 | 20 | def tearDown(self): 21 | db.session.remove() 22 | db.drop_all() 23 | self.app_context.pop() 24 | 25 | @staticmethod 26 | def add_user(username): 27 | user_data = { 28 | "email": username + "@example.com", 29 | "username": username, 30 | "password": "correcthorsebatterystaple", 31 | } 32 | user = User.from_dict(user_data) 33 | return User.query.filter_by(username=user.username).first() 34 | 35 | @staticmethod 36 | def add_todo(description, user, todolist_id=None): 37 | todo_data = { 38 | "description": description, 39 | "todolist_id": todolist_id or TodoList().save().id, 40 | "creator": user.username, 41 | } 42 | read_todo = Todo.from_dict(todo_data) 43 | return Todo.query.filter_by(id=read_todo.id).first() 44 | 45 | def test_app_exists(self): 46 | self.assertTrue(current_app is not None) 47 | 48 | def test_app_is_testing(self): 49 | self.assertTrue(current_app.config["TESTING"]) 50 | 51 | def test_password_setter(self): 52 | u = User(password="correcthorsebatterystaple") 53 | self.assertTrue(u.password_hash is not None) 54 | 55 | def test_no_password_getter(self): 56 | u = User(password="correcthorsebatterystaple") 57 | with self.assertRaises(AttributeError): 58 | u.password 59 | 60 | def test_password_verification(self): 61 | u = User(password="correcthorsebatterystaple") 62 | self.assertTrue(u.verify_password("correcthorsebatterystaple")) 63 | self.assertFalse(u.verify_password("incorrecthorsebatterystaple")) 64 | 65 | def test_password_salts_are_random(self): 66 | u = User(password="correcthorsebatterystaple") 67 | u2 = User(password="correcthorsebatterystaple") 68 | self.assertNotEqual(u.password_hash, u2.password_hash) 69 | 70 | def test_adding_new_user(self): 71 | new_user = self.add_user(self.username_adam) 72 | self.assertEqual(new_user.username, self.username_adam) 73 | self.assertEqual(new_user.email, self.username_adam + "@example.com") 74 | 75 | def test_adding_new_todo_without_user(self): 76 | todo = Todo( 77 | description=self.read_todo_description, todolist_id=TodoList().save().id 78 | ).save() 79 | todo_from_db = Todo.query.filter_by(id=todo.id).first() 80 | 81 | self.assertEqual(todo_from_db.description, self.read_todo_description) 82 | self.assertIsNone(todo_from_db.creator) 83 | 84 | def test_adding_new_todo_with_user(self): 85 | some_user = self.add_user(self.username_adam) 86 | new_todo = self.add_todo(self.read_todo_description, some_user) 87 | self.assertEqual(new_todo.description, self.read_todo_description) 88 | self.assertEqual(new_todo.creator, some_user.username) 89 | 90 | def test_closing_todo(self): 91 | some_user = self.add_user(self.username_adam) 92 | new_todo = self.add_todo(self.read_todo_description, some_user) 93 | self.assertFalse(new_todo.is_finished) 94 | new_todo.finished() 95 | self.assertTrue(new_todo.is_finished) 96 | self.assertEqual(new_todo.description, self.read_todo_description) 97 | self.assertEqual(new_todo.creator, some_user.username) 98 | 99 | def test_reopen_closed_todo(self): 100 | some_user = self.add_user(self.username_adam) 101 | new_todo = self.add_todo(self.read_todo_description, some_user) 102 | self.assertFalse(new_todo.is_finished) 103 | new_todo.finished() 104 | self.assertTrue(new_todo.is_finished) 105 | new_todo.reopen() 106 | self.assertFalse(new_todo.is_finished) 107 | self.assertEqual(new_todo.description, self.read_todo_description) 108 | self.assertEqual(new_todo.creator, some_user.username) 109 | 110 | def test_adding_two_todos_with_the_same_description(self): 111 | some_user = self.add_user(self.username_adam) 112 | first_todo = self.add_todo(self.read_todo_description, some_user) 113 | second_todo = self.add_todo(self.read_todo_description, some_user) 114 | 115 | self.assertEqual(first_todo.description, second_todo.description) 116 | self.assertEqual(first_todo.creator, second_todo.creator) 117 | self.assertNotEqual(first_todo.id, second_todo.id) 118 | 119 | def test_adding_new_todolist_without_user(self): 120 | todolist = TodoList(self.shopping_list_title).save() 121 | todolist_from_db = TodoList.query.filter_by(id=todolist.id).first() 122 | 123 | self.assertEqual(todolist_from_db.title, self.shopping_list_title) 124 | self.assertIsNone(todolist_from_db.creator) 125 | 126 | def test_adding_new_todolist_with_user(self): 127 | user = self.add_user(self.username_adam) 128 | todolist = TodoList( 129 | title=self.shopping_list_title, creator=user.username 130 | ).save() 131 | todolist_from_db = TodoList.query.filter_by(id=todolist.id).first() 132 | 133 | self.assertEqual(todolist_from_db.title, self.shopping_list_title) 134 | self.assertEqual(todolist_from_db.creator, user.username) 135 | 136 | def test_adding_two_todolists_with_the_same_title(self): 137 | user = self.add_user(self.username_adam) 138 | ftodolist = TodoList( 139 | title=self.shopping_list_title, creator=user.username 140 | ).save() 141 | first_todolist = TodoList.query.filter_by(id=ftodolist.id).first() 142 | stodolist = TodoList( 143 | title=self.shopping_list_title, creator=user.username 144 | ).save() 145 | second_todolist = TodoList.query.filter_by(id=stodolist.id).first() 146 | 147 | self.assertEqual(first_todolist.title, second_todolist.title) 148 | self.assertEqual(first_todolist.creator, second_todolist.creator) 149 | self.assertNotEqual(first_todolist.id, second_todolist.id) 150 | 151 | def test_adding_todo_to_todolist(self): 152 | user = self.add_user(self.username_adam) 153 | todolist = TodoList( 154 | title=self.shopping_list_title, creator=user.username 155 | ).save() 156 | todolist_from_db = TodoList.query.filter_by(id=todolist.id).first() 157 | 158 | todo_description = "A book about TDD" 159 | todo = self.add_todo(todo_description, user, todolist_from_db.id) 160 | 161 | self.assertEqual(todolist_from_db.todo_count, 1) 162 | self.assertEqual(todolist.title, self.shopping_list_title) 163 | self.assertEqual(todolist.creator, user.username) 164 | self.assertEqual(todo.todolist_id, todolist_from_db.id) 165 | self.assertEqual(todolist.todos.first(), todo) 166 | 167 | def test_counting_todos_of_todolist(self): 168 | user = self.add_user(self.username_adam) 169 | todolist = TodoList( 170 | title=self.shopping_list_title, creator=user.username 171 | ).save() 172 | todolist_from_db = TodoList.query.filter_by(id=todolist.id).first() 173 | 174 | todo_description = "A book about TDD" 175 | todo = self.add_todo(todo_description, user, todolist_from_db.id) 176 | 177 | self.assertEqual(todolist.title, self.shopping_list_title) 178 | self.assertEqual(todolist.creator, user.username) 179 | self.assertEqual(todo.todolist_id, todolist_from_db.id) 180 | self.assertEqual(todolist.todos.first(), todo) 181 | 182 | self.assertEqual(todolist_from_db.finished_count, 0) 183 | self.assertEqual(todolist_from_db.open_count, 1) 184 | 185 | todo.finished() 186 | 187 | self.assertEqual(todolist_from_db.finished_count, 1) 188 | self.assertEqual(todolist_from_db.open_count, 0) 189 | 190 | # test delete functions 191 | def test_delete_user(self): 192 | user = self.add_user(self.username_adam) 193 | user_id = user.id 194 | user.delete() 195 | self.assertIsNone(User.query.get(user_id)) 196 | 197 | def test_delete_todolist(self): 198 | todolist = TodoList(self.shopping_list_title).save() 199 | todolist_id = todolist.id 200 | todolist.delete() 201 | self.assertIsNone(TodoList.query.get(todolist_id)) 202 | 203 | def test_delete_todo(self): 204 | todolist = TodoList(self.shopping_list_title).save() 205 | todo = Todo("A book about TDD", todolist.id).save() 206 | self.assertEqual(todolist.todo_count, 1) 207 | todo_id = todo.id 208 | todo.delete() 209 | self.assertIsNone(Todo.query.get(todo_id)) 210 | self.assertEqual(todolist.todo_count, 0) 211 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | from flask_testing import TestCase 3 | 4 | from app import create_app, db 5 | from app.models import User 6 | 7 | 8 | class TodolistClientTestCase(TestCase): 9 | def create_app(self): 10 | return create_app("testing") 11 | 12 | def setUp(self): 13 | db.create_all() 14 | self.username_alice = "alice" 15 | 16 | def tearDown(self): 17 | db.session.remove() 18 | db.drop_all() 19 | 20 | def register_user(self, name): 21 | response = self.client.post( 22 | url_for("auth.register"), 23 | data={ 24 | "username": name, 25 | "email": name + "@example.com", 26 | "password": "correcthorsebatterystaple", 27 | "password_confirmation": "correcthorsebatterystaple", 28 | }, 29 | ) 30 | return response 31 | 32 | def login_user(self, name): 33 | response = self.client.post( 34 | url_for("auth.login"), 35 | data={ 36 | "email_or_username": name + "@example.com", 37 | "password": "correcthorsebatterystaple", 38 | }, 39 | ) 40 | return response 41 | 42 | def register_and_login(self, name): 43 | response = self.register_user(name) 44 | self.assert_redirects(response, "/auth/login") 45 | response = self.login_user(name) 46 | self.assert_redirects(response, "/") 47 | 48 | def test_home_page(self): 49 | response = self.client.get(url_for("main.index")) 50 | self.assert_200(response) 51 | self.assert_template_used("index.html") 52 | 53 | def test_register_page(self): 54 | response = self.client.get(url_for("auth.register")) 55 | self.assert_200(response) 56 | self.assert_template_used("register.html") 57 | 58 | def test_login_page(self): 59 | response = self.client.get(url_for("auth.login")) 60 | self.assert_200(response) 61 | self.assert_template_used("login.html") 62 | 63 | def test_overview_page(self): 64 | self.register_and_login(self.username_alice) 65 | response = self.client.get(url_for("main.todolist_overview")) 66 | # expect not redirect as user is logged in 67 | self.assert_200(response) 68 | self.assert_template_used("overview.html") 69 | 70 | def test_last_seen_update_after_login(self): 71 | self.register_user(self.username_alice) 72 | user = User.query.filter_by(username=self.username_alice).first() 73 | before = user.last_seen 74 | self.login_user(self.username_alice) 75 | after = user.last_seen 76 | self.assertNotEqual(before, after) 77 | 78 | def test_register_and_login_and_logout(self): 79 | # register a new account 80 | response = self.register_user(self.username_alice) 81 | # expect redirect to login 82 | self.assert_redirects(response, "/auth/login") 83 | 84 | # login with the new account 85 | response = self.login_user(self.username_alice) 86 | # expect redirect to index 87 | self.assert_redirects(response, "/") 88 | 89 | # logout 90 | response = self.client.get(url_for("auth.logout"), follow_redirects=True) 91 | # follow redirect to index 92 | self.assert_200(response) 93 | self.assert_template_used("index.html") 94 | -------------------------------------------------------------------------------- /todolist.py: -------------------------------------------------------------------------------- 1 | from app import create_app 2 | 3 | app = create_app("development") 4 | 5 | 6 | @app.cli.command() 7 | def test(): 8 | """Runs the unit tests.""" 9 | import sys 10 | import unittest 11 | 12 | tests = unittest.TestLoader().discover("tests") 13 | result = unittest.TextTestRunner(verbosity=2).run(tests) 14 | if result.errors or result.failures: 15 | sys.exit(1) 16 | 17 | 18 | @app.cli.command() 19 | def fill_db(): 20 | """Fills database with random data. 21 | By default 10 users, 40 todolists and 160 todos. 22 | WARNING: will delete existing data. For testing purposes only. 23 | """ 24 | from utils.fake_generator import FakeGenerator 25 | 26 | FakeGenerator().start() # side effect: deletes existing data 27 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wissen-snake/todo-list-flask/96769ad0d7e3db6c2defeab54d16de54e0966423/utils/__init__.py -------------------------------------------------------------------------------- /utils/fake_generator.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import datetime 3 | 4 | import forgery_py 5 | 6 | from app import db 7 | from app.models import Todo, TodoList, User 8 | 9 | 10 | class FakeGenerator: 11 | def __init__(self): 12 | # in case the tables haven't been created already 13 | db.drop_all() 14 | db.create_all() 15 | 16 | def generate_fake_date(self): 17 | return datetime.combine(forgery_py.date.date(True), datetime.utcnow().time()) 18 | 19 | def generate_fake_users(self, count): 20 | for _ in range(count): 21 | User( 22 | email=forgery_py.internet.email_address(), 23 | username=forgery_py.internet.user_name(True), 24 | password="correcthorsebatterystaple", 25 | member_since=self.generate_fake_date(), 26 | ).save() 27 | 28 | def generate_fake_todolists(self, count): 29 | # for the creator relation we need users 30 | users = User.query.all() 31 | assert users != [] 32 | for _ in range(count): 33 | TodoList( 34 | title=forgery_py.forgery.lorem_ipsum.title(), 35 | creator=random.choice(users).username, 36 | created_at=self.generate_fake_date(), 37 | ).save() 38 | 39 | def generate_fake_todo(self, count): 40 | # for the todolist relation we need todolists 41 | todolists = TodoList.query.all() 42 | assert todolists != [] 43 | for _ in range(count): 44 | todolist = random.choice(todolists) 45 | todo = Todo( 46 | description=forgery_py.forgery.lorem_ipsum.words(), 47 | todolist_id=todolist.id, 48 | creator=todolist.creator, 49 | created_at=self.generate_fake_date(), 50 | ).save() 51 | if random.choice([True, False]): 52 | todo.finished() 53 | 54 | def generate_fake_data(self, count): 55 | # generation must follow this order, as each builds on the previous 56 | self.generate_fake_users(count) 57 | self.generate_fake_todolists(count * 4) 58 | self.generate_fake_todo(count * 16) 59 | 60 | def start(self, count=10): 61 | self.generate_fake_data(count) 62 | --------------------------------------------------------------------------------