From 6f3a59a315df37c12f2ecc2eab7e642417de76ce Mon Sep 17 00:00:00 2001 From: ForceScreamers Date: Mon, 14 Jul 2025 09:13:34 +0300 Subject: [PATCH 1/5] Added python code --- LICENSE | 31 +++++++ MANIFEST.in | 6 ++ README.md | 1 + README.rst | 77 ++++++++++++++++ codefresh.yml | 28 ++++++ docker-flask-codefresh.jpg | Bin 0 -> 48529 bytes flaskr/__init__.py | 50 +++++++++++ flaskr/auth.py | 116 ++++++++++++++++++++++++ flaskr/blog.py | 125 ++++++++++++++++++++++++++ flaskr/db.py | 54 +++++++++++ flaskr/schema.sql | 20 +++++ flaskr/static/style.css | 134 ++++++++++++++++++++++++++++ flaskr/templates/auth/login.html | 15 ++++ flaskr/templates/auth/register.html | 15 ++++ flaskr/templates/base.html | 24 +++++ flaskr/templates/blog/create.html | 15 ++++ flaskr/templates/blog/index.html | 28 ++++++ flaskr/templates/blog/update.html | 19 ++++ requirements.txt | 6 ++ setup.cfg | 13 +++ setup.py | 23 +++++ tests/conftest.py | 62 +++++++++++++ tests/data.sql | 8 ++ tests/test_auth.py | 69 ++++++++++++++ tests/test_blog.py | 83 +++++++++++++++++ tests/test_db.py | 29 ++++++ tests/test_factory.py | 12 +++ 27 files changed, 1063 insertions(+) create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 README.rst create mode 100644 codefresh.yml create mode 100644 docker-flask-codefresh.jpg create mode 100644 flaskr/__init__.py create mode 100644 flaskr/auth.py create mode 100644 flaskr/blog.py create mode 100644 flaskr/db.py create mode 100644 flaskr/schema.sql create mode 100644 flaskr/static/style.css create mode 100644 flaskr/templates/auth/login.html create mode 100644 flaskr/templates/auth/register.html create mode 100644 flaskr/templates/base.html create mode 100644 flaskr/templates/blog/create.html create mode 100644 flaskr/templates/blog/index.html create mode 100644 flaskr/templates/blog/update.html create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/conftest.py create mode 100644 tests/data.sql create mode 100644 tests/test_auth.py create mode 100644 tests/test_blog.py create mode 100644 tests/test_db.py create mode 100644 tests/test_factory.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f9252f --- /dev/null +++ b/LICENSE @@ -0,0 +1,31 @@ +Copyright © 2010 by the Pallets team. + +Some rights reserved. + +Redistribution and use in source and binary forms of the software as +well as documentation, with or without modification, are permitted +provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a73511e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include LICENSE +include flaskr/schema.sql +graft flaskr/static +graft flaskr/templates +graft tests +global-exclude *.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fc13c1 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# devopscourse -final workshop diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..7c7255f --- /dev/null +++ b/README.rst @@ -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=flaskr + $ export FLASK_ENV=development + $ flask init-db + $ flask run + +Or on Windows cmd:: + + > set FLASK_APP=flaskr + > 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 diff --git a/codefresh.yml b/codefresh.yml new file mode 100644 index 0000000..f501804 --- /dev/null +++ b/codefresh.yml @@ -0,0 +1,28 @@ +version: '1.0' +stages: + - checkout + - package + - test +steps: + main_clone: + title: Cloning main repository... + type: git-clone + repo: '${{CF_REPO_OWNER}}/${{CF_REPO_NAME}}' + revision: '${{CF_REVISION}}' + stage: checkout + MyAppDockerImage: + title: Building Docker Image + type: build + stage: package + image_name: my-app-image + working_directory: ./ + tag: v1.0.1 + dockerfile: Dockerfile + disable_push: true + MyUnitTests: + title: Running Unit tests + image: '${{MyAppDockerImage}}' + stage: test + commands: + - pip install pytest + - pytest diff --git a/docker-flask-codefresh.jpg b/docker-flask-codefresh.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f498d485622720b2caf65be21a32f2b5b23e9e76 GIT binary patch literal 48529 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 Vy#9A)*Z-20|GzFufA-ht{{f2eiJ$-g literal 0 HcmV?d00001 diff --git a/flaskr/__init__.py b/flaskr/__init__.py new file mode 100644 index 0000000..bb9cce5 --- /dev/null +++ b/flaskr/__init__.py @@ -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, "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.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 flaskr import db + + db.init_app(app) + + # apply the blueprints to the app + from flaskr 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 diff --git a/flaskr/auth.py b/flaskr/auth.py new file mode 100644 index 0000000..815ab69 --- /dev/null +++ b/flaskr/auth.py @@ -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 flaskr.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")) diff --git a/flaskr/blog.py b/flaskr/blog.py new file mode 100644 index 0000000..445fb5a --- /dev/null +++ b/flaskr/blog.py @@ -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 flaskr.auth import login_required +from flaskr.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("//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("//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")) diff --git a/flaskr/db.py b/flaskr/db.py new file mode 100644 index 0000000..f1e2dc3 --- /dev/null +++ b/flaskr/db.py @@ -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) diff --git a/flaskr/schema.sql b/flaskr/schema.sql new file mode 100644 index 0000000..dd4c866 --- /dev/null +++ b/flaskr/schema.sql @@ -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) +); diff --git a/flaskr/static/style.css b/flaskr/static/style.css new file mode 100644 index 0000000..2f1f4d0 --- /dev/null +++ b/flaskr/static/style.css @@ -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; +} diff --git a/flaskr/templates/auth/login.html b/flaskr/templates/auth/login.html new file mode 100644 index 0000000..b326b5a --- /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..4320e17 --- /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..f09e926 --- /dev/null +++ b/flaskr/templates/base.html @@ -0,0 +1,24 @@ + +{% 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..3481b8e --- /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'] }}

