commit 40e347616fea313968e19890c23ac4aeff8de5f1 Author: Punnamaraju Vinayaka Tejas Date: Tue Jul 11 14:09:54 2023 +0530 Minimum viable product diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d9734b --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ + +*.pyc +__pycache__/ + +instance/ + +.pytest_cache/ +.coverage +htmlcov/ + +dist/ +build/ +*.egg-info/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..055ff1e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include flaskr/schema.sql +graft flaskr/static +graft flaskr/templates +global-exclude *.pyc \ No newline at end of file diff --git a/flaskr/__init__.py b/flaskr/__init__.py new file mode 100644 index 0000000..bf080a1 --- /dev/null +++ b/flaskr/__init__.py @@ -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 diff --git a/flaskr/auth.py b/flaskr/auth.py new file mode 100644 index 0000000..e41998c --- /dev/null +++ b/flaskr/auth.py @@ -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 diff --git a/flaskr/blog.py b/flaskr/blog.py new file mode 100644 index 0000000..cfa78d8 --- /dev/null +++ b/flaskr/blog.py @@ -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('//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('//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')) diff --git a/flaskr/db.py b/flaskr/db.py new file mode 100644 index 0000000..16a9cde --- /dev/null +++ b/flaskr/db.py @@ -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) diff --git a/flaskr/schema.sql b/flaskr/schema.sql new file mode 100644 index 0000000..be76d7e --- /dev/null +++ b/flaskr/schema.sql @@ -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) +); diff --git a/flaskr/static/favicon.png b/flaskr/static/favicon.png new file mode 100644 index 0000000..910818c Binary files /dev/null and b/flaskr/static/favicon.png differ diff --git a/flaskr/static/style.css b/flaskr/static/style.css new file mode 100644 index 0000000..a45a73b --- /dev/null +++ b/flaskr/static/style.css @@ -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; } diff --git a/flaskr/templates/auth/login.html b/flaskr/templates/auth/login.html new file mode 100644 index 0000000..1cd1fe8 --- /dev/null +++ b/flaskr/templates/auth/login.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Log In{% endblock%}

+{% endblock %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/flaskr/templates/auth/register.html b/flaskr/templates/auth/register.html new file mode 100644 index 0000000..19f0365 --- /dev/null +++ b/flaskr/templates/auth/register.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Register{% endblock%}

+{% endblock %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/flaskr/templates/base.html b/flaskr/templates/base.html new file mode 100644 index 0000000..a2c26cd --- /dev/null +++ b/flaskr/templates/base.html @@ -0,0 +1,25 @@ + +{% block title %}{% endblock %} - Flaskr + + + +
+
+ {% block header %}{% endblock %} +
+ {% for message in get_flashed_messages() %} +
{{ message }}
+ {% endfor %} + {% block content %}{% endblock %} +
diff --git a/flaskr/templates/blog/create.html b/flaskr/templates/blog/create.html new file mode 100644 index 0000000..88e31e4 --- /dev/null +++ b/flaskr/templates/blog/create.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}New Post{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/flaskr/templates/blog/index.html b/flaskr/templates/blog/index.html new file mode 100644 index 0000000..ffd9fbc --- /dev/null +++ b/flaskr/templates/blog/index.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Posts{% endblock %}

+ {% if g.user %} + New + {% endif %} +{% endblock %} + +{% block content %} + {% for post in posts %} +
+
+
+

{{ post['title'] }}

+
by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}
+
+ {% if g.user['id'] == post['author_id'] %} + Edit + {% endif %} +
+

{{ post['body']|safe }}

+
+ {% if not loop.last %} +
+ {% endif %} + {% endfor %} +{% endblock %} diff --git a/flaskr/templates/blog/update.html b/flaskr/templates/blog/update.html new file mode 100644 index 0000000..af66322 --- /dev/null +++ b/flaskr/templates/blog/update.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Edit "{{ post['title'] }}"{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+
+
+ +
+{% endblock %} diff --git a/flaskr/templates/hello.html b/flaskr/templates/hello.html new file mode 100644 index 0000000..90c13fe --- /dev/null +++ b/flaskr/templates/hello.html @@ -0,0 +1,7 @@ + +Hello from Flask +{% if name %} +

Hello {{ name }}!

+{% else %} +

Hello, World!

+{% endif %} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3382fa5 --- /dev/null +++ b/pyproject.toml @@ -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"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c914d7 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2db6e29 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/data.sql b/tests/data.sql new file mode 100644 index 0000000..80a2e72 --- /dev/null +++ b/tests/data.sql @@ -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'); diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..d9b9b9e --- /dev/null +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_blog.py b/tests/test_blog.py new file mode 100644 index 0000000..2479428 --- /dev/null +++ b/tests/test_blog.py @@ -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 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..f8eca6b --- /dev/null +++ b/tests/test_db.py @@ -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 diff --git a/tests/test_factory.py b/tests/test_factory.py new file mode 100644 index 0000000..ad01a4e --- /dev/null +++ b/tests/test_factory.py @@ -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!'