diff --git a/Chapter08_User/Projects/Nino_Tsiklauri/dev-requirements.txt b/Chapter08_User/Projects/Nino_Tsiklauri/dev-requirements.txt new file mode 100644 index 0000000..e5bc481 Binary files /dev/null and b/Chapter08_User/Projects/Nino_Tsiklauri/dev-requirements.txt differ diff --git a/Chapter08_User/Projects/Nino_Tsiklauri/prod-requirements.txt b/Chapter08_User/Projects/Nino_Tsiklauri/prod-requirements.txt new file mode 100644 index 0000000..9623f57 Binary files /dev/null and b/Chapter08_User/Projects/Nino_Tsiklauri/prod-requirements.txt differ diff --git a/Chapter08_User/Projects/Nino_Tsiklauri/src/static/upload/c9cdea47-70fd-4636-b5cd-88b1605f1a63.png b/Chapter08_User/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/Chapter08_User/Projects/Nino_Tsiklauri/src/static/upload/c9cdea47-70fd-4636-b5cd-88b1605f1a63.png differ diff --git a/Chapter08_User/Projects/Nino_Tsiklauri/uwsgi.conf b/Chapter08_User/Projects/Nino_Tsiklauri/uwsgi.conf new file mode 100644 index 0000000..50a5ff7 --- /dev/null +++ b/Chapter08_User/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/Chapter08_User/Projects/Readme.md b/Chapter08_User/Projects/Readme.md index d36b8ae..b0c3f11 100644 --- a/Chapter08_User/Projects/Readme.md +++ b/Chapter08_User/Projects/Readme.md @@ -2,5 +2,6 @@ მაგალითი: - სახელი გვარი | [პროექტი](/მისამართი) +- ნინო წიკლაური | [Book Shop](/Chapter08_User/Projects/Nino_Tsiklauri/app.py) ### 2025 ზაფხული diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/app.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/app.py new file mode 100644 index 0000000..ce3460f --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/dev-requirements.txt b/Chapter09_Admin/Projects/Nino_Tsiklauri/dev-requirements.txt new file mode 100644 index 0000000..e5bc481 Binary files /dev/null and b/Chapter09_Admin/Projects/Nino_Tsiklauri/dev-requirements.txt differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/README b/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/alembic.ini b/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/env.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/script.py.mako b/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/versions/32b86fbcd47b_added_ragint_to_movies.py new file mode 100644 index 0000000..586b48a --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/versions/5b5008085981_added_users_table.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/migrations/versions/5b5008085981_added_users_table.py new file mode 100644 index 0000000..b3f35cc --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/prod-requirements.txt b/Chapter09_Admin/Projects/Nino_Tsiklauri/prod-requirements.txt new file mode 100644 index 0000000..9623f57 Binary files /dev/null and b/Chapter09_Admin/Projects/Nino_Tsiklauri/prod-requirements.txt differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/__init__.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/__init__.py new file mode 100644 index 0000000..e678632 --- /dev/null +++ b/Chapter09_Admin/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 +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, populate_db] + + +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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/admin_views/__init__.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/admin_views/__init__.py new file mode 100644 index 0000000..980d5af --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/admin_views/base.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/admin_views/base.py new file mode 100644 index 0000000..b70082f --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/admin_views/product.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/admin_views/product.py new file mode 100644 index 0000000..9db4c70 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/admin_views/user.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/admin_views/user.py new file mode 100644 index 0000000..db13583 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/commands.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/commands.py new file mode 100644 index 0000000..b5703d4 --- /dev/null +++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/commands.py @@ -0,0 +1,77 @@ +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 + + +@click.command("init_db") +@with_appcontext +def init_db(): + click.echo("initializing database...") + + db.drop_all() + db.create_all() + + click.echo("Initialized database") + + +@click.command("populate_db") +@with_appcontext +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() \ No newline at end of file diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/config.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/config.py new file mode 100644 index 0000000..f0d8c0c --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/ext.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/ext.py new file mode 100644 index 0000000..3732832 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/__init__.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/__init__.py new file mode 100644 index 0000000..ef7ab98 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/actor_movie.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/actor_movie.py new file mode 100644 index 0000000..f00bf7c --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/base.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/base.py new file mode 100644 index 0000000..f633c82 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/person.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/person.py new file mode 100644 index 0000000..2e8ff95 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/product.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/product.py new file mode 100644 index 0000000..385b457 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/university_student.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/university_student.py new file mode 100644 index 0000000..729b3fe --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/user.py b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/models/user.py new file mode 100644 index 0000000..5ba4afc --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/78b3e9f4-6112-47c3-abe7-85a15523909f.jpg b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/78b3e9f4-6112-47c3-abe7-85a15523909f.jpg differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/admin.jpg b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/admin.jpg new file mode 100644 index 0000000..a24f56c Binary files /dev/null and b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/admin.jpg differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/atomuri_cvevebi.jpg b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/atomuri_cvevebi.jpg new file mode 100644 index 0000000..ec569a3 Binary files /dev/null and b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/atomuri_cvevebi.jpg differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/b0416bf9-44d3-44a1-bddb-1e8e5b63db03.jpg b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/b0416bf9-44d3-44a1-bddb-1e8e5b63db03.jpg differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/cover1.png b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/cover1.png new file mode 100644 index 0000000..137a914 Binary files /dev/null and b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/cover1.png differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/cover2.png b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/cover2.png new file mode 100644 index 0000000..97af56e Binary files /dev/null and b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/cover2.png differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/cover3.png b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/cover3.png new file mode 100644 index 0000000..77a3d43 Binary files /dev/null and b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/cover3.png differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/dzalauflebis_48_kanoni.jpg b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/dzalauflebis_48_kanoni.jpg new file mode 100644 index 0000000..e8fd535 Binary files /dev/null and b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/dzalauflebis_48_kanoni.jpg differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/franit-morbenali.png b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/franit-morbenali.png new file mode 100644 index 0000000..cb752bd Binary files /dev/null and b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/franit-morbenali.png differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/xafangi22.png b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/xafangi22.png new file mode 100644 index 0000000..a275dcb Binary files /dev/null and b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/assets/xafangi22.png differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/css/styles.css b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/css/styles.css new file mode 100644 index 0000000..02817d6 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/upload/252a4acb-f0be-4d6d-b1d6-0011fc613313.png b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/upload/252a4acb-f0be-4d6d-b1d6-0011fc613313.png differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/upload/a5348e6c-142a-472d-9658-d433620c0173.jpg b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/upload/a5348e6c-142a-472d-9658-d433620c0173.jpg differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/upload/aed61a25-b537-4891-bf51-20260f5f7bb9.jpg b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/upload/aed61a25-b537-4891-bf51-20260f5f7bb9.jpg differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/upload/c9cdea47-70fd-4636-b5cd-88b1605f1a63.png b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/upload/c9cdea47-70fd-4636-b5cd-88b1605f1a63.png differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/upload/cb25879f-83a3-4ce7-87da-33829bd0125a.png b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/static/upload/cb25879f-83a3-4ce7-87da-33829bd0125a.png differ diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/admin/admin_base.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/admin/admin_base.html new file mode 100644 index 0000000..09286ee --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/auth/login.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/auth/login.html new file mode 100644 index 0000000..51f3d00 --- /dev/null +++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/auth/login.html @@ -0,0 +1,17 @@ +{% extends "partials/base.html" %} + +{% block title %} +LogIn +{% endblock %} + +{% block content %} +
+
+ {{ form.hidden_tag() }} + {{ form.username(placeholder=form.username.label.text, class="form-control") }} + {{ form.password(placeholder=form.password.label.text, class="form-control") }} + + {{ form.login(class="btn login-btn") }} +
+
+{% endblock %} \ No newline at end of file diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/auth/profile.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/auth/profile.html new file mode 100644 index 0000000..0a833b9 --- /dev/null +++ b/Chapter09_Admin/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/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/auth/registration.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/auth/registration.html new file mode 100644 index 0000000..b5d29a0 --- /dev/null +++ b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/auth/registration.html @@ -0,0 +1,29 @@ +{% extends "partials/base.html" %} + +{% block title %} +Registration +{% endblock %} + +{% block content %} +
+
+ {% for errors in form.errors.values() %} + {% for error in errors %} +

