mirror of
https://github.com/yzernik/squeaknode.git
synced 2026-08-20 13:28:20 +02:00
Initial commit
This commit is contained in:
commit
cea55d63bd
23 changed files with 1011 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
venv/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
instance/
|
||||
.cache/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
6
MANIFEST.in
Normal file
6
MANIFEST.in
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
include LICENSE
|
||||
include squeakserver/schema.sql
|
||||
graft squeakserver/static
|
||||
graft squeakserver/templates
|
||||
graft tests
|
||||
global-exclude *.pyc
|
||||
77
README.rst
Normal file
77
README.rst
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
Flaskr
|
||||
======
|
||||
|
||||
The basic blog app built in the Flask `tutorial`_.
|
||||
|
||||
.. _tutorial: https://flask.palletsprojects.com/tutorial/
|
||||
|
||||
|
||||
Install
|
||||
-------
|
||||
|
||||
**Be sure to use the same version of the code as the version of the docs
|
||||
you're reading.** You probably want the latest tagged version, but the
|
||||
default Git version is the master branch. ::
|
||||
|
||||
# clone the repository
|
||||
$ git clone https://github.com/pallets/flask
|
||||
$ cd flask
|
||||
# checkout the correct version
|
||||
$ git tag # shows the tagged versions
|
||||
$ git checkout latest-tag-found-above
|
||||
$ cd examples/tutorial
|
||||
|
||||
Create a virtualenv and activate it::
|
||||
|
||||
$ python3 -m venv venv
|
||||
$ . venv/bin/activate
|
||||
|
||||
Or on Windows cmd::
|
||||
|
||||
$ py -3 -m venv venv
|
||||
$ venv\Scripts\activate.bat
|
||||
|
||||
Install Flaskr::
|
||||
|
||||
$ pip install -e .
|
||||
|
||||
Or if you are using the master branch, install Flask from source before
|
||||
installing Flaskr::
|
||||
|
||||
$ pip install -e ../..
|
||||
$ pip install -e .
|
||||
|
||||
|
||||
Run
|
||||
---
|
||||
|
||||
::
|
||||
|
||||
$ export FLASK_APP=squeakserver
|
||||
$ export FLASK_ENV=development
|
||||
$ flask init-db
|
||||
$ flask run
|
||||
|
||||
Or on Windows cmd::
|
||||
|
||||
> set FLASK_APP=squeakserver
|
||||
> set FLASK_ENV=development
|
||||
> flask init-db
|
||||
> flask run
|
||||
|
||||
Open http://127.0.0.1:5000 in a browser.
|
||||
|
||||
|
||||
Test
|
||||
----
|
||||
|
||||
::
|
||||
|
||||
$ pip install '.[test]'
|
||||
$ pytest
|
||||
|
||||
Run with coverage report::
|
||||
|
||||
$ coverage run -m pytest
|
||||
$ coverage report
|
||||
$ coverage html # open htmlcov/index.html in a browser
|
||||
13
setup.cfg
Normal file
13
setup.cfg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[metadata]
|
||||
license_file = LICENSE
|
||||
|
||||
[bdist_wheel]
|
||||
universal = True
|
||||
|
||||
[tool:pytest]
|
||||
testpaths = tests
|
||||
|
||||
[coverage:run]
|
||||
branch = True
|
||||
source =
|
||||
squeakserver
|
||||
23
setup.py
Normal file
23
setup.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import io
|
||||
|
||||
from setuptools import find_packages
|
||||
from setuptools import setup
|
||||
|
||||
with io.open("README.rst", "rt", encoding="utf8") as f:
|
||||
readme = f.read()
|
||||
|
||||
setup(
|
||||
name="squeakserver",
|
||||
version="1.0.0",
|
||||
url="https://flask.palletsprojects.com/tutorial/",
|
||||
license="BSD",
|
||||
maintainer="Pallets team",
|
||||
maintainer_email="contact@palletsprojects.com",
|
||||
description="The basic blog app built in the Flask tutorial.",
|
||||
long_description=readme,
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
install_requires=["flask"],
|
||||
extras_require={"test": ["pytest", "coverage"]},
|
||||
)
|
||||
50
squeakserver/__init__.py
Normal file
50
squeakserver/__init__.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import os
|
||||
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def create_app(test_config=None):
|
||||
"""Create and configure an instance of the Flask application."""
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
app.config.from_mapping(
|
||||
# a default secret that should be overridden by instance config
|
||||
SECRET_KEY="dev",
|
||||
# store the database in the instance folder
|
||||
DATABASE=os.path.join(app.instance_path, "squeakserver.sqlite"),
|
||||
)
|
||||
|
||||
if test_config is None:
|
||||
# load the instance config, if it exists, when not testing
|
||||
app.config.from_pyfile("config.py", silent=True)
|
||||
else:
|
||||
# load the test config if passed in
|
||||
app.config.update(test_config)
|
||||
|
||||
# ensure the instance folder exists
|
||||
try:
|
||||
os.makedirs(app.instance_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@app.route("/hello")
|
||||
def hello():
|
||||
return "Hello, World!"
|
||||
|
||||
# register the database commands
|
||||
from squeakserver import db
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
# apply the blueprints to the app
|
||||
from squeakserver import auth, blog
|
||||
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(blog.bp)
|
||||
|
||||
# make url_for('index') == url_for('blog.index')
|
||||
# in another app, you might define a separate main index here with
|
||||
# app.route, while giving the blog blueprint a url_prefix, but for
|
||||
# the tutorial the blog will be the main index
|
||||
app.add_url_rule("/", endpoint="index")
|
||||
|
||||
return app
|
||||
116
squeakserver/auth.py
Normal file
116
squeakserver/auth.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import functools
|
||||
|
||||
from flask import Blueprint
|
||||
from flask import flash
|
||||
from flask import g
|
||||
from flask import redirect
|
||||
from flask import render_template
|
||||
from flask import request
|
||||
from flask import session
|
||||
from flask import url_for
|
||||
from werkzeug.security import check_password_hash
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from squeakserver.db import get_db
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
|
||||
|
||||
def login_required(view):
|
||||
"""View decorator that redirects anonymous users to the login page."""
|
||||
|
||||
@functools.wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
if g.user is None:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
return view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
|
||||
@bp.before_app_request
|
||||
def load_logged_in_user():
|
||||
"""If a user id is stored in the session, load the user object from
|
||||
the database into ``g.user``."""
|
||||
user_id = session.get("user_id")
|
||||
|
||||
if user_id is None:
|
||||
g.user = None
|
||||
else:
|
||||
g.user = (
|
||||
get_db().execute("SELECT * FROM user WHERE id = ?", (user_id,)).fetchone()
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/register", methods=("GET", "POST"))
|
||||
def register():
|
||||
"""Register a new user.
|
||||
|
||||
Validates that the username is not already taken. Hashes the
|
||||
password for security.
|
||||
"""
|
||||
if request.method == "POST":
|
||||
username = request.form["username"]
|
||||
password = request.form["password"]
|
||||
db = get_db()
|
||||
error = None
|
||||
|
||||
if not username:
|
||||
error = "Username is required."
|
||||
elif not password:
|
||||
error = "Password is required."
|
||||
elif (
|
||||
db.execute("SELECT id FROM user WHERE username = ?", (username,)).fetchone()
|
||||
is not None
|
||||
):
|
||||
error = "User {0} is already registered.".format(username)
|
||||
|
||||
if error is None:
|
||||
# the name is available, store it in the database and go to
|
||||
# the login page
|
||||
db.execute(
|
||||
"INSERT INTO user (username, password) VALUES (?, ?)",
|
||||
(username, generate_password_hash(password)),
|
||||
)
|
||||
db.commit()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
flash(error)
|
||||
|
||||
return render_template("auth/register.html")
|
||||
|
||||
|
||||
@bp.route("/login", methods=("GET", "POST"))
|
||||
def login():
|
||||
"""Log in a registered user by adding the user id to the session."""
|
||||
if request.method == "POST":
|
||||
username = request.form["username"]
|
||||
password = request.form["password"]
|
||||
db = get_db()
|
||||
error = None
|
||||
user = db.execute(
|
||||
"SELECT * FROM user WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
|
||||
if user is None:
|
||||
error = "Incorrect username."
|
||||
elif not check_password_hash(user["password"], password):
|
||||
error = "Incorrect password."
|
||||
|
||||
if error is None:
|
||||
# store the user id in a new session and return to the index
|
||||
session.clear()
|
||||
session["user_id"] = user["id"]
|
||||
return redirect(url_for("index"))
|
||||
|
||||
flash(error)
|
||||
|
||||
return render_template("auth/login.html")
|
||||
|
||||
|
||||
@bp.route("/logout")
|
||||
def logout():
|
||||
"""Clear the current session, including the stored user id."""
|
||||
session.clear()
|
||||
return redirect(url_for("index"))
|
||||
125
squeakserver/blog.py
Normal file
125
squeakserver/blog.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from flask import Blueprint
|
||||
from flask import flash
|
||||
from flask import g
|
||||
from flask import redirect
|
||||
from flask import render_template
|
||||
from flask import request
|
||||
from flask import url_for
|
||||
from werkzeug.exceptions import abort
|
||||
|
||||
from squeakserver.auth import login_required
|
||||
from squeakserver.db import get_db
|
||||
|
||||
bp = Blueprint("blog", __name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def index():
|
||||
"""Show all the posts, most recent first."""
|
||||
db = get_db()
|
||||
posts = db.execute(
|
||||
"SELECT p.id, title, body, created, author_id, username"
|
||||
" FROM post p JOIN user u ON p.author_id = u.id"
|
||||
" ORDER BY created DESC"
|
||||
).fetchall()
|
||||
return render_template("blog/index.html", posts=posts)
|
||||
|
||||
|
||||
def get_post(id, check_author=True):
|
||||
"""Get a post and its author by id.
|
||||
|
||||
Checks that the id exists and optionally that the current user is
|
||||
the author.
|
||||
|
||||
:param id: id of post to get
|
||||
:param check_author: require the current user to be the author
|
||||
:return: the post with author information
|
||||
:raise 404: if a post with the given id doesn't exist
|
||||
:raise 403: if the current user isn't the author
|
||||
"""
|
||||
post = (
|
||||
get_db()
|
||||
.execute(
|
||||
"SELECT p.id, title, body, created, author_id, username"
|
||||
" FROM post p JOIN user u ON p.author_id = u.id"
|
||||
" WHERE p.id = ?",
|
||||
(id,),
|
||||
)
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
if post is None:
|
||||
abort(404, "Post id {0} doesn't exist.".format(id))
|
||||
|
||||
if check_author and post["author_id"] != g.user["id"]:
|
||||
abort(403)
|
||||
|
||||
return post
|
||||
|
||||
|
||||
@bp.route("/create", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def create():
|
||||
"""Create a new post for the current user."""
|
||||
if request.method == "POST":
|
||||
title = request.form["title"]
|
||||
body = request.form["body"]
|
||||
error = None
|
||||
|
||||
if not title:
|
||||
error = "Title is required."
|
||||
|
||||
if error is not None:
|
||||
flash(error)
|
||||
else:
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO post (title, body, author_id) VALUES (?, ?, ?)",
|
||||
(title, body, g.user["id"]),
|
||||
)
|
||||
db.commit()
|
||||
return redirect(url_for("blog.index"))
|
||||
|
||||
return render_template("blog/create.html")
|
||||
|
||||
|
||||
@bp.route("/<int:id>/update", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def update(id):
|
||||
"""Update a post if the current user is the author."""
|
||||
post = get_post(id)
|
||||
|
||||
if request.method == "POST":
|
||||
title = request.form["title"]
|
||||
body = request.form["body"]
|
||||
error = None
|
||||
|
||||
if not title:
|
||||
error = "Title is required."
|
||||
|
||||
if error is not None:
|
||||
flash(error)
|
||||
else:
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE post SET title = ?, body = ? WHERE id = ?", (title, body, id)
|
||||
)
|
||||
db.commit()
|
||||
return redirect(url_for("blog.index"))
|
||||
|
||||
return render_template("blog/update.html", post=post)
|
||||
|
||||
|
||||
@bp.route("/<int:id>/delete", methods=("POST",))
|
||||
@login_required
|
||||
def delete(id):
|
||||
"""Delete a post.
|
||||
|
||||
Ensures that the post exists and that the logged in user is the
|
||||
author of the post.
|
||||
"""
|
||||
get_post(id)
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM post WHERE id = ?", (id,))
|
||||
db.commit()
|
||||
return redirect(url_for("blog.index"))
|
||||
54
squeakserver/db.py
Normal file
54
squeakserver/db.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import sqlite3
|
||||
|
||||
import click
|
||||
from flask import current_app
|
||||
from flask import g
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Connect to the application's configured database. The connection
|
||||
is unique for each request and will be reused if this is called
|
||||
again.
|
||||
"""
|
||||
if "db" not in g:
|
||||
g.db = sqlite3.connect(
|
||||
current_app.config["DATABASE"], detect_types=sqlite3.PARSE_DECLTYPES
|
||||
)
|
||||
g.db.row_factory = sqlite3.Row
|
||||
|
||||
return g.db
|
||||
|
||||
|
||||
def close_db(e=None):
|
||||
"""If this request connected to the database, close the
|
||||
connection.
|
||||
"""
|
||||
db = g.pop("db", None)
|
||||
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Clear existing data and create new tables."""
|
||||
db = get_db()
|
||||
|
||||
with current_app.open_resource("schema.sql") as f:
|
||||
db.executescript(f.read().decode("utf8"))
|
||||
|
||||
|
||||
@click.command("init-db")
|
||||
@with_appcontext
|
||||
def init_db_command():
|
||||
"""Clear existing data and create new tables."""
|
||||
init_db()
|
||||
click.echo("Initialized the database.")
|
||||
|
||||
|
||||
def init_app(app):
|
||||
"""Register database functions with the Flask app. This is called by
|
||||
the application factory.
|
||||
"""
|
||||
app.teardown_appcontext(close_db)
|
||||
app.cli.add_command(init_db_command)
|
||||
20
squeakserver/schema.sql
Normal file
20
squeakserver/schema.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- Initialize the database.
|
||||
-- Drop any existing data and create empty tables.
|
||||
|
||||
DROP TABLE IF EXISTS user;
|
||||
DROP TABLE IF EXISTS post;
|
||||
|
||||
CREATE TABLE user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE post (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
author_id INTEGER NOT NULL,
|
||||
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
FOREIGN KEY (author_id) REFERENCES user (id)
|
||||
);
|
||||
134
squeakserver/static/style.css
Normal file
134
squeakserver/static/style.css
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
html {
|
||||
font-family: sans-serif;
|
||||
background: #eee;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: serif;
|
||||
color: #377ba8;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #377ba8;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid lightgray;
|
||||
}
|
||||
|
||||
nav {
|
||||
background: lightgray;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
nav h1 {
|
||||
flex: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
nav h1 a {
|
||||
text-decoration: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav ul li a, nav ul li span, header .action {
|
||||
display: block;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
|
||||
.content > header {
|
||||
border-bottom: 1px solid lightgray;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.content > header h1 {
|
||||
flex: auto;
|
||||
margin: 1rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.flash {
|
||||
margin: 1em 0;
|
||||
padding: 1em;
|
||||
background: #cae6f6;
|
||||
border: 1px solid #377ba8;
|
||||
}
|
||||
|
||||
.post > header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.post > header > div:first-of-type {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.post > header h1 {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.post .about {
|
||||
color: slategray;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.post .body {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.content:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.content form {
|
||||
margin: 1em 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.content input, .content textarea {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.content textarea {
|
||||
min-height: 12em;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input.danger {
|
||||
color: #cc2f2e;
|
||||
}
|
||||
|
||||
input[type=submit] {
|
||||
align-self: start;
|
||||
min-width: 10em;
|
||||
}
|
||||
15
squeakserver/templates/auth/login.html
Normal file
15
squeakserver/templates/auth/login.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block header %}
|
||||
<h1>{% block title %}Log In{% endblock %}</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
<label for="username">Username</label>
|
||||
<input name="username" id="username" required>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" id="password" required>
|
||||
<input type="submit" value="Log In">
|
||||
</form>
|
||||
{% endblock %}
|
||||
15
squeakserver/templates/auth/register.html
Normal file
15
squeakserver/templates/auth/register.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block header %}
|
||||
<h1>{% block title %}Register{% endblock %}</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
<label for="username">Username</label>
|
||||
<input name="username" id="username" required>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" id="password" required>
|
||||
<input type="submit" value="Register">
|
||||
</form>
|
||||
{% endblock %}
|
||||
24
squeakserver/templates/base.html
Normal file
24
squeakserver/templates/base.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<!doctype html>
|
||||
<title>{% block title %}{% endblock %} - Flaskr</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<nav>
|
||||
<h1><a href="{{ url_for('index') }}">Flaskr</a></h1>
|
||||
<ul>
|
||||
{% if g.user %}
|
||||
<li><span>{{ g.user['username'] }}</span>
|
||||
<li><a href="{{ url_for('auth.logout') }}">Log Out</a>
|
||||
{% else %}
|
||||
<li><a href="{{ url_for('auth.register') }}">Register</a>
|
||||
<li><a href="{{ url_for('auth.login') }}">Log In</a>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
<section class="content">
|
||||
<header>
|
||||
{% block header %}{% endblock %}
|
||||
</header>
|
||||
{% for message in get_flashed_messages() %}
|
||||
<div class="flash">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% block content %}{% endblock %}
|
||||
</section>
|
||||
15
squeakserver/templates/blog/create.html
Normal file
15
squeakserver/templates/blog/create.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block header %}
|
||||
<h1>{% block title %}New Post{% endblock %}</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
<label for="title">Title</label>
|
||||
<input name="title" id="title" value="{{ request.form['title'] }}" required>
|
||||
<label for="body">Body</label>
|
||||
<textarea name="body" id="body">{{ request.form['body'] }}</textarea>
|
||||
<input type="submit" value="Save">
|
||||
</form>
|
||||
{% endblock %}
|
||||
28
squeakserver/templates/blog/index.html
Normal file
28
squeakserver/templates/blog/index.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block header %}
|
||||
<h1>{% block title %}Posts{% endblock %}</h1>
|
||||
{% if g.user %}
|
||||
<a class="action" href="{{ url_for('blog.create') }}">New</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% for post in posts %}
|
||||
<article class="post">
|
||||
<header>
|
||||
<div>
|
||||
<h1>{{ post['title'] }}</h1>
|
||||
<div class="about">by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}</div>
|
||||
</div>
|
||||
{% if g.user['id'] == post['author_id'] %}
|
||||
<a class="action" href="{{ url_for('blog.update', id=post['id']) }}">Edit</a>
|
||||
{% endif %}
|
||||
</header>
|
||||
<p class="body">{{ post['body'] }}</p>
|
||||
</article>
|
||||
{% if not loop.last %}
|
||||
<hr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
19
squeakserver/templates/blog/update.html
Normal file
19
squeakserver/templates/blog/update.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block header %}
|
||||
<h1>{% block title %}Edit "{{ post['title'] }}"{% endblock %}</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
<label for="title">Title</label>
|
||||
<input name="title" id="title" value="{{ request.form['title'] or post['title'] }}" required>
|
||||
<label for="body">Body</label>
|
||||
<textarea name="body" id="body">{{ request.form['body'] or post['body'] }}</textarea>
|
||||
<input type="submit" value="Save">
|
||||
</form>
|
||||
<hr>
|
||||
<form action="{{ url_for('blog.delete', id=post['id']) }}" method="post">
|
||||
<input class="danger" type="submit" value="Delete" onclick="return confirm('Are you sure?');">
|
||||
</form>
|
||||
{% endblock %}
|
||||
62
tests/conftest.py
Normal file
62
tests/conftest.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from squeakserver import create_app
|
||||
from squeakserver.db import get_db
|
||||
from squeakserver.db import init_db
|
||||
|
||||
# read in SQL for populating test data
|
||||
with open(os.path.join(os.path.dirname(__file__), "data.sql"), "rb") as f:
|
||||
_data_sql = f.read().decode("utf8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create and configure a new app instance for each test."""
|
||||
# create a temporary file to isolate the database for each test
|
||||
db_fd, db_path = tempfile.mkstemp()
|
||||
# create the app with common test config
|
||||
app = create_app({"TESTING": True, "DATABASE": db_path})
|
||||
|
||||
# create the database and load test data
|
||||
with app.app_context():
|
||||
init_db()
|
||||
get_db().executescript(_data_sql)
|
||||
|
||||
yield app
|
||||
|
||||
# close and remove the temporary database
|
||||
os.close(db_fd)
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""A test client for the app."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(app):
|
||||
"""A test runner for the app's Click commands."""
|
||||
return app.test_cli_runner()
|
||||
|
||||
|
||||
class AuthActions(object):
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def login(self, username="test", password="test"):
|
||||
return self._client.post(
|
||||
"/auth/login", data={"username": username, "password": password}
|
||||
)
|
||||
|
||||
def logout(self):
|
||||
return self._client.get("/auth/logout")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth(client):
|
||||
return AuthActions(client)
|
||||
8
tests/data.sql
Normal file
8
tests/data.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
INSERT INTO user (username, password)
|
||||
VALUES
|
||||
('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
|
||||
('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');
|
||||
|
||||
INSERT INTO post (title, body, author_id, created)
|
||||
VALUES
|
||||
('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');
|
||||
69
tests/test_auth.py
Normal file
69
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import pytest
|
||||
from flask import g
|
||||
from flask import session
|
||||
|
||||
from squeakserver.db import get_db
|
||||
|
||||
|
||||
def test_register(client, app):
|
||||
# test that viewing the page renders without template errors
|
||||
assert client.get("/auth/register").status_code == 200
|
||||
|
||||
# test that successful registration redirects to the login page
|
||||
response = client.post("/auth/register", data={"username": "a", "password": "a"})
|
||||
assert "http://localhost/auth/login" == response.headers["Location"]
|
||||
|
||||
# test that the user was inserted into the database
|
||||
with app.app_context():
|
||||
assert (
|
||||
get_db().execute("select * from user where username = 'a'").fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("username", "password", "message"),
|
||||
(
|
||||
("", "", b"Username is required."),
|
||||
("a", "", b"Password is required."),
|
||||
("test", "test", b"already registered"),
|
||||
),
|
||||
)
|
||||
def test_register_validate_input(client, username, password, message):
|
||||
response = client.post(
|
||||
"/auth/register", data={"username": username, "password": password}
|
||||
)
|
||||
assert message in response.data
|
||||
|
||||
|
||||
def test_login(client, auth):
|
||||
# test that viewing the page renders without template errors
|
||||
assert client.get("/auth/login").status_code == 200
|
||||
|
||||
# test that successful login redirects to the index page
|
||||
response = auth.login()
|
||||
assert response.headers["Location"] == "http://localhost/"
|
||||
|
||||
# login request set the user_id in the session
|
||||
# check that the user is loaded from the session
|
||||
with client:
|
||||
client.get("/")
|
||||
assert session["user_id"] == 1
|
||||
assert g.user["username"] == "test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("username", "password", "message"),
|
||||
(("a", "test", b"Incorrect username."), ("test", "a", b"Incorrect password.")),
|
||||
)
|
||||
def test_login_validate_input(auth, username, password, message):
|
||||
response = auth.login(username, password)
|
||||
assert message in response.data
|
||||
|
||||
|
||||
def test_logout(client, auth):
|
||||
auth.login()
|
||||
|
||||
with client:
|
||||
auth.logout()
|
||||
assert "user_id" not in session
|
||||
83
tests/test_blog.py
Normal file
83
tests/test_blog.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import pytest
|
||||
|
||||
from squeakserver.db import get_db
|
||||
|
||||
|
||||
def test_index(client, auth):
|
||||
response = client.get("/")
|
||||
assert b"Log In" in response.data
|
||||
assert b"Register" in response.data
|
||||
|
||||
auth.login()
|
||||
response = client.get("/")
|
||||
assert b"test title" in response.data
|
||||
assert b"by test on 2018-01-01" in response.data
|
||||
assert b"test\nbody" in response.data
|
||||
assert b'href="/1/update"' in response.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ("/create", "/1/update", "/1/delete"))
|
||||
def test_login_required(client, path):
|
||||
response = client.post(path)
|
||||
assert response.headers["Location"] == "http://localhost/auth/login"
|
||||
|
||||
|
||||
def test_author_required(app, client, auth):
|
||||
# change the post author to another user
|
||||
with app.app_context():
|
||||
db = get_db()
|
||||
db.execute("UPDATE post SET author_id = 2 WHERE id = 1")
|
||||
db.commit()
|
||||
|
||||
auth.login()
|
||||
# current user can't modify other user's post
|
||||
assert client.post("/1/update").status_code == 403
|
||||
assert client.post("/1/delete").status_code == 403
|
||||
# current user doesn't see edit link
|
||||
assert b'href="/1/update"' not in client.get("/").data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ("/2/update", "/2/delete"))
|
||||
def test_exists_required(client, auth, path):
|
||||
auth.login()
|
||||
assert client.post(path).status_code == 404
|
||||
|
||||
|
||||
def test_create(client, auth, app):
|
||||
auth.login()
|
||||
assert client.get("/create").status_code == 200
|
||||
client.post("/create", data={"title": "created", "body": ""})
|
||||
|
||||
with app.app_context():
|
||||
db = get_db()
|
||||
count = db.execute("SELECT COUNT(id) FROM post").fetchone()[0]
|
||||
assert count == 2
|
||||
|
||||
|
||||
def test_update(client, auth, app):
|
||||
auth.login()
|
||||
assert client.get("/1/update").status_code == 200
|
||||
client.post("/1/update", data={"title": "updated", "body": ""})
|
||||
|
||||
with app.app_context():
|
||||
db = get_db()
|
||||
post = db.execute("SELECT * FROM post WHERE id = 1").fetchone()
|
||||
assert post["title"] == "updated"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ("/create", "/1/update"))
|
||||
def test_create_update_validate(client, auth, path):
|
||||
auth.login()
|
||||
response = client.post(path, data={"title": "", "body": ""})
|
||||
assert b"Title is required." in response.data
|
||||
|
||||
|
||||
def test_delete(client, auth, app):
|
||||
auth.login()
|
||||
response = client.post("/1/delete")
|
||||
assert response.headers["Location"] == "http://localhost/"
|
||||
|
||||
with app.app_context():
|
||||
db = get_db()
|
||||
post = db.execute("SELECT * FROM post WHERE id = 1").fetchone()
|
||||
assert post is None
|
||||
29
tests/test_db.py
Normal file
29
tests/test_db.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from squeakserver.db import get_db
|
||||
|
||||
|
||||
def test_get_close_db(app):
|
||||
with app.app_context():
|
||||
db = get_db()
|
||||
assert db is get_db()
|
||||
|
||||
with pytest.raises(sqlite3.ProgrammingError) as e:
|
||||
db.execute("SELECT 1")
|
||||
|
||||
assert "closed" in str(e.value)
|
||||
|
||||
|
||||
def test_init_db_command(runner, monkeypatch):
|
||||
class Recorder(object):
|
||||
called = False
|
||||
|
||||
def fake_init_db():
|
||||
Recorder.called = True
|
||||
|
||||
monkeypatch.setattr("squeakserver.db.init_db", fake_init_db)
|
||||
result = runner.invoke(args=["init-db"])
|
||||
assert "Initialized" in result.output
|
||||
assert Recorder.called
|
||||
12
tests/test_factory.py
Normal file
12
tests/test_factory.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from squeakserver import create_app
|
||||
|
||||
|
||||
def test_config():
|
||||
"""Test create_app without passing test config."""
|
||||
assert not create_app().testing
|
||||
assert create_app({"TESTING": True}).testing
|
||||
|
||||
|
||||
def test_hello(client):
|
||||
response = client.get("/hello")
|
||||
assert response.data == b"Hello, World!"
|
||||
Loading…
Add table
Add a link
Reference in a new issue