Minimum viable product
This commit is contained in:
commit
40e347616f
24 changed files with 748 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
instance/
|
||||||
|
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
4
MANIFEST.in
Normal file
4
MANIFEST.in
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
include flaskr/schema.sql
|
||||||
|
graft flaskr/static
|
||||||
|
graft flaskr/templates
|
||||||
|
global-exclude *.pyc
|
||||||
40
flaskr/__init__.py
Normal file
40
flaskr/__init__.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import os
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
def create_app(test_config=None):
|
||||||
|
# create and configure the app
|
||||||
|
app = Flask(__name__,instance_relative_config=True)
|
||||||
|
app.config.from_mapping(
|
||||||
|
SECRET_KEY='dev',
|
||||||
|
DATABASE=os.path.join(app.instance_path, 'flaskr.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.from_mapping(test_config)
|
||||||
|
|
||||||
|
# ensure the instance folder exists
|
||||||
|
try:
|
||||||
|
os.makedirs(app.instance_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# a simple page that says hello
|
||||||
|
@app.route('/hello')
|
||||||
|
def hello():
|
||||||
|
return 'Hello, World!'
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
from . import auth
|
||||||
|
app.register_blueprint(auth.bp)
|
||||||
|
|
||||||
|
from . import blog
|
||||||
|
app.register_blueprint(blog.bp)
|
||||||
|
app.add_url_rule('/', endpoint='index')
|
||||||
|
|
||||||
|
return app
|
||||||
90
flaskr/auth.py
Normal file
90
flaskr/auth.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint, flash, g, redirect, render_template, request, session, url_for
|
||||||
|
)
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
from flaskr.db import get_db
|
||||||
|
|
||||||
|
bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||||
|
|
||||||
|
@bp.route('/register', methods=('GET', 'POST'))
|
||||||
|
def register():
|
||||||
|
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."
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO user (username, password) VALUES (?, ?)",
|
||||||
|
(username, generate_password_hash(password)),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except db.IntegrityError:
|
||||||
|
error = f"User {username} is already registered."
|
||||||
|
else:
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
flash(error)
|
||||||
|
|
||||||
|
return render_template('auth/register.html')
|
||||||
|
|
||||||
|
@bp.route('/login', methods=('GET', 'POST'))
|
||||||
|
def login():
|
||||||
|
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:
|
||||||
|
session.clear()
|
||||||
|
session['user_id'] = user['id']
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
flash(error)
|
||||||
|
|
||||||
|
return render_template('auth/login.html')
|
||||||
|
|
||||||
|
@bp.before_app_request
|
||||||
|
def load_logged_in_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('/logout')
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
def login_required(view):
|
||||||
|
@functools.wraps(view)
|
||||||
|
def wrapped_view(**kwargs):
|
||||||
|
if g.user is None:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
return view(**kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
103
flaskr/blog.py
Normal file
103
flaskr/blog.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
from flask import (
|
||||||
|
Blueprint, flash, g, redirect, render_template, request, url_for
|
||||||
|
)
|
||||||
|
from werkzeug.exceptions import abort
|
||||||
|
|
||||||
|
from flaskr.auth import login_required
|
||||||
|
from flaskr.db import get_db
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
bp = Blueprint('blog', __name__)
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
def index():
|
||||||
|
db = get_db()
|
||||||
|
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()
|
||||||
|
posts = []
|
||||||
|
for post in db_posts:
|
||||||
|
post = dict(post)
|
||||||
|
post['body'] = markdown.markdown(post['body'])
|
||||||
|
posts.append(post)
|
||||||
|
return render_template('blog/index.html', posts=posts)
|
||||||
|
|
||||||
|
@bp.route('/create',methods=('GET', 'POST'))
|
||||||
|
@login_required
|
||||||
|
def create():
|
||||||
|
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')
|
||||||
|
|
||||||
|
def get_post(id, check_author=True):
|
||||||
|
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, f"Post id {id} doesn't exist.")
|
||||||
|
|
||||||
|
if check_author and post['author_id'] != g.user['id']:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
@bp.route('/<int:id>/update', methods=('GET', 'POST'))
|
||||||
|
@login_required
|
||||||
|
def update(id):
|
||||||
|
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):
|
||||||
|
get_post(id)
|
||||||
|
db = get_db()
|
||||||
|
db.execute('DELETE FROM post WHERE id = ?',(id,))
|
||||||
|
db.commit()
|
||||||
|
return redirect(url_for('blog.index'))
|
||||||
35
flaskr/db.py
Normal file
35
flaskr/db.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import click
|
||||||
|
from flask import current_app, g
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
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):
|
||||||
|
db = g.pop('db', None)
|
||||||
|
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
def init_db():
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
with current_app.open_resource('schema.sql') as f:
|
||||||
|
db.executescript(f.read().decode('utf-8'))
|
||||||
|
|
||||||
|
@click.command('init-db')
|
||||||
|
def init_db_command():
|
||||||
|
"""Clear the existing data and create new tables."""
|
||||||
|
init_db()
|
||||||
|
click.echo("Initialized the database.")
|
||||||
|
|
||||||
|
def init_app(app):
|
||||||
|
app.teardown_appcontext(close_db)
|
||||||
|
app.cli.add_command(init_db_command)
|
||||||
17
flaskr/schema.sql
Normal file
17
flaskr/schema.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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)
|
||||||
|
);
|
||||||
BIN
flaskr/static/favicon.png
Normal file
BIN
flaskr/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 B |
26
flaskr/static/style.css
Normal file
26
flaskr/static/style.css
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
html { font-family: sans-serif; background: #eee; padding: 1rem; }
|
||||||
|
body { max-width: 960px; margin: 0 auto; background: white; }
|
||||||
|
h1 { 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
flaskr/templates/auth/login.html
Normal file
15
flaskr/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
flaskr/templates/auth/register.html
Normal file
15
flaskr/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 %}
|
||||||
25
flaskr/templates/base.html
Normal file
25
flaskr/templates/base.html
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<!doctype html>
|
||||||
|
<title>{% block title %}{% endblock %} - Flaskr</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
|
||||||
|
<nav>
|
||||||
|
<h1>Flaskr</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
flaskr/templates/blog/create.html
Normal file
15
flaskr/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
flaskr/templates/blog/index.html
Normal file
28
flaskr/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']|safe }}</p>
|
||||||
|
</article>
|
||||||
|
{% if not loop.last %}
|
||||||
|
<hr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
20
flaskr/templates/blog/update.html
Normal file
20
flaskr/templates/blog/update.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% 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 %}
|
||||||
7
flaskr/templates/hello.html
Normal file
7
flaskr/templates/hello.html
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<!doctype html>
|
||||||
|
<title>Hello from Flask</title>
|
||||||
|
{% if name %}
|
||||||
|
<h1>Hello {{ name }}!</h1>
|
||||||
|
{% else %}
|
||||||
|
<h1>Hello, World!</h1>
|
||||||
|
{% endif %}
|
||||||
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[project]
|
||||||
|
name = "flaskr"
|
||||||
|
version = "1.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"flask",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
exclude = ["instance"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
branch = true
|
||||||
|
source = ["flaskr"]
|
||||||
42
requirements.txt
Normal file
42
requirements.txt
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
anyio==3.6.2
|
||||||
|
blinker==1.6.2
|
||||||
|
build==0.10.0
|
||||||
|
certifi==2023.5.7
|
||||||
|
click==8.1.3
|
||||||
|
coverage==7.2.7
|
||||||
|
dnspython==2.3.0
|
||||||
|
email-validator==2.0.0.post2
|
||||||
|
exceptiongroup==1.1.1
|
||||||
|
fastapi==0.95.1
|
||||||
|
Flask==2.3.2
|
||||||
|
# Editable install with no version control (flaskr==1.0.0)
|
||||||
|
-e /root/blog-dev
|
||||||
|
h11==0.14.0
|
||||||
|
httpcore==0.17.0
|
||||||
|
httptools==0.5.0
|
||||||
|
httpx==0.24.0
|
||||||
|
idna==3.4
|
||||||
|
iniconfig==2.0.0
|
||||||
|
itsdangerous==2.1.2
|
||||||
|
Jinja2==3.1.2
|
||||||
|
Markdown==3.4.3
|
||||||
|
MarkupSafe==2.1.2
|
||||||
|
orjson==3.8.11
|
||||||
|
packaging==23.1
|
||||||
|
pluggy==1.0.0
|
||||||
|
pydantic==1.10.7
|
||||||
|
pyproject_hooks==1.0.0
|
||||||
|
pytest==7.3.2
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
PyYAML==6.0
|
||||||
|
sniffio==1.3.0
|
||||||
|
starlette==0.26.1
|
||||||
|
tomli==2.0.1
|
||||||
|
typing_extensions==4.5.0
|
||||||
|
ujson==5.7.0
|
||||||
|
uvicorn==0.22.0
|
||||||
|
uvloop==0.17.0
|
||||||
|
watchfiles==0.19.0
|
||||||
|
websockets==11.0.3
|
||||||
|
Werkzeug==2.3.3
|
||||||
52
tests/conftest.py
Normal file
52
tests/conftest.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flaskr import create_app
|
||||||
|
from flaskr.db import get_db, init_db
|
||||||
|
|
||||||
|
with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f:
|
||||||
|
_data_sql = f.read().decode('utf8')
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
db_fd, db_path = tempfile.mkstemp()
|
||||||
|
|
||||||
|
app = create_app({
|
||||||
|
'TESTING': True,
|
||||||
|
'DATABASE': db_path,
|
||||||
|
})
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
init_db()
|
||||||
|
get_db().executescript(_data_sql)
|
||||||
|
|
||||||
|
yield app
|
||||||
|
|
||||||
|
os.close(db_fd)
|
||||||
|
os.unlink(db_path)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner(app):
|
||||||
|
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)
|
||||||
7
tests/data.sql
Normal file
7
tests/data.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
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');
|
||||||
52
tests/test_auth.py
Normal file
52
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import pytest
|
||||||
|
from flask import g, session
|
||||||
|
from flaskr.db import get_db
|
||||||
|
|
||||||
|
def test_register(client, app):
|
||||||
|
assert client.get('/auth/register').status_code == 200
|
||||||
|
response = client.post(
|
||||||
|
'/auth/register', data={'username': 'a', 'password': 'a'}
|
||||||
|
)
|
||||||
|
assert response.headers["Location"] == "/auth/login"
|
||||||
|
|
||||||
|
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):
|
||||||
|
assert client.get('/auth/login').status_code == 200
|
||||||
|
response = auth.login()
|
||||||
|
assert response.headers["Location"] == "/"
|
||||||
|
|
||||||
|
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
|
||||||
85
tests/test_blog.py
Normal file
85
tests/test_blog.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import pytest
|
||||||
|
from flaskr.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'Log Out' in response.data
|
||||||
|
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"] == "/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"] == "/"
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db = get_db()
|
||||||
|
post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
|
||||||
|
assert post is None
|
||||||
26
tests/test_db.py
Normal file
26
tests/test_db.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flaskr.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():
|
||||||
|
class Recorder(object):
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def fake_init_db():
|
||||||
|
Recorder.called = True
|
||||||
|
|
||||||
|
monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
|
||||||
|
result = runner.invoke(args=['init-db'])
|
||||||
|
assert 'Initialized' in result.output
|
||||||
|
assert Recorder.called
|
||||||
10
tests/test_factory.py
Normal file
10
tests/test_factory.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from flaskr import create_app
|
||||||
|
|
||||||
|
def 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