* {{ error }}

+ {% endfor %} + {% endfor %} + + {{ form.hidden_tag() }} + + {{ form.username(placeholder=form.username.label.text, class="form-control") }} + {{ form.email(placeholder=form.email.label.text, class="form-control") }} + {{ form.password(placeholder=form.password.label.text, class="form-control") }} + {{ form.repeat_password(placeholder=form.repeat_password.label.text, class="form-control") }} + {{ form.birthday(class="form-control") }} + {{ form.country(class="form-select") }} + {{ form.gender(class="mt-3 ms-2") }} + {{ form.profile_image(class="form-control", accept=".jpg, .png, .jpeg") }} + {{ form.submit(class="registration-btn") }} +
+
+{% endblock %} \ No newline at end of file diff --git a/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/macros.html b/Chapter09_Admin/Projects/Nino_Tsiklauri/src/templates/macros.html new file mode 100644 index 0000000..25cc5fe --- /dev/null +++ b/Chapter09_Admin/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/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 %} +
+
+
+ book_shop +
+
+

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

+
+
+ books +
+
+
+{% 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 %} +
+
+ {% for errors in form.errors.values() %} + {% for error in errors %} +

* {{ error }}

+ {% endfor %} + {% endfor %} + + {{ form.hidden_tag() }} + {{ form.name(placeholder=form.name.label.text, class="form-control") }} + {{ form.author(placeholder=form.author.label.text, class="form-control") }} + {{ form.price(placeholder=form.price.label.text, class="form-control") }} + {{ form.image(class="form-control", accept=".jpg, .png, .jpeg") }} + {{ form.submit(class="registration-btn") }} +
+
+{% 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 %} +
+
+ {{ form.hidden_tag() }} + {{ form.username(placeholder=form.username.label.text, class="form-control") }} + {{ form.password(placeholder=form.password.label.text, class="form-control") }} + + {{ form.login(class="btn login-btn") }} +
+
+{% 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 %} +
+
+ {% for errors in form.errors.values() %} + {% for error in errors %} +

