+{% endmacro %}
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/main/about.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/main/about.html
new file mode 100644
index 0000000..66f60f3
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/main/about.html
@@ -0,0 +1,30 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+About
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Nestled in the heart of the old town, The Book Nook has been a cozy corner for readers since
+ 1998.
+ Whether you're chasing ancient tales, modern mysteries, or just a quiet place with a cat named
+ Oliver napping by the window, you'll find your next chapter here.
+
+
+
+
+
+
“There is no friend as loyal as a book.” — Ernest Hemingway
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/main/index.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/main/index.html
new file mode 100644
index 0000000..c69c1e9
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/main/index.html
@@ -0,0 +1,155 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+Main Page
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add New Product
+
+
+
+ {% for product in products %}
+ {% if loop.index0 % 4 == 0 %}
+ {% if not loop.first %}
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+
{{ product.name }}
+
{{ product.author }}
+
{{ product.price }}₾
+ View
+
+ {% if current_user.is_authenticated and current_user.is_admin() %}
+ Edit
+ Delete
+ {% endif %}
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
You can browse our catalog, add books to your cart, and proceed to
+ checkout
+ using your preferred payment method.
+
+
+
+
+
+
+
+
+
We accept credit/debit cards, PayPal, Apple Pay, and Google Pay.
+
+
+
+
+
+
+
+
Delivery typically takes 3–7 business days, depending on your location
+ and
+ the shipping option selected.
+
+
+
+
+
+
+
+
+
Yes, returns are accepted within 14 days of delivery if the book is
+ unused
+ and in its original condition. Contact support for return instructions.
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/partials/base.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/partials/base.html
new file mode 100644
index 0000000..ef4b590
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/partials/base.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ {% block title %}{% endblock %}
+
+
+
+
+{% include "partials/navbar.html" %}
+
+{% for message in get_flashed_messages(with_categories=True) if message %}
+
+
{{ message[1] }}
+
+{% endfor %}
+
+{% block content %}
+{% endblock %}
+
+
+
+
+
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/partials/navbar.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/partials/navbar.html
new file mode 100644
index 0000000..e4e5cb3
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/partials/navbar.html
@@ -0,0 +1,34 @@
+{% from "macros.html" import is_active %}
+{% from "macros.html" import generate_navlink %}
+
+
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/product/create_product.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/product/create_product.html
new file mode 100644
index 0000000..a227c33
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/product/create_product.html
@@ -0,0 +1,24 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+Create Product
+{% endblock %}
+
+{% block content %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/product/view_product.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/product/view_product.html
new file mode 100644
index 0000000..3b3927c
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/product/view_product.html
@@ -0,0 +1,35 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+View Product
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
სახელი:
+
{{ product.name }}
+
+
+
ავტორი:
+
{{ product.author }}
+
+
+
ფასი:
+
{{ product.price }}₾
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/utils.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/utils.py
new file mode 100644
index 0000000..38cfbcd
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/utils.py
@@ -0,0 +1,13 @@
+from functools import wraps
+from flask_login import current_user
+from flask import redirect, url_for
+
+
+def admin_required(func):
+ @wraps(func)
+ def admin_view(*args, **kwargs):
+ if current_user and not current_user.is_admin():
+ return redirect(url_for("main.index"))
+ return func(*args, **kwargs)
+
+ return admin_view
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/__init__.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/__init__.py
new file mode 100644
index 0000000..3b3672a
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/__init__.py
@@ -0,0 +1,3 @@
+from src.views.main.routes import main_blueprint
+from src.views.auth.routes import auth_blueprint
+from src.views.product.routes import product_blueprint
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/auth/forms.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/auth/forms.py
new file mode 100644
index 0000000..49b1d1c
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/auth/forms.py
@@ -0,0 +1,72 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, SubmitField, DateField, RadioField, SelectField, EmailField, FloatField
+from wtforms.validators import DataRequired, length, equal_to, ValidationError
+from flask_wtf.file import FileField, FileRequired, FileAllowed, FileSize
+from string import ascii_uppercase, ascii_lowercase, digits, punctuation
+
+
+class LoginForm(FlaskForm):
+ username = StringField("Enter Username...", validators=[DataRequired()])
+ password = PasswordField("Enter Password...", validators=[DataRequired()])
+ login = SubmitField("Log In")
+
+
+class RegisterForm(FlaskForm):
+ username = StringField("Enter Username...",
+ validators=[DataRequired()])
+
+ email = EmailField("Enter Email...",
+ validators=[DataRequired()])
+
+ password = PasswordField("Enter Password...",
+ validators=[DataRequired(), length(min=8, max=20)])
+
+ repeat_password = PasswordField("Confirm Password...",
+ validators=[DataRequired("განმეორებითი პაროლის ველი სავალდებულოა"),
+ equal_to("password",
+ message="პაროლი და განმეორებითი პაროლი არ ემთხვევა")])
+
+ birthday = DateField("Enter Birthday...",
+ validators=[DataRequired()])
+
+ gender = RadioField("Choose Gender", choices=[(0, "Male"), (1, "Female")],
+ validators=[DataRequired()])
+
+ country = SelectField("Choose Country", choices=["Georgia", "USA", "Spain"],
+ validators=[DataRequired()])
+
+ profile_image = FileField("Upload Profile Image",
+ validators=[FileSize(1024 * 1024),
+ FileAllowed(["jpg", "jpeg", "png"])])
+ submit = SubmitField("Registration")
+
+ def validate_password(self, field):
+ contains_uppercase = False
+ contains_lowercase = False
+ contains_digits = False
+ contains_symbols = False
+
+ for char in field.data:
+ if char in ascii_uppercase:
+ contains_uppercase = True
+
+ if char in ascii_lowercase:
+ contains_lowercase = True
+
+ if char in digits:
+ contains_digits = True
+
+ if char in punctuation:
+ contains_symbols = True
+
+ if not contains_uppercase:
+ raise ValidationError("პაროლი უნდა შეიცავდეს დიდ ასოებს")
+
+ if not contains_lowercase:
+ raise ValidationError("პაროლი უნდა შეიცავდეს პატარა ასოებს")
+
+ if not contains_digits:
+ raise ValidationError("პაროლი უნდა შეიცავდეს ციფრებს")
+
+ if not contains_symbols:
+ raise ValidationError("პაროლი უნდა შეიცავდეს სიმბოლოებს")
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/auth/routes.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/auth/routes.py
new file mode 100644
index 0000000..38cd155
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/auth/routes.py
@@ -0,0 +1,56 @@
+from uuid import uuid4
+from os import path
+from flask import Blueprint, render_template, redirect, url_for, request, flash, get_flashed_messages
+from flask_login import login_user, logout_user, login_required
+
+from src.views.auth.forms import RegisterForm, LoginForm
+from src.config import Config
+from src.models import User
+
+auth_blueprint = Blueprint("auth", __name__)
+
+
+@auth_blueprint.route("/login", methods=["GET", "POST"])
+def login():
+ form = LoginForm()
+ if form.validate_on_submit():
+ user = User.query.filter(User.username == form.username.data).first()
+ if user and user.check_password(form.password.data):
+ login_user(user)
+ next = request.args.get("next")
+ if next:
+ return redirect(next)
+ flash("Logged in!", "success")
+ return redirect(url_for("main.index"))
+ else:
+ flash("Username or Password is incorrect", "danger")
+
+ return render_template("auth/login.html", form=form)
+
+
+@auth_blueprint.route("/registration", methods=["GET", "POST"])
+def registration():
+ form = RegisterForm()
+ if form.validate_on_submit():
+ file = form.profile_image.data
+ _, extension = path.splitext(file.filename)
+ filename = f"{uuid4()}{extension}"
+ file.save(path.join(Config.UPLOAD_PATH, filename))
+
+ new_user = User(username=form.username.data, password=form.password.data, profile_image=filename)
+ new_user.create()
+ else:
+ print(form.errors)
+ return render_template("auth/registration.html", form=form)
+
+
+@auth_blueprint.route("/logout")
+def logout():
+ logout_user()
+ return redirect(url_for("auth.login"))
+
+
+@auth_blueprint.route("/profile")
+@login_required
+def view_profile():
+ return render_template("auth/profile.html")
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/main/routes.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/main/routes.py
new file mode 100644
index 0000000..f4f1dfd
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/main/routes.py
@@ -0,0 +1,16 @@
+from flask import Blueprint, render_template
+
+from src.models.product import Product
+
+main_blueprint = Blueprint("main", __name__)
+
+
+@main_blueprint.route("/")
+def index():
+ products = Product.query.all()
+ return render_template("main/index.html", products=products)
+
+
+@main_blueprint.route("/about")
+def about():
+ return render_template("main/about.html")
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/product/forms.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/product/forms.py
new file mode 100644
index 0000000..df10d20
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/product/forms.py
@@ -0,0 +1,11 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, SubmitField, FloatField
+from flask_wtf.file import FileField, FileRequired, FileAllowed, FileSize
+
+class ProductForm(FlaskForm):
+ name = StringField("Enter Book Name")
+ author = StringField("Enter Author Name")
+ price = FloatField("Enter Price")
+ image = FileField("Upload Image", validators=[FileSize(1024 * 1024),
+ FileAllowed(["jpg", "png", "jpeg"])])
+ submit = SubmitField("Create Product")
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/product/routes.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/product/routes.py
new file mode 100644
index 0000000..000cc6a
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/views/product/routes.py
@@ -0,0 +1,69 @@
+from uuid import uuid4
+from os import path
+from flask import Blueprint, render_template, url_for, redirect
+from flask_login import login_required
+
+from src.views.product.forms import ProductForm
+from src.models.product import Product
+from src.config import Config
+from src.utils import admin_required
+
+product_blueprint = Blueprint("products", __name__)
+
+
+@product_blueprint.route("/add_product", methods=["GET", "POST"])
+@admin_required
+def add_product():
+ form = ProductForm()
+ if form.validate_on_submit():
+ file = form.image.data
+ _, extension = path.splitext(file.filename)
+ filename = f"{uuid4()}{extension}"
+ file.save(path.join(Config.UPLOAD_PATH, filename))
+
+ new_product = Product(name=form.name.data, author=form.author.data, price=form.price.data, image=filename)
+ new_product.create()
+
+ return redirect(url_for("main.index"))
+
+ return render_template("product/create_product.html", form=form)
+
+
+@product_blueprint.route("/edit_product/", methods=["GET", "POST"])
+@admin_required
+def edit_product(id):
+ product = Product.query.get(id)
+
+ form = ProductForm(name=product.name, author=product.author, price=product.price)
+ if form.validate_on_submit():
+ product.name = form.name.data
+ product.author = form.author.data
+ product.price = form.price.data
+
+ if form.image.data:
+ file = form.image.data
+ _, extension = path.splitext(file.filename)
+ filename = f"{uuid4()}{extension}"
+ file.save(path.join(Config.UPLOAD_PATH, filename))
+
+ product.image = filename
+
+ product.save()
+ return redirect(url_for("main.index"))
+
+ return render_template("product/create_product.html", form=form)
+
+
+@product_blueprint.route("/delete_product/")
+@admin_required
+def delete_product(id):
+ product = Product.query.get(id)
+ product.delete()
+
+ return redirect(url_for("main.index"))
+
+
+@product_blueprint.route("/view/")
+def view_product(product_id):
+ chosen_product = Product.query.get(product_id)
+ return render_template("product/view_product.html", product=chosen_product)
diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/uwsgi.conf b/Chapter09_Admin/Projects/Nino_Tsiklauri/uwsgi.conf
new file mode 100644
index 0000000..50a5ff7
--- /dev/null
+++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/uwsgi.conf
@@ -0,0 +1,9 @@
+[uwsgi]
+master = true
+module = src:create_app()
+processes = 4
+threads = 8
+socket = :5000
+chmod-socket = 600
+die-on-term = true
+vacuum = true
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Readme.md b/Chapter09_Admin/Projects/Readme.md
index b684ad0..eb7f6aa 100644
--- a/Chapter09_Admin/Projects/Readme.md
+++ b/Chapter09_Admin/Projects/Readme.md
@@ -2,5 +2,6 @@
მაგალითი:
- სახელი გვარი | [პროექტი](/მისამართი)
+- ნინო წიკლაური | [Book Shop](/Chapter09_Admin/Projects/Nino_Tsiklauri/app.py)
### 2025 ზაფხული
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/app.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/app.py
new file mode 100644
index 0000000..ce3460f
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/app.py
@@ -0,0 +1,6 @@
+from src import create_app
+
+app = create_app()
+
+if __name__ == "__main__":
+ app.run(debug=True)
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/dev-requirements.txt b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/dev-requirements.txt
new file mode 100644
index 0000000..e5bc481
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/dev-requirements.txt differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/README b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/README
new file mode 100644
index 0000000..0e04844
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/README
@@ -0,0 +1 @@
+Single-database configuration for Flask.
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/alembic.ini b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/alembic.ini
new file mode 100644
index 0000000..ec9d45c
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/alembic.ini
@@ -0,0 +1,50 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic,flask_migrate
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[logger_flask_migrate]
+level = INFO
+handlers =
+qualname = flask_migrate
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/env.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/env.py
new file mode 100644
index 0000000..4c97092
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/env.py
@@ -0,0 +1,113 @@
+import logging
+from logging.config import fileConfig
+
+from flask import current_app
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+
+def get_engine():
+ try:
+ # this works with Flask-SQLAlchemy<3 and Alchemical
+ return current_app.extensions['migrate'].db.get_engine()
+ except (TypeError, AttributeError):
+ # this works with Flask-SQLAlchemy>=3
+ return current_app.extensions['migrate'].db.engine
+
+
+def get_engine_url():
+ try:
+ return get_engine().url.render_as_string(hide_password=False).replace(
+ '%', '%%')
+ except AttributeError:
+ return str(get_engine().url).replace('%', '%%')
+
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+config.set_main_option('sqlalchemy.url', get_engine_url())
+target_db = current_app.extensions['migrate'].db
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def get_metadata():
+ if hasattr(target_db, 'metadatas'):
+ return target_db.metadatas[None]
+ return target_db.metadata
+
+
+def run_migrations_offline():
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url, target_metadata=get_metadata(), literal_binds=True
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online():
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+
+ # this callback is used to prevent an auto-migration from being generated
+ # when there are no changes to the schema
+ # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
+ def process_revision_directives(context, revision, directives):
+ if getattr(config.cmd_opts, 'autogenerate', False):
+ script = directives[0]
+ if script.upgrade_ops.is_empty():
+ directives[:] = []
+ logger.info('No changes in schema detected.')
+
+ conf_args = current_app.extensions['migrate'].configure_args
+ if conf_args.get("process_revision_directives") is None:
+ conf_args["process_revision_directives"] = process_revision_directives
+
+ connectable = get_engine()
+
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection,
+ target_metadata=get_metadata(),
+ **conf_args
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/script.py.mako b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/script.py.mako
new file mode 100644
index 0000000..2c01563
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py
new file mode 100644
index 0000000..586b48a
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py
@@ -0,0 +1,32 @@
+"""Added ragint to Movies
+
+Revision ID: 32b86fbcd47b
+Revises:
+Create Date: 2025-05-31 20:51:33.043357
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '32b86fbcd47b'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('movies', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('rating', sa.Float(), nullable=True))
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('movies', schema=None) as batch_op:
+ batch_op.drop_column('rating')
+
+ # ### end Alembic commands ###
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/versions/5b5008085981_added_users_table.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/versions/5b5008085981_added_users_table.py
new file mode 100644
index 0000000..b3f35cc
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/migrations/versions/5b5008085981_added_users_table.py
@@ -0,0 +1,34 @@
+"""Added Users table
+
+Revision ID: 5b5008085981
+Revises: 32b86fbcd47b
+Create Date: 2025-06-01 20:57:15.373884
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '5b5008085981'
+down_revision = '32b86fbcd47b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('users',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('username', sa.String(), nullable=True),
+ sa.Column('password', sa.String(), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('username')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('users')
+ # ### end Alembic commands ###
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/prod-requirements.txt b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/prod-requirements.txt
new file mode 100644
index 0000000..9623f57
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/prod-requirements.txt differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/pytest_demo/pytest.ini b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/pytest_demo/pytest.ini
new file mode 100644
index 0000000..f7ce355
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/pytest_demo/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+markers = arithmetic1: multiplication and division
+ arithmetic2: addition and subtraction
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/pytest_demo/test_functions.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/pytest_demo/test_functions.py
new file mode 100644
index 0000000..9dcf0e8
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/pytest_demo/test_functions.py
@@ -0,0 +1,81 @@
+import pytest
+from datetime import date
+
+def is_even(num):
+ return num % 2 == 0
+
+def test_is_even():
+ assert is_even(6) == True
+ assert is_even(11) == False
+#============================================================================
+
+class Person():
+ def __init__(self, name, birthdate):
+ self.name = name
+ self.birthdate = birthdate
+
+ def calc_age(self):
+ return date.today().year - self.birthdate.year
+
+@pytest.fixture(scope="session")
+def person():
+ dummy_person = Person("john", date(1997, 8, 11))
+ return dummy_person
+
+def test_person_class(person):
+ assert person.calc_age() == 28
+
+def test_person_name(person):
+ assert person.name == "john"
+ person.name = "George"
+ assert person.name == "George"
+
+def test_person_rename(person):
+ assert person.name == "George"
+#============================================================================
+
+def multiply_numbers(num1, num2):
+ return num1*num2
+
+@pytest.mark.parametrize("num1, num2, result",
+ [[5, 10, 50],
+ [2, 4, 8],
+ [10, 2, 20]])
+def test_sum_numbers(num1, num2, result):
+ assert multiply_numbers(num1, num2) == result
+#============================================================================
+
+def divide_numbers(num1, num2):
+ return num1 / num2
+
+def test_divide_numbers():
+ with pytest.raises(ZeroDivisionError):
+ assert divide_numbers(1, 0)
+#============================================================================
+
+@pytest.mark.arithmetic1
+def test_multiplication():
+ assert 5 * 2 == 10
+
+@pytest.mark.arithmetic1
+def test_division():
+ assert 10 / 2 == 5
+
+@pytest.mark.arithmetic2
+def test_addition():
+ assert 2 + 2 == 4
+
+@pytest.mark.arithmetic2
+def test_substraction():
+ assert 10 - 2 == 8
+#============================================================================
+
+@pytest.mark.xfail(reason="error on Linux, it's case-sensitive")
+def test_something_else():
+ name = "Temo"
+ assert name == "temo"
+#============================================================================
+
+@pytest.mark.skip
+def test_ignore():
+ assert 5 + 5 == 10
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/__init__.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/__init__.py
new file mode 100644
index 0000000..7d3164d
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/__init__.py
@@ -0,0 +1,57 @@
+from flask import Flask
+from flask_admin.menu import MenuLink
+
+from src.config import Config
+from src.ext import db, migrate, login_manager, admin
+from src.views import main_blueprint, auth_blueprint, product_blueprint
+from src.commands import init_db, populate_db, init_db_command, populate_db_command
+from src.models import User, Product
+from src.admin_views.base import SecureModelView
+from src.admin_views import UserView, ProductView
+
+BLUEPRINTS = [main_blueprint, auth_blueprint, product_blueprint]
+COMMANDS = [init_db_command, populate_db_command]
+
+
+def create_app():
+ app = Flask(__name__)
+ app.config.from_object(Config)
+
+ register_extensions(app)
+ register_blueprints(app)
+ register_commands(app)
+
+ return app
+
+
+def register_extensions(app):
+ # Flask-SQLAlchemy
+ db.init_app(app)
+
+ # Flask-Migrate
+ migrate.init_app(app, db)
+
+ # Flask-Login
+ login_manager.init_app(app)
+ login_manager.login_view = 'auth.login'
+
+ @login_manager.user_loader
+ def load_user(_id):
+ return User.query.get(_id)
+
+ # Flask-Admin
+ admin.init_app(app)
+ admin.add_view(UserView(User, db.session))
+ admin.add_view(ProductView(Product, db.session))
+
+ admin.add_link(MenuLink("To Site", url="/", icon_type="fa", icon_value="fa-sign-out"))
+
+
+def register_blueprints(app):
+ for blueprint in BLUEPRINTS:
+ app.register_blueprint(blueprint)
+
+
+def register_commands(app):
+ for command in COMMANDS:
+ app.cli.add_command(command)
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/__init__.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/__init__.py
new file mode 100644
index 0000000..980d5af
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/__init__.py
@@ -0,0 +1,2 @@
+from src.admin_views.user import UserView
+from src.admin_views.product import ProductView
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/base.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/base.py
new file mode 100644
index 0000000..b70082f
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/base.py
@@ -0,0 +1,23 @@
+from flask_admin.contrib.sqla import ModelView
+from flask_admin import AdminIndexView
+from flask_login import current_user
+from flask import redirect, url_for
+
+class SecureModelView(ModelView):
+ def is_accessible(self):
+ return current_user.is_authenticated and current_user.is_admin()
+
+
+ def inaccessible_callback(self, name, **kwargs):
+ if not self.is_accessible():
+ return redirect(url_for("auth.login"))
+
+
+class SecureIndexView(AdminIndexView):
+ def is_accessible(self):
+ return current_user.is_authenticated and current_user.is_admin()
+
+
+ def inaccessible_callback(self, name, **kwargs):
+ if not self.is_accessible():
+ return redirect(url_for("auth.login"))
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/product.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/product.py
new file mode 100644
index 0000000..9db4c70
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/product.py
@@ -0,0 +1,31 @@
+from src.admin_views.base import SecureModelView
+from src.config import Config
+
+from flask_admin.form import ImageUploadField
+from os import path
+from uuid import uuid4
+from markupsafe import Markup
+
+def generate_filename(obj, file):
+ name, extension = path.splitext(file.filename)
+ return f"{uuid4()}{extension}"
+
+class ProductView(SecureModelView):
+
+ create_modal = True
+ edit_modal = True
+ column_editable_list = _list = ("price",)
+ column_filters = ("price", "name")
+
+ column_formatters = {
+ "name": lambda v,c,m,n: m.name if len(m.name) <15 else m.name[0:15] + "...",
+ "image": lambda v,c,m,n: Markup(f"")
+ }
+
+ form_overrides = {"image": ImageUploadField}
+ form_args = {
+ "image": {
+ "base_path": Config.UPLOAD_PATH,
+ "namegen": generate_filename
+ }
+ }
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/user.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/user.py
new file mode 100644
index 0000000..db13583
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/admin_views/user.py
@@ -0,0 +1,16 @@
+from src.admin_views.base import SecureModelView
+from wtforms.fields import SelectField
+
+class UserView(SecureModelView):
+ can_view_details = True
+ can_edit = False
+ can_create = True
+ can_delete = False
+
+ column_exclude_list = ['_password',]
+ column_searchable_list = ['username', 'role']
+
+ form_overrides = {"role": SelectField}
+ form_args = {"role":{
+ "choices":["Admin", "Moderator", "User"]
+ }}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/commands.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/commands.py
new file mode 100644
index 0000000..ef7bb3f
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/commands.py
@@ -0,0 +1,78 @@
+from flask.cli import with_appcontext
+import click
+import datetime
+
+from src.ext import db
+from src.models import Person, Product, IDCard, Actor, Movie, ActorMovie, University, Student, User
+
+def init_db():
+ db.drop_all()
+ db.create_all()
+
+def populate_db():
+ products = [
+ {"id": 0, "name": "ფრანით მორბენალი", "author": "ხალიდ ჰოსეინი", "price": "25", "img": "franit-morbenali.png"},
+ {"id": 1, "name": "48 კანონი", "author": "რობერტ გრინი", "price": "40", "img": "dzalauflebis_48_kanoni.jpg"},
+ {"id": 2, "name": "ატომური ჩვევები", "author": "ჯეიმს ქლიერი", "price": "35", "img": "atomuri_cvevebi.jpg"},
+ {"id": 3, "name": "ხაფანგი 22", "author": "ჟოზეფ ჰელერის", "price": "30", "img": "xafangi22.png"}
+ ]
+
+ for product in products:
+ new_product = Product(name=product["name"], author=product["author"], price=product["price"],
+ image=product["img"])
+ db.session.add(new_product)
+ db.session.commit()
+
+ ### ONE TO ONE EXAMPLE DATA ###
+ id_card = IDCard(personal_number="12345678901", serial_number="XXX", expiry_date=datetime.datetime.now())
+ db.session.add(id_card)
+ db.session.commit()
+
+ person = Person(name="John", surname="Doe", birthday=datetime.datetime.now(), idcard_id=id_card.id)
+ db.session.add(person)
+ db.session.commit()
+
+ ### ONE TO MANY EXAMPLE DATA ###
+ university = University(name="GTU", address="Teqnikuri")
+ db.session.add(university)
+ db.session.commit()
+
+ student1 = Student(name="Jeiran Doadze", university_id=university.id)
+ student2 = Student(name="Joanna Smth", university_id=university.id)
+ db.session.add_all([student1, student2])
+ db.session.commit()
+
+ ### MANY TO MANY EXAMPLE DATA ###
+ actor1 = Actor(name="Robert Downey Jr")
+ actor2 = Actor(name="Chris Evans")
+
+ movie1 = Movie(name="Iron Man", genre="Fantasy, Action")
+ movie2 = Movie(name="Captain America", genre="Action")
+ movie3 = Movie(name="Avengers", genre="Action")
+
+ db.session.add_all([actor1, actor2])
+ db.session.add_all([movie1, movie2, movie3])
+ db.session.commit()
+
+ actormovie1 = ActorMovie(actor_id=1, movie_id=1)
+ actormovie2 = ActorMovie(actor_id=2, movie_id=2)
+ actormovie3 = ActorMovie(actor_id=1, movie_id=3)
+ actormovie4 = ActorMovie(actor_id=2, movie_id=3)
+ db.session.add_all([actormovie1, actormovie2, actormovie3, actormovie4])
+ db.session.commit()
+
+ ### ADMIN USER ###
+ User(username="admin", password="password123", profile_image="static/assets/admin.jpg", role="Admin").create()
+
+@click.command("init_db")
+@with_appcontext
+def init_db_command():
+ click.echo("initializing database...")
+ init_db()
+ click.echo("Initialized database")
+
+
+@click.command("populate_db")
+@with_appcontext
+def populate_db_command():
+ populate_db()
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/config.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/config.py
new file mode 100644
index 0000000..f0d8c0c
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/config.py
@@ -0,0 +1,11 @@
+from os import path, environ
+
+
+class Config(object):
+ BASE_DIRECTORY = path.abspath(path.dirname(__file__))
+
+ SQLALCHEMY_DATABASE_URI = "sqlite:///database.db"
+ SECRET_KEY = environ.get("SECRET_KEY", "defaultsecretkey123!456@")
+ FLASK_ADMIN_SWATCH = 'Cerulean'
+
+ UPLOAD_PATH = path.join(BASE_DIRECTORY, "static", "upload")
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/ext.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/ext.py
new file mode 100644
index 0000000..3732832
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/ext.py
@@ -0,0 +1,11 @@
+from flask_sqlalchemy import SQLAlchemy
+from flask_migrate import Migrate
+from flask_login import LoginManager
+from flask_admin import Admin
+
+from src.admin_views.base import SecureIndexView
+
+db = SQLAlchemy()
+migrate = Migrate()
+login_manager = LoginManager()
+admin = Admin(name="Admin Panel", template_mode="bootstrap4", index_view=SecureIndexView(), base_template="admin/admin_base.html")
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/__init__.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/__init__.py
new file mode 100644
index 0000000..ef7ab98
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/__init__.py
@@ -0,0 +1,5 @@
+from src.models.product import Product
+from src.models.person import Person, IDCard
+from src.models.actor_movie import Actor, Movie, ActorMovie
+from src.models.university_student import University, Student
+from src.models.user import User
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/actor_movie.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/actor_movie.py
new file mode 100644
index 0000000..f00bf7c
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/actor_movie.py
@@ -0,0 +1,28 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+
+### MANY TO MANY RELATIONSHIP ###
+class Actor(BaseModel):
+ __tablename__ = "actors"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+
+ movies = db.relationship("Movie", back_populates="actors", secondary="actor_movie")
+
+
+class ActorMovie(BaseModel):
+ __tablename__ = "actor_movie"
+ id = db.Column(db.Integer, primary_key=True)
+ actor_id = db.Column(db.Integer, db.ForeignKey("actors.id"))
+ movie_id = db.Column(db.Integer, db.ForeignKey("movies.id"))
+
+
+class Movie(BaseModel):
+ __tablename__ = "movies"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ genre = db.Column(db.String)
+ rating = db.Column(db.Float)
+
+ actors = db.relationship("Actor", back_populates="movies", secondary="actor_movie")
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/base.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/base.py
new file mode 100644
index 0000000..f633c82
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/base.py
@@ -0,0 +1,21 @@
+from src.ext import db
+
+
+class BaseModel(db.Model):
+
+ __abstract__ = True
+
+ def create(self, commit=True):
+ db.session.add(self)
+
+ if commit:
+ self.save()
+
+ def save(self):
+ db.session.commit()
+
+ def delete(self, commit=True):
+ db.session.delete(self)
+
+ if commit:
+ self.save()
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/person.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/person.py
new file mode 100644
index 0000000..2e8ff95
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/person.py
@@ -0,0 +1,25 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+### ONE TO ONE RELATIONSHIP ###
+
+
+class Person(BaseModel):
+ __tablename__ = "people"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ surname = db.Column(db.String)
+ birthday = db.Column(db.Date)
+ idcard_id = db.Column(db.Integer, db.ForeignKey("id_cards.id"))
+
+ id_card = db.relationship("IDCard", back_populates="person")
+
+
+class IDCard(BaseModel):
+ __tablename__ = "id_cards"
+ id = db.Column(db.Integer, primary_key=True)
+ personal_number = db.Column(db.String)
+ serial_number = db.Column(db.String)
+ expiry_date = db.Column(db.Date)
+
+ person = db.relationship("Person", back_populates="id_card", uselist=False)
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/product.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/product.py
new file mode 100644
index 0000000..385b457
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/product.py
@@ -0,0 +1,14 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+
+class Product(BaseModel):
+ __tablename__ = "products"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ author = db.Column(db.String)
+ price = db.Column(db.Float)
+ image = db.Column(db.String)
+
+ def __repr__(self):
+ return self.name
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/university_student.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/university_student.py
new file mode 100644
index 0000000..729b3fe
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/university_student.py
@@ -0,0 +1,21 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+
+### ONE TO MANY RELATIONSHIP ###
+class University(BaseModel):
+ __tablename__ = "universities"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ address = db.Column(db.String)
+
+ students = db.relationship("Student", back_populates="university")
+
+
+class Student(BaseModel):
+ __tablename__ = "students"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ university_id = db.Column(db.Integer, db.ForeignKey("universities.id"))
+
+ university = db.relationship("University", back_populates="students")
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/user.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/user.py
new file mode 100644
index 0000000..5ba4afc
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/models/user.py
@@ -0,0 +1,30 @@
+from flask_login import UserMixin
+from werkzeug.security import generate_password_hash, check_password_hash
+
+from src.ext import db
+from src.models.base import BaseModel
+
+
+class User (BaseModel, UserMixin):
+ __tablename__ = 'users'
+
+ id = db.Column(db.Integer, primary_key=True)
+ username = db.Column(db.String, unique=True)
+ _password = db.Column(db.String)
+ profile_image = db.Column(db.String)
+ role = db.Column(db.String)
+
+ def check_password(self, password):
+ return check_password_hash(self.password, password)
+
+ def is_admin(self):
+ return self.role == "Admin"
+
+ #getter
+ @property
+ def password(self):
+ return self._password
+
+ @password.setter
+ def password(self, password):
+ self._password = generate_password_hash(password)
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/78b3e9f4-6112-47c3-abe7-85a15523909f.jpg b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/78b3e9f4-6112-47c3-abe7-85a15523909f.jpg
new file mode 100644
index 0000000..cf75dd6
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/78b3e9f4-6112-47c3-abe7-85a15523909f.jpg differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/admin.jpg b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/admin.jpg
new file mode 100644
index 0000000..a24f56c
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/admin.jpg differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/atomuri_cvevebi.jpg b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/atomuri_cvevebi.jpg
new file mode 100644
index 0000000..ec569a3
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/atomuri_cvevebi.jpg differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/b0416bf9-44d3-44a1-bddb-1e8e5b63db03.jpg b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/b0416bf9-44d3-44a1-bddb-1e8e5b63db03.jpg
new file mode 100644
index 0000000..6c85cee
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/b0416bf9-44d3-44a1-bddb-1e8e5b63db03.jpg differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/cover1.png b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/cover1.png
new file mode 100644
index 0000000..137a914
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/cover1.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/cover2.png b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/cover2.png
new file mode 100644
index 0000000..97af56e
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/cover2.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/cover3.png b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/cover3.png
new file mode 100644
index 0000000..77a3d43
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/cover3.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/dzalauflebis_48_kanoni.jpg b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/dzalauflebis_48_kanoni.jpg
new file mode 100644
index 0000000..e8fd535
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/dzalauflebis_48_kanoni.jpg differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/franit-morbenali.png b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/franit-morbenali.png
new file mode 100644
index 0000000..cb752bd
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/franit-morbenali.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/xafangi22.png b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/xafangi22.png
new file mode 100644
index 0000000..a275dcb
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/assets/xafangi22.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/css/styles.css b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/css/styles.css
new file mode 100644
index 0000000..02817d6
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/css/styles.css
@@ -0,0 +1,91 @@
+body {
+ background-color: rgb(212, 215, 255);
+}
+
+.container {
+ padding: 0px;
+ margin-top: 5px;
+}
+
+#carouselExampleIndicators {
+ margin-bottom: 20px;
+}
+
+.card {
+ cursor: pointer;
+ margin-bottom: 20px;
+}
+
+#accordionFlushExample {
+ margin-top: 50px;
+ margin-bottom: 50px;
+}
+
+nav {
+ background-color: rgb(43, 0, 99);
+}
+
+.btn {
+
+ border: none;
+}
+
+.btn a {
+ text-decoration: none;
+ color: white;
+}
+
+.pagination {
+ justify-content: center;
+ background-color: rgb(212, 215, 255);
+ margin-top: 20px;
+}
+
+.book_shop img {
+ width: 200px;
+ border-radius: 10px;
+}
+
+.registration {
+ margin-top: 30px;
+ width: 300px;
+}
+
+.registration input {
+ margin-bottom: 15px;
+}
+
+.nav-btn{
+ border: 1px solid rgb(0, 0, 185);
+}
+
+.nav-btn:hover{
+ background-color: rgb(0, 0, 185);
+ color: white
+}
+
+.registration-btn, .login-btn {
+ border: none;
+ padding: 8px;
+ background-color: blue;
+ border-radius: 5px;
+ color: white;
+}
+.registration-btn:hover{
+ background-color: rgb(0, 0, 200);
+}
+
+.login-btn:hover{
+ background-color: rgb(0, 0, 200);
+ color: white
+}
+
+.price{
+ color: rgb(51, 107, 172);
+ font-weight: bold;
+}
+.product-img {
+ width: 100%;
+ height: 250px;
+ object-fit: cover;
+}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/252a4acb-f0be-4d6d-b1d6-0011fc613313.png b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/252a4acb-f0be-4d6d-b1d6-0011fc613313.png
new file mode 100644
index 0000000..40b526e
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/252a4acb-f0be-4d6d-b1d6-0011fc613313.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/a5348e6c-142a-472d-9658-d433620c0173.jpg b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/a5348e6c-142a-472d-9658-d433620c0173.jpg
new file mode 100644
index 0000000..72809b0
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/a5348e6c-142a-472d-9658-d433620c0173.jpg differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/aed61a25-b537-4891-bf51-20260f5f7bb9.jpg b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/aed61a25-b537-4891-bf51-20260f5f7bb9.jpg
new file mode 100644
index 0000000..72809b0
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/aed61a25-b537-4891-bf51-20260f5f7bb9.jpg differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/c9cdea47-70fd-4636-b5cd-88b1605f1a63.png b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/c9cdea47-70fd-4636-b5cd-88b1605f1a63.png
new file mode 100644
index 0000000..40b526e
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/c9cdea47-70fd-4636-b5cd-88b1605f1a63.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/cb25879f-83a3-4ce7-87da-33829bd0125a.png b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/cb25879f-83a3-4ce7-87da-33829bd0125a.png
new file mode 100644
index 0000000..40b526e
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/static/upload/cb25879f-83a3-4ce7-87da-33829bd0125a.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/admin/admin_base.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/admin/admin_base.html
new file mode 100644
index 0000000..09286ee
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/admin/admin_base.html
@@ -0,0 +1,43 @@
+{% extends "admin/base.html" %}
+
+{% block page_body %}
+
+
+
+ {% block messages %}
+ {{ layout.messages() }}
+ {% endblock %}
+
+ {# store the jinja2 context for form_rules rendering logic #}
+ {% set render_ctx = h.resolve_ctx() %}
+
+ {% block body %}{% endblock %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/auth/login.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/auth/login.html
new file mode 100644
index 0000000..51f3d00
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/auth/login.html
@@ -0,0 +1,17 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+LogIn
+{% endblock %}
+
+{% block content %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/auth/profile.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/auth/profile.html
new file mode 100644
index 0000000..0a833b9
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/auth/profile.html
@@ -0,0 +1,31 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+Viewing profile - {{ current_user.username }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
Username:
+
{{ current_user.username }}
+
+
+
Password:
+
{{ current_user.password }}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/auth/registration.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/auth/registration.html
new file mode 100644
index 0000000..b5d29a0
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/auth/registration.html
@@ -0,0 +1,29 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+Registration
+{% endblock %}
+
+{% block content %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/macros.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/macros.html
new file mode 100644
index 0000000..25cc5fe
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/macros.html
@@ -0,0 +1,14 @@
+{% macro is_active(endpoint_name) %}
+ {% if endpoint_name == request.endpoint %}
+ active
+ {% endif %}
+{% endmacro %}
+
+{% macro generate_navlink(name, endpoint) %}
+
+{% endmacro %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/main/about.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/main/about.html
new file mode 100644
index 0000000..66f60f3
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/main/about.html
@@ -0,0 +1,30 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+About
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Nestled in the heart of the old town, The Book Nook has been a cozy corner for readers since
+ 1998.
+ Whether you're chasing ancient tales, modern mysteries, or just a quiet place with a cat named
+ Oliver napping by the window, you'll find your next chapter here.
+
+
+
+
+
+
“There is no friend as loyal as a book.” — Ernest Hemingway
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/main/index.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/main/index.html
new file mode 100644
index 0000000..c69c1e9
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/main/index.html
@@ -0,0 +1,155 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+Main Page
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add New Product
+
+
+
+ {% for product in products %}
+ {% if loop.index0 % 4 == 0 %}
+ {% if not loop.first %}
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+
{{ product.name }}
+
{{ product.author }}
+
{{ product.price }}₾
+ View
+
+ {% if current_user.is_authenticated and current_user.is_admin() %}
+ Edit
+ Delete
+ {% endif %}
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
You can browse our catalog, add books to your cart, and proceed to
+ checkout
+ using your preferred payment method.
+
+
+
+
+
+
+
+
+
We accept credit/debit cards, PayPal, Apple Pay, and Google Pay.
+
+
+
+
+
+
+
+
Delivery typically takes 3–7 business days, depending on your location
+ and
+ the shipping option selected.
+
+
+
+
+
+
+
+
+
Yes, returns are accepted within 14 days of delivery if the book is
+ unused
+ and in its original condition. Contact support for return instructions.
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/partials/base.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/partials/base.html
new file mode 100644
index 0000000..ef4b590
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/partials/base.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ {% block title %}{% endblock %}
+
+
+
+
+{% include "partials/navbar.html" %}
+
+{% for message in get_flashed_messages(with_categories=True) if message %}
+
+
{{ message[1] }}
+
+{% endfor %}
+
+{% block content %}
+{% endblock %}
+
+
+
+
+
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/partials/navbar.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/partials/navbar.html
new file mode 100644
index 0000000..e4e5cb3
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/partials/navbar.html
@@ -0,0 +1,34 @@
+{% from "macros.html" import is_active %}
+{% from "macros.html" import generate_navlink %}
+
+
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/product/create_product.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/product/create_product.html
new file mode 100644
index 0000000..a227c33
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/product/create_product.html
@@ -0,0 +1,24 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+Create Product
+{% endblock %}
+
+{% block content %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/product/view_product.html b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/product/view_product.html
new file mode 100644
index 0000000..3b3927c
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/templates/product/view_product.html
@@ -0,0 +1,35 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+View Product
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
სახელი:
+
{{ product.name }}
+
+
+
ავტორი:
+
{{ product.author }}
+
+
+
ფასი:
+
{{ product.price }}₾
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/utils.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/utils.py
new file mode 100644
index 0000000..90a3480
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/utils.py
@@ -0,0 +1,16 @@
+from functools import wraps
+from flask_login import current_user
+from flask import redirect, url_for
+
+
+def admin_required(func):
+ @wraps(func)
+ def admin_view(*args, **kwargs):
+ if not current_user.is_authenticated:
+ return redirect(url_for("main.index"))
+
+ if current_user.is_authenticated and not current_user.is_admin():
+ return redirect(url_for("main.index"))
+
+ return func(*args, **kwargs)
+ return admin_view
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/__init__.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/__init__.py
new file mode 100644
index 0000000..3b3672a
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/__init__.py
@@ -0,0 +1,3 @@
+from src.views.main.routes import main_blueprint
+from src.views.auth.routes import auth_blueprint
+from src.views.product.routes import product_blueprint
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/auth/forms.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/auth/forms.py
new file mode 100644
index 0000000..49b1d1c
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/auth/forms.py
@@ -0,0 +1,72 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, SubmitField, DateField, RadioField, SelectField, EmailField, FloatField
+from wtforms.validators import DataRequired, length, equal_to, ValidationError
+from flask_wtf.file import FileField, FileRequired, FileAllowed, FileSize
+from string import ascii_uppercase, ascii_lowercase, digits, punctuation
+
+
+class LoginForm(FlaskForm):
+ username = StringField("Enter Username...", validators=[DataRequired()])
+ password = PasswordField("Enter Password...", validators=[DataRequired()])
+ login = SubmitField("Log In")
+
+
+class RegisterForm(FlaskForm):
+ username = StringField("Enter Username...",
+ validators=[DataRequired()])
+
+ email = EmailField("Enter Email...",
+ validators=[DataRequired()])
+
+ password = PasswordField("Enter Password...",
+ validators=[DataRequired(), length(min=8, max=20)])
+
+ repeat_password = PasswordField("Confirm Password...",
+ validators=[DataRequired("განმეორებითი პაროლის ველი სავალდებულოა"),
+ equal_to("password",
+ message="პაროლი და განმეორებითი პაროლი არ ემთხვევა")])
+
+ birthday = DateField("Enter Birthday...",
+ validators=[DataRequired()])
+
+ gender = RadioField("Choose Gender", choices=[(0, "Male"), (1, "Female")],
+ validators=[DataRequired()])
+
+ country = SelectField("Choose Country", choices=["Georgia", "USA", "Spain"],
+ validators=[DataRequired()])
+
+ profile_image = FileField("Upload Profile Image",
+ validators=[FileSize(1024 * 1024),
+ FileAllowed(["jpg", "jpeg", "png"])])
+ submit = SubmitField("Registration")
+
+ def validate_password(self, field):
+ contains_uppercase = False
+ contains_lowercase = False
+ contains_digits = False
+ contains_symbols = False
+
+ for char in field.data:
+ if char in ascii_uppercase:
+ contains_uppercase = True
+
+ if char in ascii_lowercase:
+ contains_lowercase = True
+
+ if char in digits:
+ contains_digits = True
+
+ if char in punctuation:
+ contains_symbols = True
+
+ if not contains_uppercase:
+ raise ValidationError("პაროლი უნდა შეიცავდეს დიდ ასოებს")
+
+ if not contains_lowercase:
+ raise ValidationError("პაროლი უნდა შეიცავდეს პატარა ასოებს")
+
+ if not contains_digits:
+ raise ValidationError("პაროლი უნდა შეიცავდეს ციფრებს")
+
+ if not contains_symbols:
+ raise ValidationError("პაროლი უნდა შეიცავდეს სიმბოლოებს")
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/auth/routes.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/auth/routes.py
new file mode 100644
index 0000000..38cd155
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/auth/routes.py
@@ -0,0 +1,56 @@
+from uuid import uuid4
+from os import path
+from flask import Blueprint, render_template, redirect, url_for, request, flash, get_flashed_messages
+from flask_login import login_user, logout_user, login_required
+
+from src.views.auth.forms import RegisterForm, LoginForm
+from src.config import Config
+from src.models import User
+
+auth_blueprint = Blueprint("auth", __name__)
+
+
+@auth_blueprint.route("/login", methods=["GET", "POST"])
+def login():
+ form = LoginForm()
+ if form.validate_on_submit():
+ user = User.query.filter(User.username == form.username.data).first()
+ if user and user.check_password(form.password.data):
+ login_user(user)
+ next = request.args.get("next")
+ if next:
+ return redirect(next)
+ flash("Logged in!", "success")
+ return redirect(url_for("main.index"))
+ else:
+ flash("Username or Password is incorrect", "danger")
+
+ return render_template("auth/login.html", form=form)
+
+
+@auth_blueprint.route("/registration", methods=["GET", "POST"])
+def registration():
+ form = RegisterForm()
+ if form.validate_on_submit():
+ file = form.profile_image.data
+ _, extension = path.splitext(file.filename)
+ filename = f"{uuid4()}{extension}"
+ file.save(path.join(Config.UPLOAD_PATH, filename))
+
+ new_user = User(username=form.username.data, password=form.password.data, profile_image=filename)
+ new_user.create()
+ else:
+ print(form.errors)
+ return render_template("auth/registration.html", form=form)
+
+
+@auth_blueprint.route("/logout")
+def logout():
+ logout_user()
+ return redirect(url_for("auth.login"))
+
+
+@auth_blueprint.route("/profile")
+@login_required
+def view_profile():
+ return render_template("auth/profile.html")
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/main/routes.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/main/routes.py
new file mode 100644
index 0000000..f4f1dfd
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/main/routes.py
@@ -0,0 +1,16 @@
+from flask import Blueprint, render_template
+
+from src.models.product import Product
+
+main_blueprint = Blueprint("main", __name__)
+
+
+@main_blueprint.route("/")
+def index():
+ products = Product.query.all()
+ return render_template("main/index.html", products=products)
+
+
+@main_blueprint.route("/about")
+def about():
+ return render_template("main/about.html")
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/product/forms.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/product/forms.py
new file mode 100644
index 0000000..df10d20
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/product/forms.py
@@ -0,0 +1,11 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, SubmitField, FloatField
+from flask_wtf.file import FileField, FileRequired, FileAllowed, FileSize
+
+class ProductForm(FlaskForm):
+ name = StringField("Enter Book Name")
+ author = StringField("Enter Author Name")
+ price = FloatField("Enter Price")
+ image = FileField("Upload Image", validators=[FileSize(1024 * 1024),
+ FileAllowed(["jpg", "png", "jpeg"])])
+ submit = SubmitField("Create Product")
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/product/routes.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/product/routes.py
new file mode 100644
index 0000000..000cc6a
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/src/views/product/routes.py
@@ -0,0 +1,69 @@
+from uuid import uuid4
+from os import path
+from flask import Blueprint, render_template, url_for, redirect
+from flask_login import login_required
+
+from src.views.product.forms import ProductForm
+from src.models.product import Product
+from src.config import Config
+from src.utils import admin_required
+
+product_blueprint = Blueprint("products", __name__)
+
+
+@product_blueprint.route("/add_product", methods=["GET", "POST"])
+@admin_required
+def add_product():
+ form = ProductForm()
+ if form.validate_on_submit():
+ file = form.image.data
+ _, extension = path.splitext(file.filename)
+ filename = f"{uuid4()}{extension}"
+ file.save(path.join(Config.UPLOAD_PATH, filename))
+
+ new_product = Product(name=form.name.data, author=form.author.data, price=form.price.data, image=filename)
+ new_product.create()
+
+ return redirect(url_for("main.index"))
+
+ return render_template("product/create_product.html", form=form)
+
+
+@product_blueprint.route("/edit_product/", methods=["GET", "POST"])
+@admin_required
+def edit_product(id):
+ product = Product.query.get(id)
+
+ form = ProductForm(name=product.name, author=product.author, price=product.price)
+ if form.validate_on_submit():
+ product.name = form.name.data
+ product.author = form.author.data
+ product.price = form.price.data
+
+ if form.image.data:
+ file = form.image.data
+ _, extension = path.splitext(file.filename)
+ filename = f"{uuid4()}{extension}"
+ file.save(path.join(Config.UPLOAD_PATH, filename))
+
+ product.image = filename
+
+ product.save()
+ return redirect(url_for("main.index"))
+
+ return render_template("product/create_product.html", form=form)
+
+
+@product_blueprint.route("/delete_product/")
+@admin_required
+def delete_product(id):
+ product = Product.query.get(id)
+ product.delete()
+
+ return redirect(url_for("main.index"))
+
+
+@product_blueprint.route("/view/")
+def view_product(product_id):
+ chosen_product = Product.query.get(product_id)
+ return render_template("product/view_product.html", product=chosen_product)
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/auth/test_auth.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/auth/test_auth.py
new file mode 100644
index 0000000..fcd5080
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/auth/test_auth.py
@@ -0,0 +1,34 @@
+from flask_login import current_user
+
+def test_login(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "wrongPass"})
+ assert current_user.is_authenticated == False
+
+ client.post("/login", data={"username": "admin", "password": "password123"})
+ assert current_user.is_authenticated == True
+
+def test_header_anonymous_user(client):
+ response = client.get("/")
+ html = response.data
+
+ assert b"Log In" in html
+ assert b"Registration" in html
+ assert b"Profile" not in html
+ assert b"Log out" not in html
+
+def test_header_authenticated_user(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "password123"})
+
+ response = client.get("/")
+ html = response.data
+
+ assert b"Log In" not in html
+ assert b"Registration" not in html
+ assert b"Profile" in html
+ assert b"Log Out" in html
+
+def test_login_wrong_password(client):
+ response = client.post("/login", data={"username": "admin", "password": "wrongPass"}, follow_redirects=True)
+ assert b"Username or Password is incorrect" in response.data
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/conftest.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/conftest.py
new file mode 100644
index 0000000..3a53a9f
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/conftest.py
@@ -0,0 +1,36 @@
+import tempfile
+import os
+import pytest
+from src.ext import admin
+from src import create_app
+from src.commands import init_db, populate_db
+
+@pytest.fixture
+def app():
+ db_fd, db_path = tempfile.mkstemp()
+ app = create_app()
+ app.config.update({
+ "TESTING": True,
+ "WTF_CSRF_ENABLED": False,
+ "DEBUG": False,
+ "SQLALCHEMY_DATABASE_URI": "sqlite:///" + db_path + ".sqlite"
+ })
+
+ admin._views = []
+
+ with app.app_context():
+ init_db()
+ populate_db()
+
+ yield app
+
+ os.close(db_fd)
+ os.unlink(db_path)
+
+@pytest.fixture
+def client(app):
+ return app.test_client()
+
+@pytest.fixture
+def server(app):
+ return app.test_cli_runner()
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/main/test_main.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/main/test_main.py
new file mode 100644
index 0000000..1d6747e
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/main/test_main.py
@@ -0,0 +1,12 @@
+def test_index(client):
+ response = client.get('/')
+ assert response.status_code == 200
+
+def test_404_page(client):
+ response = client.get('/unknown')
+ assert response.status_code == 404
+ assert b"Not Found" in response.data
+
+def test_index_content(client):
+ response = client.get('/about')
+ assert b"There is no friend as loyal as a book." in response.data
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/product/test_product.py b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/product/test_product.py
new file mode 100644
index 0000000..c0ef6e6
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/tests/product/test_product.py
@@ -0,0 +1,19 @@
+def test_add_product_unauthorized(client):
+ response = client.get('/add_product', follow_redirects=True)
+ assert response.request.path == "/"
+
+def test_add_product(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "password123"})
+
+ response = client.get('/add_product', follow_redirects=True)
+ assert b"Create Product" in response.data
+
+def test_add_product(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "password123"})
+
+ response = client.get('/', follow_redirects=True)
+ assert b"Add New Product" in response.data
+ assert b"Edit" in response.data
+ assert b"Delete" in response.data
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/uwsgi.conf b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/uwsgi.conf
new file mode 100644
index 0000000..50a5ff7
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/uwsgi.conf
@@ -0,0 +1,9 @@
+[uwsgi]
+master = true
+module = src:create_app()
+processes = 4
+threads = 8
+socket = :5000
+chmod-socket = 600
+die-on-term = true
+vacuum = true
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/readme.md b/Chapter10_TestDrivenDevelopment/Projects/readme.md
index 99fb713..5a4c6a2 100644
--- a/Chapter10_TestDrivenDevelopment/Projects/readme.md
+++ b/Chapter10_TestDrivenDevelopment/Projects/readme.md
@@ -1,6 +1,7 @@
# Unit ტესტინგის დირექტორია
- სახელი გვარი | [პროექტი](/მისამართი)
+- ნინო წიკლაური | [Book Shop](/Chapter10_TestDrivenDevelopment/Projects/Nino_Tsiklauri/app.py)
### 2025 ზაფხული
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/Dockerfile b/Chapter11_Docker/Projects/Nino_Tsiklauri/Dockerfile
new file mode 100644
index 0000000..1868130
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/Dockerfile
@@ -0,0 +1,12 @@
+FROM python:3.13-slim-bookworm
+
+WORKDIR /demo
+
+COPY . .
+
+RUN apt-get update && apt-get -y install gcc build-essential python3-dev
+RUN pip install -r prod-requirements.txt
+
+EXPOSE 5000
+
+CMD ["uwsgi", "uwsgi.ini"]
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/app.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/app.py
new file mode 100644
index 0000000..ce3460f
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/app.py
@@ -0,0 +1,6 @@
+from src import create_app
+
+app = create_app()
+
+if __name__ == "__main__":
+ app.run(debug=True)
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/dev-requirements.txt b/Chapter11_Docker/Projects/Nino_Tsiklauri/dev-requirements.txt
new file mode 100644
index 0000000..e5bc481
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/dev-requirements.txt differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/docker-compose.yml b/Chapter11_Docker/Projects/Nino_Tsiklauri/docker-compose.yml
new file mode 100644
index 0000000..a10f921
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+ nginx:
+ build: ./nginx
+ container_name: nginx
+ ports:
+ - "8080:80"
+ restart: always
+ depends_on:
+ - flask
+
+ flask:
+ build: .
+ container_name: flask_docker
+ expose:
+ - "5000"
+ restart: always
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/README b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/README
new file mode 100644
index 0000000..0e04844
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/README
@@ -0,0 +1 @@
+Single-database configuration for Flask.
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/alembic.ini b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/alembic.ini
new file mode 100644
index 0000000..ec9d45c
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/alembic.ini
@@ -0,0 +1,50 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic,flask_migrate
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[logger_flask_migrate]
+level = INFO
+handlers =
+qualname = flask_migrate
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/env.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/env.py
new file mode 100644
index 0000000..4c97092
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/env.py
@@ -0,0 +1,113 @@
+import logging
+from logging.config import fileConfig
+
+from flask import current_app
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+
+def get_engine():
+ try:
+ # this works with Flask-SQLAlchemy<3 and Alchemical
+ return current_app.extensions['migrate'].db.get_engine()
+ except (TypeError, AttributeError):
+ # this works with Flask-SQLAlchemy>=3
+ return current_app.extensions['migrate'].db.engine
+
+
+def get_engine_url():
+ try:
+ return get_engine().url.render_as_string(hide_password=False).replace(
+ '%', '%%')
+ except AttributeError:
+ return str(get_engine().url).replace('%', '%%')
+
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+config.set_main_option('sqlalchemy.url', get_engine_url())
+target_db = current_app.extensions['migrate'].db
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def get_metadata():
+ if hasattr(target_db, 'metadatas'):
+ return target_db.metadatas[None]
+ return target_db.metadata
+
+
+def run_migrations_offline():
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url, target_metadata=get_metadata(), literal_binds=True
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online():
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+
+ # this callback is used to prevent an auto-migration from being generated
+ # when there are no changes to the schema
+ # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
+ def process_revision_directives(context, revision, directives):
+ if getattr(config.cmd_opts, 'autogenerate', False):
+ script = directives[0]
+ if script.upgrade_ops.is_empty():
+ directives[:] = []
+ logger.info('No changes in schema detected.')
+
+ conf_args = current_app.extensions['migrate'].configure_args
+ if conf_args.get("process_revision_directives") is None:
+ conf_args["process_revision_directives"] = process_revision_directives
+
+ connectable = get_engine()
+
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection,
+ target_metadata=get_metadata(),
+ **conf_args
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/script.py.mako b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/script.py.mako
new file mode 100644
index 0000000..2c01563
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py
new file mode 100644
index 0000000..586b48a
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py
@@ -0,0 +1,32 @@
+"""Added ragint to Movies
+
+Revision ID: 32b86fbcd47b
+Revises:
+Create Date: 2025-05-31 20:51:33.043357
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '32b86fbcd47b'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('movies', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('rating', sa.Float(), nullable=True))
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('movies', schema=None) as batch_op:
+ batch_op.drop_column('rating')
+
+ # ### end Alembic commands ###
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/versions/5b5008085981_added_users_table.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/versions/5b5008085981_added_users_table.py
new file mode 100644
index 0000000..b3f35cc
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/migrations/versions/5b5008085981_added_users_table.py
@@ -0,0 +1,34 @@
+"""Added Users table
+
+Revision ID: 5b5008085981
+Revises: 32b86fbcd47b
+Create Date: 2025-06-01 20:57:15.373884
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '5b5008085981'
+down_revision = '32b86fbcd47b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('users',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('username', sa.String(), nullable=True),
+ sa.Column('password', sa.String(), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('username')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('users')
+ # ### end Alembic commands ###
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/nginx/Dockerfile b/Chapter11_Docker/Projects/Nino_Tsiklauri/nginx/Dockerfile
new file mode 100644
index 0000000..baa066f
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/nginx/Dockerfile
@@ -0,0 +1,3 @@
+FROM nginx:1.27.5
+RUN rm /etc/nginx/conf.d/default.conf
+COPY default.conf /etc/nginx/conf.d/
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/nginx/default.conf b/Chapter11_Docker/Projects/Nino_Tsiklauri/nginx/default.conf
new file mode 100644
index 0000000..139ee8d
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/nginx/default.conf
@@ -0,0 +1,9 @@
+server {
+ listen 80;
+ server_name 127.0.0.1;
+
+ location / {
+ include uwsgi_params;
+ uwsgi_pass flask_docker:5000;
+ }
+}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/prod-requirements.txt b/Chapter11_Docker/Projects/Nino_Tsiklauri/prod-requirements.txt
new file mode 100644
index 0000000..397c7f8
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/prod-requirements.txt differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/__init__.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/__init__.py
new file mode 100644
index 0000000..7d3164d
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/__init__.py
@@ -0,0 +1,57 @@
+from flask import Flask
+from flask_admin.menu import MenuLink
+
+from src.config import Config
+from src.ext import db, migrate, login_manager, admin
+from src.views import main_blueprint, auth_blueprint, product_blueprint
+from src.commands import init_db, populate_db, init_db_command, populate_db_command
+from src.models import User, Product
+from src.admin_views.base import SecureModelView
+from src.admin_views import UserView, ProductView
+
+BLUEPRINTS = [main_blueprint, auth_blueprint, product_blueprint]
+COMMANDS = [init_db_command, populate_db_command]
+
+
+def create_app():
+ app = Flask(__name__)
+ app.config.from_object(Config)
+
+ register_extensions(app)
+ register_blueprints(app)
+ register_commands(app)
+
+ return app
+
+
+def register_extensions(app):
+ # Flask-SQLAlchemy
+ db.init_app(app)
+
+ # Flask-Migrate
+ migrate.init_app(app, db)
+
+ # Flask-Login
+ login_manager.init_app(app)
+ login_manager.login_view = 'auth.login'
+
+ @login_manager.user_loader
+ def load_user(_id):
+ return User.query.get(_id)
+
+ # Flask-Admin
+ admin.init_app(app)
+ admin.add_view(UserView(User, db.session))
+ admin.add_view(ProductView(Product, db.session))
+
+ admin.add_link(MenuLink("To Site", url="/", icon_type="fa", icon_value="fa-sign-out"))
+
+
+def register_blueprints(app):
+ for blueprint in BLUEPRINTS:
+ app.register_blueprint(blueprint)
+
+
+def register_commands(app):
+ for command in COMMANDS:
+ app.cli.add_command(command)
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/__init__.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/__init__.py
new file mode 100644
index 0000000..980d5af
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/__init__.py
@@ -0,0 +1,2 @@
+from src.admin_views.user import UserView
+from src.admin_views.product import ProductView
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/base.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/base.py
new file mode 100644
index 0000000..b70082f
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/base.py
@@ -0,0 +1,23 @@
+from flask_admin.contrib.sqla import ModelView
+from flask_admin import AdminIndexView
+from flask_login import current_user
+from flask import redirect, url_for
+
+class SecureModelView(ModelView):
+ def is_accessible(self):
+ return current_user.is_authenticated and current_user.is_admin()
+
+
+ def inaccessible_callback(self, name, **kwargs):
+ if not self.is_accessible():
+ return redirect(url_for("auth.login"))
+
+
+class SecureIndexView(AdminIndexView):
+ def is_accessible(self):
+ return current_user.is_authenticated and current_user.is_admin()
+
+
+ def inaccessible_callback(self, name, **kwargs):
+ if not self.is_accessible():
+ return redirect(url_for("auth.login"))
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/product.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/product.py
new file mode 100644
index 0000000..9db4c70
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/product.py
@@ -0,0 +1,31 @@
+from src.admin_views.base import SecureModelView
+from src.config import Config
+
+from flask_admin.form import ImageUploadField
+from os import path
+from uuid import uuid4
+from markupsafe import Markup
+
+def generate_filename(obj, file):
+ name, extension = path.splitext(file.filename)
+ return f"{uuid4()}{extension}"
+
+class ProductView(SecureModelView):
+
+ create_modal = True
+ edit_modal = True
+ column_editable_list = _list = ("price",)
+ column_filters = ("price", "name")
+
+ column_formatters = {
+ "name": lambda v,c,m,n: m.name if len(m.name) <15 else m.name[0:15] + "...",
+ "image": lambda v,c,m,n: Markup(f"")
+ }
+
+ form_overrides = {"image": ImageUploadField}
+ form_args = {
+ "image": {
+ "base_path": Config.UPLOAD_PATH,
+ "namegen": generate_filename
+ }
+ }
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/user.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/user.py
new file mode 100644
index 0000000..db13583
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/admin_views/user.py
@@ -0,0 +1,16 @@
+from src.admin_views.base import SecureModelView
+from wtforms.fields import SelectField
+
+class UserView(SecureModelView):
+ can_view_details = True
+ can_edit = False
+ can_create = True
+ can_delete = False
+
+ column_exclude_list = ['_password',]
+ column_searchable_list = ['username', 'role']
+
+ form_overrides = {"role": SelectField}
+ form_args = {"role":{
+ "choices":["Admin", "Moderator", "User"]
+ }}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/commands.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/commands.py
new file mode 100644
index 0000000..ef7bb3f
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/commands.py
@@ -0,0 +1,78 @@
+from flask.cli import with_appcontext
+import click
+import datetime
+
+from src.ext import db
+from src.models import Person, Product, IDCard, Actor, Movie, ActorMovie, University, Student, User
+
+def init_db():
+ db.drop_all()
+ db.create_all()
+
+def populate_db():
+ products = [
+ {"id": 0, "name": "ფრანით მორბენალი", "author": "ხალიდ ჰოსეინი", "price": "25", "img": "franit-morbenali.png"},
+ {"id": 1, "name": "48 კანონი", "author": "რობერტ გრინი", "price": "40", "img": "dzalauflebis_48_kanoni.jpg"},
+ {"id": 2, "name": "ატომური ჩვევები", "author": "ჯეიმს ქლიერი", "price": "35", "img": "atomuri_cvevebi.jpg"},
+ {"id": 3, "name": "ხაფანგი 22", "author": "ჟოზეფ ჰელერის", "price": "30", "img": "xafangi22.png"}
+ ]
+
+ for product in products:
+ new_product = Product(name=product["name"], author=product["author"], price=product["price"],
+ image=product["img"])
+ db.session.add(new_product)
+ db.session.commit()
+
+ ### ONE TO ONE EXAMPLE DATA ###
+ id_card = IDCard(personal_number="12345678901", serial_number="XXX", expiry_date=datetime.datetime.now())
+ db.session.add(id_card)
+ db.session.commit()
+
+ person = Person(name="John", surname="Doe", birthday=datetime.datetime.now(), idcard_id=id_card.id)
+ db.session.add(person)
+ db.session.commit()
+
+ ### ONE TO MANY EXAMPLE DATA ###
+ university = University(name="GTU", address="Teqnikuri")
+ db.session.add(university)
+ db.session.commit()
+
+ student1 = Student(name="Jeiran Doadze", university_id=university.id)
+ student2 = Student(name="Joanna Smth", university_id=university.id)
+ db.session.add_all([student1, student2])
+ db.session.commit()
+
+ ### MANY TO MANY EXAMPLE DATA ###
+ actor1 = Actor(name="Robert Downey Jr")
+ actor2 = Actor(name="Chris Evans")
+
+ movie1 = Movie(name="Iron Man", genre="Fantasy, Action")
+ movie2 = Movie(name="Captain America", genre="Action")
+ movie3 = Movie(name="Avengers", genre="Action")
+
+ db.session.add_all([actor1, actor2])
+ db.session.add_all([movie1, movie2, movie3])
+ db.session.commit()
+
+ actormovie1 = ActorMovie(actor_id=1, movie_id=1)
+ actormovie2 = ActorMovie(actor_id=2, movie_id=2)
+ actormovie3 = ActorMovie(actor_id=1, movie_id=3)
+ actormovie4 = ActorMovie(actor_id=2, movie_id=3)
+ db.session.add_all([actormovie1, actormovie2, actormovie3, actormovie4])
+ db.session.commit()
+
+ ### ADMIN USER ###
+ User(username="admin", password="password123", profile_image="static/assets/admin.jpg", role="Admin").create()
+
+@click.command("init_db")
+@with_appcontext
+def init_db_command():
+ click.echo("initializing database...")
+ init_db()
+ click.echo("Initialized database")
+
+
+@click.command("populate_db")
+@with_appcontext
+def populate_db_command():
+ populate_db()
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/config.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/config.py
new file mode 100644
index 0000000..f0d8c0c
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/config.py
@@ -0,0 +1,11 @@
+from os import path, environ
+
+
+class Config(object):
+ BASE_DIRECTORY = path.abspath(path.dirname(__file__))
+
+ SQLALCHEMY_DATABASE_URI = "sqlite:///database.db"
+ SECRET_KEY = environ.get("SECRET_KEY", "defaultsecretkey123!456@")
+ FLASK_ADMIN_SWATCH = 'Cerulean'
+
+ UPLOAD_PATH = path.join(BASE_DIRECTORY, "static", "upload")
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/ext.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/ext.py
new file mode 100644
index 0000000..3732832
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/ext.py
@@ -0,0 +1,11 @@
+from flask_sqlalchemy import SQLAlchemy
+from flask_migrate import Migrate
+from flask_login import LoginManager
+from flask_admin import Admin
+
+from src.admin_views.base import SecureIndexView
+
+db = SQLAlchemy()
+migrate = Migrate()
+login_manager = LoginManager()
+admin = Admin(name="Admin Panel", template_mode="bootstrap4", index_view=SecureIndexView(), base_template="admin/admin_base.html")
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/__init__.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/__init__.py
new file mode 100644
index 0000000..ef7ab98
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/__init__.py
@@ -0,0 +1,5 @@
+from src.models.product import Product
+from src.models.person import Person, IDCard
+from src.models.actor_movie import Actor, Movie, ActorMovie
+from src.models.university_student import University, Student
+from src.models.user import User
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/actor_movie.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/actor_movie.py
new file mode 100644
index 0000000..f00bf7c
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/actor_movie.py
@@ -0,0 +1,28 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+
+### MANY TO MANY RELATIONSHIP ###
+class Actor(BaseModel):
+ __tablename__ = "actors"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+
+ movies = db.relationship("Movie", back_populates="actors", secondary="actor_movie")
+
+
+class ActorMovie(BaseModel):
+ __tablename__ = "actor_movie"
+ id = db.Column(db.Integer, primary_key=True)
+ actor_id = db.Column(db.Integer, db.ForeignKey("actors.id"))
+ movie_id = db.Column(db.Integer, db.ForeignKey("movies.id"))
+
+
+class Movie(BaseModel):
+ __tablename__ = "movies"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ genre = db.Column(db.String)
+ rating = db.Column(db.Float)
+
+ actors = db.relationship("Actor", back_populates="movies", secondary="actor_movie")
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/base.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/base.py
new file mode 100644
index 0000000..f633c82
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/base.py
@@ -0,0 +1,21 @@
+from src.ext import db
+
+
+class BaseModel(db.Model):
+
+ __abstract__ = True
+
+ def create(self, commit=True):
+ db.session.add(self)
+
+ if commit:
+ self.save()
+
+ def save(self):
+ db.session.commit()
+
+ def delete(self, commit=True):
+ db.session.delete(self)
+
+ if commit:
+ self.save()
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/person.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/person.py
new file mode 100644
index 0000000..2e8ff95
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/person.py
@@ -0,0 +1,25 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+### ONE TO ONE RELATIONSHIP ###
+
+
+class Person(BaseModel):
+ __tablename__ = "people"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ surname = db.Column(db.String)
+ birthday = db.Column(db.Date)
+ idcard_id = db.Column(db.Integer, db.ForeignKey("id_cards.id"))
+
+ id_card = db.relationship("IDCard", back_populates="person")
+
+
+class IDCard(BaseModel):
+ __tablename__ = "id_cards"
+ id = db.Column(db.Integer, primary_key=True)
+ personal_number = db.Column(db.String)
+ serial_number = db.Column(db.String)
+ expiry_date = db.Column(db.Date)
+
+ person = db.relationship("Person", back_populates="id_card", uselist=False)
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/product.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/product.py
new file mode 100644
index 0000000..385b457
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/product.py
@@ -0,0 +1,14 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+
+class Product(BaseModel):
+ __tablename__ = "products"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ author = db.Column(db.String)
+ price = db.Column(db.Float)
+ image = db.Column(db.String)
+
+ def __repr__(self):
+ return self.name
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/university_student.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/university_student.py
new file mode 100644
index 0000000..729b3fe
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/university_student.py
@@ -0,0 +1,21 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+
+### ONE TO MANY RELATIONSHIP ###
+class University(BaseModel):
+ __tablename__ = "universities"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ address = db.Column(db.String)
+
+ students = db.relationship("Student", back_populates="university")
+
+
+class Student(BaseModel):
+ __tablename__ = "students"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ university_id = db.Column(db.Integer, db.ForeignKey("universities.id"))
+
+ university = db.relationship("University", back_populates="students")
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/user.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/user.py
new file mode 100644
index 0000000..5ba4afc
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/models/user.py
@@ -0,0 +1,30 @@
+from flask_login import UserMixin
+from werkzeug.security import generate_password_hash, check_password_hash
+
+from src.ext import db
+from src.models.base import BaseModel
+
+
+class User (BaseModel, UserMixin):
+ __tablename__ = 'users'
+
+ id = db.Column(db.Integer, primary_key=True)
+ username = db.Column(db.String, unique=True)
+ _password = db.Column(db.String)
+ profile_image = db.Column(db.String)
+ role = db.Column(db.String)
+
+ def check_password(self, password):
+ return check_password_hash(self.password, password)
+
+ def is_admin(self):
+ return self.role == "Admin"
+
+ #getter
+ @property
+ def password(self):
+ return self._password
+
+ @password.setter
+ def password(self, password):
+ self._password = generate_password_hash(password)
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/78b3e9f4-6112-47c3-abe7-85a15523909f.jpg b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/78b3e9f4-6112-47c3-abe7-85a15523909f.jpg
new file mode 100644
index 0000000..cf75dd6
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/78b3e9f4-6112-47c3-abe7-85a15523909f.jpg differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/admin.jpg b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/admin.jpg
new file mode 100644
index 0000000..a24f56c
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/admin.jpg differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/atomuri_cvevebi.jpg b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/atomuri_cvevebi.jpg
new file mode 100644
index 0000000..ec569a3
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/atomuri_cvevebi.jpg differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/b0416bf9-44d3-44a1-bddb-1e8e5b63db03.jpg b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/b0416bf9-44d3-44a1-bddb-1e8e5b63db03.jpg
new file mode 100644
index 0000000..6c85cee
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/b0416bf9-44d3-44a1-bddb-1e8e5b63db03.jpg differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/cover1.png b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/cover1.png
new file mode 100644
index 0000000..137a914
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/cover1.png differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/cover2.png b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/cover2.png
new file mode 100644
index 0000000..97af56e
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/cover2.png differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/cover3.png b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/cover3.png
new file mode 100644
index 0000000..77a3d43
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/cover3.png differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/dzalauflebis_48_kanoni.jpg b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/dzalauflebis_48_kanoni.jpg
new file mode 100644
index 0000000..e8fd535
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/dzalauflebis_48_kanoni.jpg differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/franit-morbenali.png b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/franit-morbenali.png
new file mode 100644
index 0000000..cb752bd
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/franit-morbenali.png differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/xafangi22.png b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/xafangi22.png
new file mode 100644
index 0000000..a275dcb
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/assets/xafangi22.png differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/css/styles.css b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/css/styles.css
new file mode 100644
index 0000000..02817d6
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/css/styles.css
@@ -0,0 +1,91 @@
+body {
+ background-color: rgb(212, 215, 255);
+}
+
+.container {
+ padding: 0px;
+ margin-top: 5px;
+}
+
+#carouselExampleIndicators {
+ margin-bottom: 20px;
+}
+
+.card {
+ cursor: pointer;
+ margin-bottom: 20px;
+}
+
+#accordionFlushExample {
+ margin-top: 50px;
+ margin-bottom: 50px;
+}
+
+nav {
+ background-color: rgb(43, 0, 99);
+}
+
+.btn {
+
+ border: none;
+}
+
+.btn a {
+ text-decoration: none;
+ color: white;
+}
+
+.pagination {
+ justify-content: center;
+ background-color: rgb(212, 215, 255);
+ margin-top: 20px;
+}
+
+.book_shop img {
+ width: 200px;
+ border-radius: 10px;
+}
+
+.registration {
+ margin-top: 30px;
+ width: 300px;
+}
+
+.registration input {
+ margin-bottom: 15px;
+}
+
+.nav-btn{
+ border: 1px solid rgb(0, 0, 185);
+}
+
+.nav-btn:hover{
+ background-color: rgb(0, 0, 185);
+ color: white
+}
+
+.registration-btn, .login-btn {
+ border: none;
+ padding: 8px;
+ background-color: blue;
+ border-radius: 5px;
+ color: white;
+}
+.registration-btn:hover{
+ background-color: rgb(0, 0, 200);
+}
+
+.login-btn:hover{
+ background-color: rgb(0, 0, 200);
+ color: white
+}
+
+.price{
+ color: rgb(51, 107, 172);
+ font-weight: bold;
+}
+.product-img {
+ width: 100%;
+ height: 250px;
+ object-fit: cover;
+}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/252a4acb-f0be-4d6d-b1d6-0011fc613313.png b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/252a4acb-f0be-4d6d-b1d6-0011fc613313.png
new file mode 100644
index 0000000..40b526e
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/252a4acb-f0be-4d6d-b1d6-0011fc613313.png differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/a5348e6c-142a-472d-9658-d433620c0173.jpg b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/a5348e6c-142a-472d-9658-d433620c0173.jpg
new file mode 100644
index 0000000..72809b0
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/a5348e6c-142a-472d-9658-d433620c0173.jpg differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/aed61a25-b537-4891-bf51-20260f5f7bb9.jpg b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/aed61a25-b537-4891-bf51-20260f5f7bb9.jpg
new file mode 100644
index 0000000..72809b0
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/aed61a25-b537-4891-bf51-20260f5f7bb9.jpg differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/c9cdea47-70fd-4636-b5cd-88b1605f1a63.png b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/c9cdea47-70fd-4636-b5cd-88b1605f1a63.png
new file mode 100644
index 0000000..40b526e
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/c9cdea47-70fd-4636-b5cd-88b1605f1a63.png differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/cb25879f-83a3-4ce7-87da-33829bd0125a.png b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/cb25879f-83a3-4ce7-87da-33829bd0125a.png
new file mode 100644
index 0000000..40b526e
Binary files /dev/null and b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/static/upload/cb25879f-83a3-4ce7-87da-33829bd0125a.png differ
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/admin/admin_base.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/admin/admin_base.html
new file mode 100644
index 0000000..09286ee
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/admin/admin_base.html
@@ -0,0 +1,43 @@
+{% extends "admin/base.html" %}
+
+{% block page_body %}
+
+
+
+ {% block messages %}
+ {{ layout.messages() }}
+ {% endblock %}
+
+ {# store the jinja2 context for form_rules rendering logic #}
+ {% set render_ctx = h.resolve_ctx() %}
+
+ {% block body %}{% endblock %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/auth/login.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/auth/login.html
new file mode 100644
index 0000000..51f3d00
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/auth/login.html
@@ -0,0 +1,17 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+LogIn
+{% endblock %}
+
+{% block content %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/auth/profile.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/auth/profile.html
new file mode 100644
index 0000000..0a833b9
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/auth/profile.html
@@ -0,0 +1,31 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+Viewing profile - {{ current_user.username }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
Username:
+
{{ current_user.username }}
+
+
+
Password:
+
{{ current_user.password }}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/auth/registration.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/auth/registration.html
new file mode 100644
index 0000000..b5d29a0
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/auth/registration.html
@@ -0,0 +1,29 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+Registration
+{% endblock %}
+
+{% block content %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/macros.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/macros.html
new file mode 100644
index 0000000..25cc5fe
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/macros.html
@@ -0,0 +1,14 @@
+{% macro is_active(endpoint_name) %}
+ {% if endpoint_name == request.endpoint %}
+ active
+ {% endif %}
+{% endmacro %}
+
+{% macro generate_navlink(name, endpoint) %}
+
+{% endmacro %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/main/about.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/main/about.html
new file mode 100644
index 0000000..66f60f3
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/main/about.html
@@ -0,0 +1,30 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+About
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Nestled in the heart of the old town, The Book Nook has been a cozy corner for readers since
+ 1998.
+ Whether you're chasing ancient tales, modern mysteries, or just a quiet place with a cat named
+ Oliver napping by the window, you'll find your next chapter here.
+
+
+
+
+
+
“There is no friend as loyal as a book.” — Ernest Hemingway
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/main/index.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/main/index.html
new file mode 100644
index 0000000..c69c1e9
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/main/index.html
@@ -0,0 +1,155 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+Main Page
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add New Product
+
+
+
+ {% for product in products %}
+ {% if loop.index0 % 4 == 0 %}
+ {% if not loop.first %}
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+
{{ product.name }}
+
{{ product.author }}
+
{{ product.price }}₾
+ View
+
+ {% if current_user.is_authenticated and current_user.is_admin() %}
+ Edit
+ Delete
+ {% endif %}
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
You can browse our catalog, add books to your cart, and proceed to
+ checkout
+ using your preferred payment method.
+
+
+
+
+
+
+
+
+
We accept credit/debit cards, PayPal, Apple Pay, and Google Pay.
+
+
+
+
+
+
+
+
Delivery typically takes 3–7 business days, depending on your location
+ and
+ the shipping option selected.
+
+
+
+
+
+
+
+
+
Yes, returns are accepted within 14 days of delivery if the book is
+ unused
+ and in its original condition. Contact support for return instructions.
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/partials/base.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/partials/base.html
new file mode 100644
index 0000000..ef4b590
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/partials/base.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ {% block title %}{% endblock %}
+
+
+
+
+{% include "partials/navbar.html" %}
+
+{% for message in get_flashed_messages(with_categories=True) if message %}
+
+
{{ message[1] }}
+
+{% endfor %}
+
+{% block content %}
+{% endblock %}
+
+
+
+
+
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/partials/navbar.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/partials/navbar.html
new file mode 100644
index 0000000..e4e5cb3
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/partials/navbar.html
@@ -0,0 +1,34 @@
+{% from "macros.html" import is_active %}
+{% from "macros.html" import generate_navlink %}
+
+
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/product/create_product.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/product/create_product.html
new file mode 100644
index 0000000..a227c33
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/product/create_product.html
@@ -0,0 +1,24 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+Create Product
+{% endblock %}
+
+{% block content %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/product/view_product.html b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/product/view_product.html
new file mode 100644
index 0000000..3b3927c
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/templates/product/view_product.html
@@ -0,0 +1,35 @@
+{% extends "partials/base.html" %}
+
+{% block title %}
+View Product
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
სახელი:
+
{{ product.name }}
+
+
+
ავტორი:
+
{{ product.author }}
+
+
+
ფასი:
+
{{ product.price }}₾
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/utils.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/utils.py
new file mode 100644
index 0000000..90a3480
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/utils.py
@@ -0,0 +1,16 @@
+from functools import wraps
+from flask_login import current_user
+from flask import redirect, url_for
+
+
+def admin_required(func):
+ @wraps(func)
+ def admin_view(*args, **kwargs):
+ if not current_user.is_authenticated:
+ return redirect(url_for("main.index"))
+
+ if current_user.is_authenticated and not current_user.is_admin():
+ return redirect(url_for("main.index"))
+
+ return func(*args, **kwargs)
+ return admin_view
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/__init__.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/__init__.py
new file mode 100644
index 0000000..3b3672a
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/__init__.py
@@ -0,0 +1,3 @@
+from src.views.main.routes import main_blueprint
+from src.views.auth.routes import auth_blueprint
+from src.views.product.routes import product_blueprint
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/auth/forms.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/auth/forms.py
new file mode 100644
index 0000000..49b1d1c
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/auth/forms.py
@@ -0,0 +1,72 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, SubmitField, DateField, RadioField, SelectField, EmailField, FloatField
+from wtforms.validators import DataRequired, length, equal_to, ValidationError
+from flask_wtf.file import FileField, FileRequired, FileAllowed, FileSize
+from string import ascii_uppercase, ascii_lowercase, digits, punctuation
+
+
+class LoginForm(FlaskForm):
+ username = StringField("Enter Username...", validators=[DataRequired()])
+ password = PasswordField("Enter Password...", validators=[DataRequired()])
+ login = SubmitField("Log In")
+
+
+class RegisterForm(FlaskForm):
+ username = StringField("Enter Username...",
+ validators=[DataRequired()])
+
+ email = EmailField("Enter Email...",
+ validators=[DataRequired()])
+
+ password = PasswordField("Enter Password...",
+ validators=[DataRequired(), length(min=8, max=20)])
+
+ repeat_password = PasswordField("Confirm Password...",
+ validators=[DataRequired("განმეორებითი პაროლის ველი სავალდებულოა"),
+ equal_to("password",
+ message="პაროლი და განმეორებითი პაროლი არ ემთხვევა")])
+
+ birthday = DateField("Enter Birthday...",
+ validators=[DataRequired()])
+
+ gender = RadioField("Choose Gender", choices=[(0, "Male"), (1, "Female")],
+ validators=[DataRequired()])
+
+ country = SelectField("Choose Country", choices=["Georgia", "USA", "Spain"],
+ validators=[DataRequired()])
+
+ profile_image = FileField("Upload Profile Image",
+ validators=[FileSize(1024 * 1024),
+ FileAllowed(["jpg", "jpeg", "png"])])
+ submit = SubmitField("Registration")
+
+ def validate_password(self, field):
+ contains_uppercase = False
+ contains_lowercase = False
+ contains_digits = False
+ contains_symbols = False
+
+ for char in field.data:
+ if char in ascii_uppercase:
+ contains_uppercase = True
+
+ if char in ascii_lowercase:
+ contains_lowercase = True
+
+ if char in digits:
+ contains_digits = True
+
+ if char in punctuation:
+ contains_symbols = True
+
+ if not contains_uppercase:
+ raise ValidationError("პაროლი უნდა შეიცავდეს დიდ ასოებს")
+
+ if not contains_lowercase:
+ raise ValidationError("პაროლი უნდა შეიცავდეს პატარა ასოებს")
+
+ if not contains_digits:
+ raise ValidationError("პაროლი უნდა შეიცავდეს ციფრებს")
+
+ if not contains_symbols:
+ raise ValidationError("პაროლი უნდა შეიცავდეს სიმბოლოებს")
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/auth/routes.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/auth/routes.py
new file mode 100644
index 0000000..38cd155
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/auth/routes.py
@@ -0,0 +1,56 @@
+from uuid import uuid4
+from os import path
+from flask import Blueprint, render_template, redirect, url_for, request, flash, get_flashed_messages
+from flask_login import login_user, logout_user, login_required
+
+from src.views.auth.forms import RegisterForm, LoginForm
+from src.config import Config
+from src.models import User
+
+auth_blueprint = Blueprint("auth", __name__)
+
+
+@auth_blueprint.route("/login", methods=["GET", "POST"])
+def login():
+ form = LoginForm()
+ if form.validate_on_submit():
+ user = User.query.filter(User.username == form.username.data).first()
+ if user and user.check_password(form.password.data):
+ login_user(user)
+ next = request.args.get("next")
+ if next:
+ return redirect(next)
+ flash("Logged in!", "success")
+ return redirect(url_for("main.index"))
+ else:
+ flash("Username or Password is incorrect", "danger")
+
+ return render_template("auth/login.html", form=form)
+
+
+@auth_blueprint.route("/registration", methods=["GET", "POST"])
+def registration():
+ form = RegisterForm()
+ if form.validate_on_submit():
+ file = form.profile_image.data
+ _, extension = path.splitext(file.filename)
+ filename = f"{uuid4()}{extension}"
+ file.save(path.join(Config.UPLOAD_PATH, filename))
+
+ new_user = User(username=form.username.data, password=form.password.data, profile_image=filename)
+ new_user.create()
+ else:
+ print(form.errors)
+ return render_template("auth/registration.html", form=form)
+
+
+@auth_blueprint.route("/logout")
+def logout():
+ logout_user()
+ return redirect(url_for("auth.login"))
+
+
+@auth_blueprint.route("/profile")
+@login_required
+def view_profile():
+ return render_template("auth/profile.html")
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/main/routes.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/main/routes.py
new file mode 100644
index 0000000..f4f1dfd
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/main/routes.py
@@ -0,0 +1,16 @@
+from flask import Blueprint, render_template
+
+from src.models.product import Product
+
+main_blueprint = Blueprint("main", __name__)
+
+
+@main_blueprint.route("/")
+def index():
+ products = Product.query.all()
+ return render_template("main/index.html", products=products)
+
+
+@main_blueprint.route("/about")
+def about():
+ return render_template("main/about.html")
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/product/forms.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/product/forms.py
new file mode 100644
index 0000000..df10d20
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/product/forms.py
@@ -0,0 +1,11 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, SubmitField, FloatField
+from flask_wtf.file import FileField, FileRequired, FileAllowed, FileSize
+
+class ProductForm(FlaskForm):
+ name = StringField("Enter Book Name")
+ author = StringField("Enter Author Name")
+ price = FloatField("Enter Price")
+ image = FileField("Upload Image", validators=[FileSize(1024 * 1024),
+ FileAllowed(["jpg", "png", "jpeg"])])
+ submit = SubmitField("Create Product")
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/product/routes.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/product/routes.py
new file mode 100644
index 0000000..000cc6a
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/src/views/product/routes.py
@@ -0,0 +1,69 @@
+from uuid import uuid4
+from os import path
+from flask import Blueprint, render_template, url_for, redirect
+from flask_login import login_required
+
+from src.views.product.forms import ProductForm
+from src.models.product import Product
+from src.config import Config
+from src.utils import admin_required
+
+product_blueprint = Blueprint("products", __name__)
+
+
+@product_blueprint.route("/add_product", methods=["GET", "POST"])
+@admin_required
+def add_product():
+ form = ProductForm()
+ if form.validate_on_submit():
+ file = form.image.data
+ _, extension = path.splitext(file.filename)
+ filename = f"{uuid4()}{extension}"
+ file.save(path.join(Config.UPLOAD_PATH, filename))
+
+ new_product = Product(name=form.name.data, author=form.author.data, price=form.price.data, image=filename)
+ new_product.create()
+
+ return redirect(url_for("main.index"))
+
+ return render_template("product/create_product.html", form=form)
+
+
+@product_blueprint.route("/edit_product/", methods=["GET", "POST"])
+@admin_required
+def edit_product(id):
+ product = Product.query.get(id)
+
+ form = ProductForm(name=product.name, author=product.author, price=product.price)
+ if form.validate_on_submit():
+ product.name = form.name.data
+ product.author = form.author.data
+ product.price = form.price.data
+
+ if form.image.data:
+ file = form.image.data
+ _, extension = path.splitext(file.filename)
+ filename = f"{uuid4()}{extension}"
+ file.save(path.join(Config.UPLOAD_PATH, filename))
+
+ product.image = filename
+
+ product.save()
+ return redirect(url_for("main.index"))
+
+ return render_template("product/create_product.html", form=form)
+
+
+@product_blueprint.route("/delete_product/")
+@admin_required
+def delete_product(id):
+ product = Product.query.get(id)
+ product.delete()
+
+ return redirect(url_for("main.index"))
+
+
+@product_blueprint.route("/view/")
+def view_product(product_id):
+ chosen_product = Product.query.get(product_id)
+ return render_template("product/view_product.html", product=chosen_product)
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/auth/test_auth.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/auth/test_auth.py
new file mode 100644
index 0000000..fcd5080
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/auth/test_auth.py
@@ -0,0 +1,34 @@
+from flask_login import current_user
+
+def test_login(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "wrongPass"})
+ assert current_user.is_authenticated == False
+
+ client.post("/login", data={"username": "admin", "password": "password123"})
+ assert current_user.is_authenticated == True
+
+def test_header_anonymous_user(client):
+ response = client.get("/")
+ html = response.data
+
+ assert b"Log In" in html
+ assert b"Registration" in html
+ assert b"Profile" not in html
+ assert b"Log out" not in html
+
+def test_header_authenticated_user(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "password123"})
+
+ response = client.get("/")
+ html = response.data
+
+ assert b"Log In" not in html
+ assert b"Registration" not in html
+ assert b"Profile" in html
+ assert b"Log Out" in html
+
+def test_login_wrong_password(client):
+ response = client.post("/login", data={"username": "admin", "password": "wrongPass"}, follow_redirects=True)
+ assert b"Username or Password is incorrect" in response.data
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/conftest.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/conftest.py
new file mode 100644
index 0000000..3a53a9f
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/conftest.py
@@ -0,0 +1,36 @@
+import tempfile
+import os
+import pytest
+from src.ext import admin
+from src import create_app
+from src.commands import init_db, populate_db
+
+@pytest.fixture
+def app():
+ db_fd, db_path = tempfile.mkstemp()
+ app = create_app()
+ app.config.update({
+ "TESTING": True,
+ "WTF_CSRF_ENABLED": False,
+ "DEBUG": False,
+ "SQLALCHEMY_DATABASE_URI": "sqlite:///" + db_path + ".sqlite"
+ })
+
+ admin._views = []
+
+ with app.app_context():
+ init_db()
+ populate_db()
+
+ yield app
+
+ os.close(db_fd)
+ os.unlink(db_path)
+
+@pytest.fixture
+def client(app):
+ return app.test_client()
+
+@pytest.fixture
+def server(app):
+ return app.test_cli_runner()
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/main/test_main.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/main/test_main.py
new file mode 100644
index 0000000..1d6747e
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/main/test_main.py
@@ -0,0 +1,12 @@
+def test_index(client):
+ response = client.get('/')
+ assert response.status_code == 200
+
+def test_404_page(client):
+ response = client.get('/unknown')
+ assert response.status_code == 404
+ assert b"Not Found" in response.data
+
+def test_index_content(client):
+ response = client.get('/about')
+ assert b"There is no friend as loyal as a book." in response.data
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/product/test_product.py b/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/product/test_product.py
new file mode 100644
index 0000000..c0ef6e6
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/tests/product/test_product.py
@@ -0,0 +1,19 @@
+def test_add_product_unauthorized(client):
+ response = client.get('/add_product', follow_redirects=True)
+ assert response.request.path == "/"
+
+def test_add_product(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "password123"})
+
+ response = client.get('/add_product', follow_redirects=True)
+ assert b"Create Product" in response.data
+
+def test_add_product(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "password123"})
+
+ response = client.get('/', follow_redirects=True)
+ assert b"Add New Product" in response.data
+ assert b"Edit" in response.data
+ assert b"Delete" in response.data
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Nino_Tsiklauri/uwsgi.ini b/Chapter11_Docker/Projects/Nino_Tsiklauri/uwsgi.ini
new file mode 100644
index 0000000..50a5ff7
--- /dev/null
+++ b/Chapter11_Docker/Projects/Nino_Tsiklauri/uwsgi.ini
@@ -0,0 +1,9 @@
+[uwsgi]
+master = true
+module = src:create_app()
+processes = 4
+threads = 8
+socket = :5000
+chmod-socket = 600
+die-on-term = true
+vacuum = true
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/readme.md b/Chapter11_Docker/Projects/readme.md
index f5ad8b1..fc391e7 100644
--- a/Chapter11_Docker/Projects/readme.md
+++ b/Chapter11_Docker/Projects/readme.md
@@ -1,5 +1,6 @@
# დოკერის დირექტორია
- სახელი გვარი | [პროექტი](/მისამართი)
+- ნინო წიკლაური | [DockerHub Link](https://hub.docker.com/r/tsiklaurii/book-shop)
### 2025 ზაფხული
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/Dockerfile b/ExtraChapters/Flask-RestX/Dockerfile
new file mode 100644
index 0000000..1868130
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/Dockerfile
@@ -0,0 +1,12 @@
+FROM python:3.13-slim-bookworm
+
+WORKDIR /demo
+
+COPY . .
+
+RUN apt-get update && apt-get -y install gcc build-essential python3-dev
+RUN pip install -r prod-requirements.txt
+
+EXPOSE 5000
+
+CMD ["uwsgi", "uwsgi.ini"]
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/app.py b/ExtraChapters/Flask-RestX/app.py
new file mode 100644
index 0000000..ce3460f
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/app.py
@@ -0,0 +1,6 @@
+from src import create_app
+
+app = create_app()
+
+if __name__ == "__main__":
+ app.run(debug=True)
diff --git a/ExtraChapters/Flask-RestX/dev-requirements.txt b/ExtraChapters/Flask-RestX/dev-requirements.txt
new file mode 100644
index 0000000..18a605d
Binary files /dev/null and b/ExtraChapters/Flask-RestX/dev-requirements.txt differ
diff --git a/ExtraChapters/Flask-RestX/docker-compose.yml b/ExtraChapters/Flask-RestX/docker-compose.yml
new file mode 100644
index 0000000..a10f921
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+ nginx:
+ build: ./nginx
+ container_name: nginx
+ ports:
+ - "8080:80"
+ restart: always
+ depends_on:
+ - flask
+
+ flask:
+ build: .
+ container_name: flask_docker
+ expose:
+ - "5000"
+ restart: always
diff --git a/ExtraChapters/Flask-RestX/migrations/README b/ExtraChapters/Flask-RestX/migrations/README
new file mode 100644
index 0000000..0e04844
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/migrations/README
@@ -0,0 +1 @@
+Single-database configuration for Flask.
diff --git a/ExtraChapters/Flask-RestX/migrations/alembic.ini b/ExtraChapters/Flask-RestX/migrations/alembic.ini
new file mode 100644
index 0000000..ec9d45c
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/migrations/alembic.ini
@@ -0,0 +1,50 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic,flask_migrate
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[logger_flask_migrate]
+level = INFO
+handlers =
+qualname = flask_migrate
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/ExtraChapters/Flask-RestX/migrations/env.py b/ExtraChapters/Flask-RestX/migrations/env.py
new file mode 100644
index 0000000..4c97092
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/migrations/env.py
@@ -0,0 +1,113 @@
+import logging
+from logging.config import fileConfig
+
+from flask import current_app
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+
+def get_engine():
+ try:
+ # this works with Flask-SQLAlchemy<3 and Alchemical
+ return current_app.extensions['migrate'].db.get_engine()
+ except (TypeError, AttributeError):
+ # this works with Flask-SQLAlchemy>=3
+ return current_app.extensions['migrate'].db.engine
+
+
+def get_engine_url():
+ try:
+ return get_engine().url.render_as_string(hide_password=False).replace(
+ '%', '%%')
+ except AttributeError:
+ return str(get_engine().url).replace('%', '%%')
+
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+config.set_main_option('sqlalchemy.url', get_engine_url())
+target_db = current_app.extensions['migrate'].db
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def get_metadata():
+ if hasattr(target_db, 'metadatas'):
+ return target_db.metadatas[None]
+ return target_db.metadata
+
+
+def run_migrations_offline():
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url, target_metadata=get_metadata(), literal_binds=True
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online():
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+
+ # this callback is used to prevent an auto-migration from being generated
+ # when there are no changes to the schema
+ # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
+ def process_revision_directives(context, revision, directives):
+ if getattr(config.cmd_opts, 'autogenerate', False):
+ script = directives[0]
+ if script.upgrade_ops.is_empty():
+ directives[:] = []
+ logger.info('No changes in schema detected.')
+
+ conf_args = current_app.extensions['migrate'].configure_args
+ if conf_args.get("process_revision_directives") is None:
+ conf_args["process_revision_directives"] = process_revision_directives
+
+ connectable = get_engine()
+
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection,
+ target_metadata=get_metadata(),
+ **conf_args
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/ExtraChapters/Flask-RestX/migrations/script.py.mako b/ExtraChapters/Flask-RestX/migrations/script.py.mako
new file mode 100644
index 0000000..2c01563
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/migrations/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/ExtraChapters/Flask-RestX/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py b/ExtraChapters/Flask-RestX/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py
new file mode 100644
index 0000000..586b48a
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py
@@ -0,0 +1,32 @@
+"""Added ragint to Movies
+
+Revision ID: 32b86fbcd47b
+Revises:
+Create Date: 2025-05-31 20:51:33.043357
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '32b86fbcd47b'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('movies', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('rating', sa.Float(), nullable=True))
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('movies', schema=None) as batch_op:
+ batch_op.drop_column('rating')
+
+ # ### end Alembic commands ###
diff --git a/ExtraChapters/Flask-RestX/migrations/versions/5b5008085981_added_users_table.py b/ExtraChapters/Flask-RestX/migrations/versions/5b5008085981_added_users_table.py
new file mode 100644
index 0000000..b3f35cc
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/migrations/versions/5b5008085981_added_users_table.py
@@ -0,0 +1,34 @@
+"""Added Users table
+
+Revision ID: 5b5008085981
+Revises: 32b86fbcd47b
+Create Date: 2025-06-01 20:57:15.373884
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '5b5008085981'
+down_revision = '32b86fbcd47b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('users',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('username', sa.String(), nullable=True),
+ sa.Column('password', sa.String(), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('username')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('users')
+ # ### end Alembic commands ###
diff --git a/ExtraChapters/Flask-RestX/nginx/Dockerfile b/ExtraChapters/Flask-RestX/nginx/Dockerfile
new file mode 100644
index 0000000..baa066f
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/nginx/Dockerfile
@@ -0,0 +1,3 @@
+FROM nginx:1.27.5
+RUN rm /etc/nginx/conf.d/default.conf
+COPY default.conf /etc/nginx/conf.d/
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/nginx/default.conf b/ExtraChapters/Flask-RestX/nginx/default.conf
new file mode 100644
index 0000000..139ee8d
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/nginx/default.conf
@@ -0,0 +1,9 @@
+server {
+ listen 80;
+ server_name 127.0.0.1;
+
+ location / {
+ include uwsgi_params;
+ uwsgi_pass flask_docker:5000;
+ }
+}
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/prod-requirements.txt b/ExtraChapters/Flask-RestX/prod-requirements.txt
new file mode 100644
index 0000000..982be2b
Binary files /dev/null and b/ExtraChapters/Flask-RestX/prod-requirements.txt differ
diff --git a/ExtraChapters/Flask-RestX/src/__init__.py b/ExtraChapters/Flask-RestX/src/__init__.py
new file mode 100644
index 0000000..fbee3a4
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/__init__.py
@@ -0,0 +1,47 @@
+from flask import Flask
+
+from src.models import User
+from src.config import Config
+from src.ext import db, migrate, api, jwt
+from src.commands import init_db, populate_db, init_db_command, populate_db_command
+from src.endpoints import ProductApi
+
+COMMANDS = [init_db_command, populate_db_command]
+
+
+def create_app():
+ app = Flask(__name__)
+ app.config.from_object(Config)
+
+ register_extensions(app)
+ register_commands(app)
+
+ return app
+
+
+def register_extensions(app):
+ # Flask-SQLAlchemy
+ db.init_app(app)
+
+ # Flask-Migrate
+ migrate.init_app(app, db)
+
+ #Flask-RestX
+ api.init_app(app)
+
+ #Flask-JWT-Extended
+ jwt.init_app(app)
+
+ @jwt.user_identity_loader
+ def user_identity_loader(user):
+ return user.username
+
+ @jwt.user_lookup_loader
+ def user_lookup_callback(_jwt_header, jwt_data):
+ username = jwt_data["sub"]
+ return User.query.filter_by(username=username).first()
+
+
+def register_commands(app):
+ for command in COMMANDS:
+ app.cli.add_command(command)
diff --git a/ExtraChapters/Flask-RestX/src/commands.py b/ExtraChapters/Flask-RestX/src/commands.py
new file mode 100644
index 0000000..ef7bb3f
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/commands.py
@@ -0,0 +1,78 @@
+from flask.cli import with_appcontext
+import click
+import datetime
+
+from src.ext import db
+from src.models import Person, Product, IDCard, Actor, Movie, ActorMovie, University, Student, User
+
+def init_db():
+ db.drop_all()
+ db.create_all()
+
+def populate_db():
+ products = [
+ {"id": 0, "name": "ფრანით მორბენალი", "author": "ხალიდ ჰოსეინი", "price": "25", "img": "franit-morbenali.png"},
+ {"id": 1, "name": "48 კანონი", "author": "რობერტ გრინი", "price": "40", "img": "dzalauflebis_48_kanoni.jpg"},
+ {"id": 2, "name": "ატომური ჩვევები", "author": "ჯეიმს ქლიერი", "price": "35", "img": "atomuri_cvevebi.jpg"},
+ {"id": 3, "name": "ხაფანგი 22", "author": "ჟოზეფ ჰელერის", "price": "30", "img": "xafangi22.png"}
+ ]
+
+ for product in products:
+ new_product = Product(name=product["name"], author=product["author"], price=product["price"],
+ image=product["img"])
+ db.session.add(new_product)
+ db.session.commit()
+
+ ### ONE TO ONE EXAMPLE DATA ###
+ id_card = IDCard(personal_number="12345678901", serial_number="XXX", expiry_date=datetime.datetime.now())
+ db.session.add(id_card)
+ db.session.commit()
+
+ person = Person(name="John", surname="Doe", birthday=datetime.datetime.now(), idcard_id=id_card.id)
+ db.session.add(person)
+ db.session.commit()
+
+ ### ONE TO MANY EXAMPLE DATA ###
+ university = University(name="GTU", address="Teqnikuri")
+ db.session.add(university)
+ db.session.commit()
+
+ student1 = Student(name="Jeiran Doadze", university_id=university.id)
+ student2 = Student(name="Joanna Smth", university_id=university.id)
+ db.session.add_all([student1, student2])
+ db.session.commit()
+
+ ### MANY TO MANY EXAMPLE DATA ###
+ actor1 = Actor(name="Robert Downey Jr")
+ actor2 = Actor(name="Chris Evans")
+
+ movie1 = Movie(name="Iron Man", genre="Fantasy, Action")
+ movie2 = Movie(name="Captain America", genre="Action")
+ movie3 = Movie(name="Avengers", genre="Action")
+
+ db.session.add_all([actor1, actor2])
+ db.session.add_all([movie1, movie2, movie3])
+ db.session.commit()
+
+ actormovie1 = ActorMovie(actor_id=1, movie_id=1)
+ actormovie2 = ActorMovie(actor_id=2, movie_id=2)
+ actormovie3 = ActorMovie(actor_id=1, movie_id=3)
+ actormovie4 = ActorMovie(actor_id=2, movie_id=3)
+ db.session.add_all([actormovie1, actormovie2, actormovie3, actormovie4])
+ db.session.commit()
+
+ ### ADMIN USER ###
+ User(username="admin", password="password123", profile_image="static/assets/admin.jpg", role="Admin").create()
+
+@click.command("init_db")
+@with_appcontext
+def init_db_command():
+ click.echo("initializing database...")
+ init_db()
+ click.echo("Initialized database")
+
+
+@click.command("populate_db")
+@with_appcontext
+def populate_db_command():
+ populate_db()
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/config.py b/ExtraChapters/Flask-RestX/src/config.py
new file mode 100644
index 0000000..0fe1b82
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/config.py
@@ -0,0 +1,22 @@
+from datetime import timedelta
+from os import path, environ
+
+
+class Config(object):
+ BASE_DIRECTORY = path.abspath(path.dirname(__file__))
+
+ SQLALCHEMY_DATABASE_URI = "sqlite:///database.db"
+ SECRET_KEY = environ.get("SECRET_KEY", "defaultsecretkey123!456@")
+ JWT_SECRET_KEY = "super-secret-key"
+ JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=10)
+ JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
+ FLASK_ADMIN_SWATCH = 'Cerulean'
+
+ UPLOAD_PATH = path.join(BASE_DIRECTORY, "static", "upload")
+ SWAGGER_AUTHORIZATION = {
+ "JsonWebToken": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "Authorization"
+ }
+ }
diff --git a/ExtraChapters/Flask-RestX/src/endpoints/__init__.py b/ExtraChapters/Flask-RestX/src/endpoints/__init__.py
new file mode 100644
index 0000000..7fc7175
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/endpoints/__init__.py
@@ -0,0 +1,5 @@
+from src.endpoints.product.product_api import ProductApi
+from src.endpoints.person.person_api import PersonApi
+
+from src.endpoints.auth.login_api import LoginApi
+from src.endpoints.auth.refresh_api import AccessTokenRefreshApi
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/endpoints/auth/__init__.py b/ExtraChapters/Flask-RestX/src/endpoints/auth/__init__.py
new file mode 100644
index 0000000..670ab62
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/endpoints/auth/__init__.py
@@ -0,0 +1,12 @@
+from src.ext import api
+
+from flask_restx import reqparse
+
+auth_ns = api.namespace("Auth", description="ავტორიზაცია")
+
+login_parser = reqparse.RequestParser()
+login_parser.add_argument("username", type=str, required=True)
+login_parser.add_argument("password", type=str, required=True)
+
+refresh_parser = reqparse.RequestParser()
+refresh_parser.add_argument("jwt", location="args", required=True)
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/endpoints/auth/login_api.py b/ExtraChapters/Flask-RestX/src/endpoints/auth/login_api.py
new file mode 100644
index 0000000..d952d81
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/endpoints/auth/login_api.py
@@ -0,0 +1,28 @@
+from flask_restx import Resource
+from flask_jwt_extended import create_access_token, create_refresh_token
+
+from src.ext import api
+from src.models import User
+from src.endpoints.auth import auth_ns, login_parser
+
+@auth_ns.route("/login")
+class LoginApi(Resource):
+
+ @auth_ns.doc(parser=login_parser)
+ def post(self):
+ args = login_parser.parse_args()
+ user = User.query.filter_by(username=args["username"]).first()
+ if not user:
+ return "მომხმარებელი ვერ მოიძებნა", 404
+
+ if user.check_password(args["password"]):
+ access_token = create_access_token(identity=user)
+ refresh_token = create_refresh_token(identity=user)
+
+ response = {
+ "access_token": access_token,
+ "refresh_token": refresh_token
+ }
+ return response
+ else:
+ return "მონაცემები არ არის სწორი", 400
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/endpoints/auth/refresh_api.py b/ExtraChapters/Flask-RestX/src/endpoints/auth/refresh_api.py
new file mode 100644
index 0000000..566f9e0
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/endpoints/auth/refresh_api.py
@@ -0,0 +1,20 @@
+from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity
+from flask_restx import Resource
+
+from src import User
+from src.endpoints.auth import auth_ns, refresh_parser
+
+@auth_ns.route("/refresh_token")
+class AccessTokenRefreshApi(Resource):
+
+ @auth_ns.doc(parser=refresh_parser)
+ @jwt_required(refresh=True, locations=["query_string"])
+ def post(self):
+ client = get_jwt_identity()
+ user = User.query.filter_by(username=client).first()
+
+ access_token = create_access_token(identity=user)
+ response = {
+ "access_token": access_token
+ }
+ return response
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/endpoints/person/__init__.py b/ExtraChapters/Flask-RestX/src/endpoints/person/__init__.py
new file mode 100644
index 0000000..f2cf0e1
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/endpoints/person/__init__.py
@@ -0,0 +1,20 @@
+from flask_restx import inputs
+from src.ext import api
+from flask_restx import reqparse, fields
+
+person_ns = api.namespace("person", description="უზერები")
+
+del_parser = reqparse.RequestParser()
+del_parser.add_argument("id", type=int, required=True)
+
+post_parser = reqparse.RequestParser()
+post_parser.add_argument("name", type=str, required=True, location="json")
+post_parser.add_argument("surname", type=str, required=True, location="json")
+post_parser.add_argument("birthday", type=inputs.date_from_iso8601, required=True, location="json")
+
+person_model = api.model("Person", {
+ "id": fields.Integer,
+ "name": fields.String,
+ "surname": fields.String,
+ "birthday": fields.DateTime
+})
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/endpoints/person/person_api.py b/ExtraChapters/Flask-RestX/src/endpoints/person/person_api.py
new file mode 100644
index 0000000..3bf3c49
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/endpoints/person/person_api.py
@@ -0,0 +1,33 @@
+from flask_restx import Resource
+from flask_jwt_extended import jwt_required
+
+from src.endpoints.person import person_ns, person_model, post_parser, del_parser
+from src.models import Person
+
+@person_ns.route("/")
+class PersonApi(Resource):
+
+ @jwt_required()
+ @person_ns.doc(security="JsonWebToken")
+ @person_ns.marshal_with(person_model)
+ def get(self):
+ persons = Person.query.all()
+ return persons
+
+ @jwt_required()
+ @person_ns.doc(security="JsonWebToken")
+ @person_ns.doc(parser=post_parser)
+ def post(self):
+ args = post_parser.parse_args()
+ person = Person(name=args["name"], surname=args["surname"], birthday=args["birthday"])
+ person.create()
+ return person.id, 200
+
+ @person_ns.doc(parser=del_parser)
+ def delete(self):
+ args = del_parser.parse_args()
+
+ person = Person.query.get(args["id"])
+ person.delete()
+ person.save()
+ return "Success", 200
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/endpoints/product/__init__.py b/ExtraChapters/Flask-RestX/src/endpoints/product/__init__.py
new file mode 100644
index 0000000..76363f4
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/endpoints/product/__init__.py
@@ -0,0 +1,20 @@
+from src.ext import api
+from flask_restx import reqparse, fields
+
+product_ns = api.namespace("product", description="პროდუქტები")
+
+del_parser = reqparse.RequestParser()
+del_parser.add_argument("id", type=int, required=True)
+
+post_parser = reqparse.RequestParser()
+post_parser.add_argument("name", type=str, required=True, location="json")
+post_parser.add_argument("author", type=str, required=True, location="json")
+post_parser.add_argument("price", type=float, required=True, location="json")
+
+product_model = api.model("Product", {
+ "id": fields.Integer,
+ "name": fields.String,
+ "author": fields.String,
+ "price": fields.Float,
+ "image": fields.String
+})
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/endpoints/product/product_api.py b/ExtraChapters/Flask-RestX/src/endpoints/product/product_api.py
new file mode 100644
index 0000000..8259efa
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/endpoints/product/product_api.py
@@ -0,0 +1,31 @@
+from flask_restx import Resource
+
+from src.endpoints.product import product_ns, product_model, post_parser, del_parser
+from src.models import Product
+
+@product_ns.route("/")
+class ProductApi(Resource):
+
+ @product_ns.marshal_with(product_model)
+ def get(self):
+ products = Product.query.all()
+ return products
+
+ @product_ns.doc(parser=post_parser)
+ def post(self):
+ args = post_parser.parse_args()
+ product = Product(name=args["name"], author=args["author"], price=args["price"])
+ product.create()
+ return product.id, 200
+
+ @product_ns.doc(parser=del_parser)
+ def delete(self):
+ args = del_parser.parse_args()
+
+ product = Product.query.get(args["id"])
+ if not product:
+ return "Not Found", 404
+
+ product.delete()
+ product.save()
+ return "Success", 200
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/ext.py b/ExtraChapters/Flask-RestX/src/ext.py
new file mode 100644
index 0000000..2676423
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/ext.py
@@ -0,0 +1,11 @@
+from flask_sqlalchemy import SQLAlchemy
+from flask_migrate import Migrate
+from flask_restx import Api
+from flask_jwt_extended import JWTManager
+
+from src.config import Config
+
+db = SQLAlchemy()
+migrate = Migrate()
+api = Api(title="My Api", authorizations=Config.SWAGGER_AUTHORIZATION)
+jwt = JWTManager()
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/models/__init__.py b/ExtraChapters/Flask-RestX/src/models/__init__.py
new file mode 100644
index 0000000..ef7ab98
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/models/__init__.py
@@ -0,0 +1,5 @@
+from src.models.product import Product
+from src.models.person import Person, IDCard
+from src.models.actor_movie import Actor, Movie, ActorMovie
+from src.models.university_student import University, Student
+from src.models.user import User
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/models/actor_movie.py b/ExtraChapters/Flask-RestX/src/models/actor_movie.py
new file mode 100644
index 0000000..f00bf7c
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/models/actor_movie.py
@@ -0,0 +1,28 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+
+### MANY TO MANY RELATIONSHIP ###
+class Actor(BaseModel):
+ __tablename__ = "actors"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+
+ movies = db.relationship("Movie", back_populates="actors", secondary="actor_movie")
+
+
+class ActorMovie(BaseModel):
+ __tablename__ = "actor_movie"
+ id = db.Column(db.Integer, primary_key=True)
+ actor_id = db.Column(db.Integer, db.ForeignKey("actors.id"))
+ movie_id = db.Column(db.Integer, db.ForeignKey("movies.id"))
+
+
+class Movie(BaseModel):
+ __tablename__ = "movies"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ genre = db.Column(db.String)
+ rating = db.Column(db.Float)
+
+ actors = db.relationship("Actor", back_populates="movies", secondary="actor_movie")
diff --git a/ExtraChapters/Flask-RestX/src/models/base.py b/ExtraChapters/Flask-RestX/src/models/base.py
new file mode 100644
index 0000000..f633c82
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/models/base.py
@@ -0,0 +1,21 @@
+from src.ext import db
+
+
+class BaseModel(db.Model):
+
+ __abstract__ = True
+
+ def create(self, commit=True):
+ db.session.add(self)
+
+ if commit:
+ self.save()
+
+ def save(self):
+ db.session.commit()
+
+ def delete(self, commit=True):
+ db.session.delete(self)
+
+ if commit:
+ self.save()
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/models/person.py b/ExtraChapters/Flask-RestX/src/models/person.py
new file mode 100644
index 0000000..2e8ff95
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/models/person.py
@@ -0,0 +1,25 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+### ONE TO ONE RELATIONSHIP ###
+
+
+class Person(BaseModel):
+ __tablename__ = "people"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ surname = db.Column(db.String)
+ birthday = db.Column(db.Date)
+ idcard_id = db.Column(db.Integer, db.ForeignKey("id_cards.id"))
+
+ id_card = db.relationship("IDCard", back_populates="person")
+
+
+class IDCard(BaseModel):
+ __tablename__ = "id_cards"
+ id = db.Column(db.Integer, primary_key=True)
+ personal_number = db.Column(db.String)
+ serial_number = db.Column(db.String)
+ expiry_date = db.Column(db.Date)
+
+ person = db.relationship("Person", back_populates="id_card", uselist=False)
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/src/models/product.py b/ExtraChapters/Flask-RestX/src/models/product.py
new file mode 100644
index 0000000..385b457
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/models/product.py
@@ -0,0 +1,14 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+
+class Product(BaseModel):
+ __tablename__ = "products"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ author = db.Column(db.String)
+ price = db.Column(db.Float)
+ image = db.Column(db.String)
+
+ def __repr__(self):
+ return self.name
diff --git a/ExtraChapters/Flask-RestX/src/models/university_student.py b/ExtraChapters/Flask-RestX/src/models/university_student.py
new file mode 100644
index 0000000..729b3fe
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/models/university_student.py
@@ -0,0 +1,21 @@
+from src.ext import db
+from src.models.base import BaseModel
+
+
+### ONE TO MANY RELATIONSHIP ###
+class University(BaseModel):
+ __tablename__ = "universities"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ address = db.Column(db.String)
+
+ students = db.relationship("Student", back_populates="university")
+
+
+class Student(BaseModel):
+ __tablename__ = "students"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String)
+ university_id = db.Column(db.Integer, db.ForeignKey("universities.id"))
+
+ university = db.relationship("University", back_populates="students")
diff --git a/ExtraChapters/Flask-RestX/src/models/user.py b/ExtraChapters/Flask-RestX/src/models/user.py
new file mode 100644
index 0000000..5ba4afc
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/src/models/user.py
@@ -0,0 +1,30 @@
+from flask_login import UserMixin
+from werkzeug.security import generate_password_hash, check_password_hash
+
+from src.ext import db
+from src.models.base import BaseModel
+
+
+class User (BaseModel, UserMixin):
+ __tablename__ = 'users'
+
+ id = db.Column(db.Integer, primary_key=True)
+ username = db.Column(db.String, unique=True)
+ _password = db.Column(db.String)
+ profile_image = db.Column(db.String)
+ role = db.Column(db.String)
+
+ def check_password(self, password):
+ return check_password_hash(self.password, password)
+
+ def is_admin(self):
+ return self.role == "Admin"
+
+ #getter
+ @property
+ def password(self):
+ return self._password
+
+ @password.setter
+ def password(self, password):
+ self._password = generate_password_hash(password)
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/tests/auth/test_auth.py b/ExtraChapters/Flask-RestX/tests/auth/test_auth.py
new file mode 100644
index 0000000..fcd5080
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/tests/auth/test_auth.py
@@ -0,0 +1,34 @@
+from flask_login import current_user
+
+def test_login(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "wrongPass"})
+ assert current_user.is_authenticated == False
+
+ client.post("/login", data={"username": "admin", "password": "password123"})
+ assert current_user.is_authenticated == True
+
+def test_header_anonymous_user(client):
+ response = client.get("/")
+ html = response.data
+
+ assert b"Log In" in html
+ assert b"Registration" in html
+ assert b"Profile" not in html
+ assert b"Log out" not in html
+
+def test_header_authenticated_user(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "password123"})
+
+ response = client.get("/")
+ html = response.data
+
+ assert b"Log In" not in html
+ assert b"Registration" not in html
+ assert b"Profile" in html
+ assert b"Log Out" in html
+
+def test_login_wrong_password(client):
+ response = client.post("/login", data={"username": "admin", "password": "wrongPass"}, follow_redirects=True)
+ assert b"Username or Password is incorrect" in response.data
diff --git a/ExtraChapters/Flask-RestX/tests/conftest.py b/ExtraChapters/Flask-RestX/tests/conftest.py
new file mode 100644
index 0000000..3a53a9f
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/tests/conftest.py
@@ -0,0 +1,36 @@
+import tempfile
+import os
+import pytest
+from src.ext import admin
+from src import create_app
+from src.commands import init_db, populate_db
+
+@pytest.fixture
+def app():
+ db_fd, db_path = tempfile.mkstemp()
+ app = create_app()
+ app.config.update({
+ "TESTING": True,
+ "WTF_CSRF_ENABLED": False,
+ "DEBUG": False,
+ "SQLALCHEMY_DATABASE_URI": "sqlite:///" + db_path + ".sqlite"
+ })
+
+ admin._views = []
+
+ with app.app_context():
+ init_db()
+ populate_db()
+
+ yield app
+
+ os.close(db_fd)
+ os.unlink(db_path)
+
+@pytest.fixture
+def client(app):
+ return app.test_client()
+
+@pytest.fixture
+def server(app):
+ return app.test_cli_runner()
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/tests/main/test_main.py b/ExtraChapters/Flask-RestX/tests/main/test_main.py
new file mode 100644
index 0000000..1d6747e
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/tests/main/test_main.py
@@ -0,0 +1,12 @@
+def test_index(client):
+ response = client.get('/')
+ assert response.status_code == 200
+
+def test_404_page(client):
+ response = client.get('/unknown')
+ assert response.status_code == 404
+ assert b"Not Found" in response.data
+
+def test_index_content(client):
+ response = client.get('/about')
+ assert b"There is no friend as loyal as a book." in response.data
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/tests/product/test_product.py b/ExtraChapters/Flask-RestX/tests/product/test_product.py
new file mode 100644
index 0000000..c0ef6e6
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/tests/product/test_product.py
@@ -0,0 +1,19 @@
+def test_add_product_unauthorized(client):
+ response = client.get('/add_product', follow_redirects=True)
+ assert response.request.path == "/"
+
+def test_add_product(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "password123"})
+
+ response = client.get('/add_product', follow_redirects=True)
+ assert b"Create Product" in response.data
+
+def test_add_product(client):
+ with client:
+ client.post("/login", data={"username": "admin", "password": "password123"})
+
+ response = client.get('/', follow_redirects=True)
+ assert b"Add New Product" in response.data
+ assert b"Edit" in response.data
+ assert b"Delete" in response.data
\ No newline at end of file
diff --git a/ExtraChapters/Flask-RestX/uwsgi.ini b/ExtraChapters/Flask-RestX/uwsgi.ini
new file mode 100644
index 0000000..50a5ff7
--- /dev/null
+++ b/ExtraChapters/Flask-RestX/uwsgi.ini
@@ -0,0 +1,9 @@
+[uwsgi]
+master = true
+module = src:create_app()
+processes = 4
+threads = 8
+socket = :5000
+chmod-socket = 600
+die-on-term = true
+vacuum = true
\ No newline at end of file