+
+ {% 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..2c405e6 --- /dev/null +++ b/flaskr/templates/blog/update.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block header %} +

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

+{% endblock %} + +{% block content %} +
+ + + + + +
+
+
+ +
+{% endblock %} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..68540b7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +click==7.0 # via flask +flask==1.1.1 # via flaskr (setup.py) +itsdangerous==1.1.0 # via flask +jinja2==2.11.1 # via flask +markupsafe==1.1.1 # via jinja2 +werkzeug==1.0.0 # via flask diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3e47794 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,13 @@ +[metadata] +license_file = LICENSE + +[bdist_wheel] +universal = True + +[tool:pytest] +testpaths = tests + +[coverage:run] +branch = True +source = + flaskr diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..3c8f411 --- /dev/null +++ b/setup.py @@ -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="flaskr", + 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"]}, +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4d109ab --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,62 @@ +import os +import tempfile + +import pytest + +from flaskr import create_app +from flaskr.db import get_db +from flaskr.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) diff --git a/tests/data.sql b/tests/data.sql new file mode 100644 index 0000000..9b68006 --- /dev/null +++ b/tests/data.sql @@ -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'); diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..3ac9a12 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,69 @@ +import pytest +from flask import g +from flask import session + +from flaskr.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 diff --git a/tests/test_blog.py b/tests/test_blog.py new file mode 100644 index 0000000..9185968 --- /dev/null +++ b/tests/test_blog.py @@ -0,0 +1,83 @@ +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"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 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..31c6c57 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,29 @@ +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_command(runner, monkeypatch): + 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..9b7ca57 --- /dev/null +++ b/tests/test_factory.py @@ -0,0 +1,12 @@ +from flaskr 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!" From 6a8c8e66d7d8223e97f17ed829a2ffae1a2f1a1a Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Mon, 14 Jul 2025 07:47:45 +0000 Subject: [PATCH 2/5] Created Dockerfile --- Dockerfile | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ebe5965 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.13.5-alpine3.22 + +RUN apk update && apk add git +RUN git clone https://github.com/pallets/flask + +WORKDIR flask + +# Get latest tag +RUN latesttag=$(git describe --tags) +RUN git checkout ${latesttag} +WORKDIR examples/tutorial + +RUN pip install -e . + +ENV FLASK_RUN_HOST=0.0.0.0 +ENV FLASK_APP=flaskr +ENV FLASK_ENV=development + +RUN flask init-db +EXPOSE 5000 +CMD ["flask", "run"] From fb91ee3242c3865585b6e777a33d77231dd579f3 Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Mon, 14 Jul 2025 08:49:45 +0000 Subject: [PATCH 3/5] Fixed dockerfile --- Dockerfile | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index ebe5965..1e555df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,15 @@ FROM python:3.13.5-alpine3.22 RUN apk update && apk add git -RUN git clone https://github.com/pallets/flask -WORKDIR flask +RUN git clone https://github.com/KfirBarokas/DevopsCourse2025 +WORKDIR DevopsCourse2025 -# Get latest tag -RUN latesttag=$(git describe --tags) -RUN git checkout ${latesttag} -WORKDIR examples/tutorial +RUN git checkout feature/sol + +RUN cp -r . /usr/src/app + +WORKDIR /usr/src/app RUN pip install -e . @@ -18,4 +19,4 @@ ENV FLASK_ENV=development RUN flask init-db EXPOSE 5000 -CMD ["flask", "run"] +CMD ["flask", "run", "--host=0.0.0.0"] From 862e6a74a0119e45b1aa5734ec23be353b4349d2 Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Mon, 14 Jul 2025 08:55:54 +0000 Subject: [PATCH 4/5] Fix --- Dockerfile | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1e555df..c7a9bdf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,15 +2,10 @@ FROM python:3.13.5-alpine3.22 RUN apk update && apk add git -RUN git clone https://github.com/KfirBarokas/DevopsCourse2025 -WORKDIR DevopsCourse2025 - -RUN git checkout feature/sol - -RUN cp -r . /usr/src/app - WORKDIR /usr/src/app +COPY . . + RUN pip install -e . ENV FLASK_RUN_HOST=0.0.0.0 From ff06c9203b43ba682c3e3458effbf1aa785b02c3 Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Mon, 14 Jul 2025 10:54:39 +0000 Subject: [PATCH 5/5] added docker-compose --- docker-compose.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docker-compose.yaml diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a70b118 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,29 @@ +version: '3' + +services: + drupal: + image: drupal:latest + container_name: drupal + ports: + - 8080:80 + networks: + - kfir-network + environment: + - POSTGRES_DB=table + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + + postgres: + image: postgres:latest + container_name: postgres + networks: + - kfir-network + environment: + - POSTGRES_DB=table + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + + +networks: + kfir-network: + driver: bridge