* {{ error }}

+ {% endfor %} + {% endfor %} + + {{ form.hidden_tag() }} + + {{ form.username(placeholder=form.username.label.text, class="form-control") }} + {{ form.email(placeholder=form.email.label.text, class="form-control") }} + {{ form.password(placeholder=form.password.label.text, class="form-control") }} + {{ form.repeat_password(placeholder=form.repeat_password.label.text, class="form-control") }} + {{ form.birthday(class="form-control") }} + {{ form.country(class="form-select") }} + {{ form.gender(class="mt-3 ms-2") }} + {{ form.profile_image(class="form-control", accept=".jpg, .png, .jpeg") }} + {{ form.submit(class="registration-btn") }} +
+
+{% 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 %} +
+
+
+ book_shop +
+
+

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

+
+
+ books +
+
+
+{% 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 %} +
+
+ {% for errors in form.errors.values() %} + {% for error in errors %} +

* {{ error }}

+ {% endfor %} + {% endfor %} + + {{ form.hidden_tag() }} + {{ form.name(placeholder=form.name.label.text, class="form-control") }} + {{ form.author(placeholder=form.author.label.text, class="form-control") }} + {{ form.price(placeholder=form.price.label.text, class="form-control") }} + {{ form.image(class="form-control", accept=".jpg, .png, .jpeg") }} + {{ form.submit(class="registration-btn") }} +
+
+{% 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 %} +
+
+ {{ form.hidden_tag() }} + {{ form.username(placeholder=form.username.label.text, class="form-control") }} + {{ form.password(placeholder=form.password.label.text, class="form-control") }} + + {{ form.login(class="btn login-btn") }} +
+
+{% 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 %} +
+
+ {% for errors in form.errors.values() %} + {% for error in errors %} +

* {{ error }}

+ {% endfor %} + {% endfor %} + + {{ form.hidden_tag() }} + + {{ form.username(placeholder=form.username.label.text, class="form-control") }} + {{ form.email(placeholder=form.email.label.text, class="form-control") }} + {{ form.password(placeholder=form.password.label.text, class="form-control") }} + {{ form.repeat_password(placeholder=form.repeat_password.label.text, class="form-control") }} + {{ form.birthday(class="form-control") }} + {{ form.country(class="form-select") }} + {{ form.gender(class="mt-3 ms-2") }} + {{ form.profile_image(class="form-control", accept=".jpg, .png, .jpeg") }} + {{ form.submit(class="registration-btn") }} +
+
+{% 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 %} +
+
+
+ book_shop +
+
+

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

+
+
+ books +
+
+
+{% 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 %} +
+
+ {% for errors in form.errors.values() %} + {% for error in errors %} +

* {{ error }}

+ {% endfor %} + {% endfor %} + + {{ form.hidden_tag() }} + {{ form.name(placeholder=form.name.label.text, class="form-control") }} + {{ form.author(placeholder=form.author.label.text, class="form-control") }} + {{ form.price(placeholder=form.price.label.text, class="form-control") }} + {{ form.image(class="form-control", accept=".jpg, .png, .jpeg") }} + {{ form.submit(class="registration-btn") }} +
+
+{% 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