diff --git a/Chapter07_Factory_Pattern/Projects/Salome_Papashvili/src/models/product.py b/Chapter07_Factory_Pattern/Projects/Salome_Papashvili/src/models/product.py
index bcde78f..4ca17f8 100644
--- a/Chapter07_Factory_Pattern/Projects/Salome_Papashvili/src/models/product.py
+++ b/Chapter07_Factory_Pattern/Projects/Salome_Papashvili/src/models/product.py
@@ -1,5 +1,6 @@
from src.ext import db
-class Tour(db.Model):
+from src.models.base import BaseModel
+class Tour(BaseModel):
__tablename__ = "tours"
id = db.Column(db.Integer, primary_key=True)
country = db.Column(db.String)
diff --git a/Chapter08_User/Projects/Readme.md b/Chapter08_User/Projects/Readme.md
index d36b8ae..876b487 100644
--- a/Chapter08_User/Projects/Readme.md
+++ b/Chapter08_User/Projects/Readme.md
@@ -2,5 +2,6 @@
მაგალითი:
- სახელი გვარი | [პროექტი](/მისამართი)
+- სალომე პაპაშვილი | [GlobeTales](/Chapter08_User/Projects/Salome_Papashvili/app.py)
### 2025 ზაფხული
diff --git a/Chapter08_User/Projects/Salome_Papashvili/app.py b/Chapter08_User/Projects/Salome_Papashvili/app.py
new file mode 100644
index 0000000..4c71782
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/app.py
@@ -0,0 +1,5 @@
+from src import create_app
+app = create_app()
+
+if __name__ == "__main__":
+ app.run(debug=True)
diff --git a/Chapter08_User/Projects/Salome_Papashvili/dev-requirements.txt b/Chapter08_User/Projects/Salome_Papashvili/dev-requirements.txt
new file mode 100644
index 0000000..b8a9de4
Binary files /dev/null and b/Chapter08_User/Projects/Salome_Papashvili/dev-requirements.txt differ
diff --git a/Chapter08_User/Projects/Salome_Papashvili/migrations/README b/Chapter08_User/Projects/Salome_Papashvili/migrations/README
new file mode 100644
index 0000000..0e04844
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/migrations/README
@@ -0,0 +1 @@
+Single-database configuration for Flask.
diff --git a/Chapter08_User/Projects/Salome_Papashvili/migrations/alembic.ini b/Chapter08_User/Projects/Salome_Papashvili/migrations/alembic.ini
new file mode 100644
index 0000000..ec9d45c
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/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/Chapter08_User/Projects/Salome_Papashvili/migrations/env.py b/Chapter08_User/Projects/Salome_Papashvili/migrations/env.py
new file mode 100644
index 0000000..4c97092
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/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/Chapter08_User/Projects/Salome_Papashvili/migrations/script.py.mako b/Chapter08_User/Projects/Salome_Papashvili/migrations/script.py.mako
new file mode 100644
index 0000000..2c01563
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/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/Chapter08_User/Projects/Salome_Papashvili/prod-requirements.txt b/Chapter08_User/Projects/Salome_Papashvili/prod-requirements.txt
new file mode 100644
index 0000000..42e0d89
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/prod-requirements.txt
@@ -0,0 +1,6 @@
+Flask==3.1.0
+Flask-Login==0.6.3
+Flask-Migrate==4.1.0
+Flask-SQLAlchemy==3.1.1
+Flask-WTF==1.2.2
+uwsgi
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/__init__.py b/Chapter08_User/Projects/Salome_Papashvili/src/__init__.py
new file mode 100644
index 0000000..3b76f6a
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/__init__.py
@@ -0,0 +1,34 @@
+from flask import Flask
+from src.commands import init_db, populate_db
+from src.config import Config
+from src.ext import db, migrate, login_manager
+from src.views import main_blueprint, auth_blueprint, product_blueprint
+from src.models import User
+BLUEPRINTS = [main_blueprint, product_blueprint, auth_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):
+ db.init_app(app)
+ migrate.init_app(app, db)
+ login_manager.init_app(app)
+ login_manager.login_view = 'auth.login'
+ @login_manager.user_loader
+ def load_user(_id):
+ return User.query.get(_id)
+
+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)
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/commands.py b/Chapter08_User/Projects/Salome_Papashvili/src/commands.py
new file mode 100644
index 0000000..6dd1415
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/commands.py
@@ -0,0 +1,83 @@
+from flask.cli import with_appcontext
+import click
+from src.ext import db
+from src.models import Tour, 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():
+ tours = [
+ {
+ "id": 0,
+ "country": "Italy",
+ "title": "Rome Ancient Wonders Tour",
+ "description": "Discover the Colosseum, Roman Forum, and Vatican City.",
+ "price": 279.99,
+ "currency": "EUR",
+ "image": "https://www.agoda.com/wp-content/uploads/2024/08/Colosseum-Rome-Featured.jpg",
+ "duration": "4 days",
+ },
+ {
+ "id": 1,
+ "country": "Egypt",
+ "title": "Cairo & Pyramids Adventure",
+ "description": "Visit the Great Pyramids of Giza and the Egyptian Museum.",
+ "price": 349.99,
+ "currency": "USD",
+ "image": "https://upload.wikimedia.org/wikipedia/commons/a/af/All_Gizah_Pyramids.jpg",
+ "duration": "5 days",
+ },
+ {
+ "id": 2,
+ "country": "Australia",
+ "title": "Sydney & Blue Mountains",
+ "description": "Explore Sydney Opera House, Harbour Bridge, and Blue Mountains.",
+ "price": 499.99,
+ "currency": "AUD",
+ "image": "https://www.sydneytravelguide.com.au/wp-content/uploads/2024/09/sydney-australia.jpg",
+ "duration": "6 days",
+ },
+ {
+ "id": 3,
+ "country": "Spain",
+ "title": "Barcelona",
+ "description": "Admire Sagrada Familia, Park Güell, and Gothic Quarter.",
+ "price": 259.99,
+ "currency": "EUR",
+ "image": "https://www.introducingbarcelona.com/f/espana/barcelona/barcelona.jpg",
+ "duration": "3 days",
+ },
+ {
+ "id": 4,
+ "country": "Turkey",
+ "title": "Istanbul Heritage Tour",
+ "description": "Visit Hagia Sophia, Blue Mosque, and Grand Bazaar.",
+ "price": 199.99,
+ "currency": "USD",
+ "image": "https://deih43ym53wif.cloudfront.net/small_cappadocia-turkey-shutterstock_1320608780_9fc0781106.jpeg",
+ "duration": "4 days",
+ }
+ ]
+
+ for tour in tours:
+ new_tour = Tour(country=tour["country"],
+ title=tour["title"],
+ description=tour["description"],
+ price=tour["price"],
+ currency=tour["currency"],
+ image=tour["image"],
+ duration=tour["duration"])
+ db.session.add(new_tour)
+ db.session.commit()
+
+ # Admin
+ User(username="Admin", password="password12345", profile_image="admin.png", role="Admin").create()
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/config.py b/Chapter08_User/Projects/Salome_Papashvili/src/config.py
new file mode 100644
index 0000000..c95edc0
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/config.py
@@ -0,0 +1,7 @@
+from os import path
+
+class Config(object):
+ BASE_DIRECTORY = path.abspath(path.dirname(__file__))
+ SQLALCHEMY_DATABASE_URI = "sqlite:///database.db"
+ SECRET_KEY = "GJFKLDJKljklhhjkhjk@595jijkjd"
+ UPLOAD_PATH = path.join(BASE_DIRECTORY, "static", "upload")
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/ext.py b/Chapter08_User/Projects/Salome_Papashvili/src/ext.py
new file mode 100644
index 0000000..07218a3
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/ext.py
@@ -0,0 +1,6 @@
+from flask_sqlalchemy import SQLAlchemy
+from flask_migrate import Migrate
+from flask_login import LoginManager
+db = SQLAlchemy()
+migrate = Migrate()
+login_manager = LoginManager()
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/models/__init__.py b/Chapter08_User/Projects/Salome_Papashvili/src/models/__init__.py
new file mode 100644
index 0000000..3a2935e
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/models/__init__.py
@@ -0,0 +1,3 @@
+from src.models.product import Tour
+from src.models.person import Person
+from src.models.user import User
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/models/base.py b/Chapter08_User/Projects/Salome_Papashvili/src/models/base.py
new file mode 100644
index 0000000..2f9ab61
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/models/base.py
@@ -0,0 +1,16 @@
+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/Chapter08_User/Projects/Salome_Papashvili/src/models/person.py b/Chapter08_User/Projects/Salome_Papashvili/src/models/person.py
new file mode 100644
index 0000000..696165c
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/models/person.py
@@ -0,0 +1,13 @@
+from src.ext import db
+from src.models.base import BaseModel
+class Person(BaseModel):
+ __tablename__ = "persons"
+
+ id = db.Column(db.Integer, primary_key=True)
+ username = db.Column(db.String, nullable=False)
+ email = db.Column(db.String, nullable=False, unique=True)
+ password = db.Column(db.String, nullable=False)
+ birthday = db.Column(db.Date, nullable=False)
+ gender = db.Column(db.String, nullable=False)
+ country = db.Column(db.String, nullable=False)
+ profile_image = db.Column(db.String)
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/models/product.py b/Chapter08_User/Projects/Salome_Papashvili/src/models/product.py
new file mode 100644
index 0000000..4ca17f8
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/models/product.py
@@ -0,0 +1,12 @@
+from src.ext import db
+from src.models.base import BaseModel
+class Tour(BaseModel):
+ __tablename__ = "tours"
+ id = db.Column(db.Integer, primary_key=True)
+ country = db.Column(db.String)
+ title = db.Column(db.String)
+ description = db.Column(db.String)
+ price = db.Column(db.Float)
+ currency = db.Column(db.String)
+ image = db.Column(db.String)
+ duration = db.Column(db.String)
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/models/user.py b/Chapter08_User/Projects/Salome_Papashvili/src/models/user.py
new file mode 100644
index 0000000..7a15848
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/models/user.py
@@ -0,0 +1,23 @@
+from src.ext import db
+from flask_login import UserMixin
+from werkzeug.security import generate_password_hash, check_password_hash
+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"
+ @property
+ def password(self):
+ print("GETTER")
+ return self._password
+ @password.setter
+ def password(self, password):
+ print("SETTER")
+ self._password = generate_password_hash(password)
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/static/styles/style.css b/Chapter08_User/Projects/Salome_Papashvili/src/static/styles/style.css
new file mode 100644
index 0000000..e23ecd0
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/static/styles/style.css
@@ -0,0 +1,80 @@
+*
+{
+ list-style-type: none;
+}
+body
+{
+ background: linear-gradient(to right, #e0f7fa, #fff);
+}
+.hero
+{
+ height: 90vh;
+ background: linear-gradient(rgba(0,0,0,0.3), #0000005e),
+ url('https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8dHJhdmVsfGVufDB8fDB8fHww') center/cover no-repeat;
+ color: white;
+}
+.flex
+{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 30px;
+}
+.custom-hero
+{
+ height: 20vh;
+}
+h1
+{
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.6);
+}
+.nav-link
+{
+ color: #fff;
+}
+.active-link
+{
+ color: #268a97;
+}
+.card img
+{
+ height: 200px;
+ object-fit: cover;
+}
+.btn-custom
+{
+ background: #268a97;
+ border: #02C211;
+ color: #fff;
+}
+.btn-custom:hover
+{
+ background: #16626C;;
+ color: #fff;
+}
+footer
+{
+ background-color: #16626C;
+ color: white;
+}
+::-webkit-scrollbar
+{
+ display: block;
+ width: 12px;
+}
+::-webkit-scrollbar-thumb
+{
+ background-color: #268a97;
+}
+::-webkit-scrollbar-track
+{
+ background-color: #fff;
+}
+.dot
+{
+ border-radius: 50%;
+}
+.image
+{
+ width: 50%;
+}
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png
new file mode 100644
index 0000000..d14294c
Binary files /dev/null and b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png differ
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png differ
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png
new file mode 100644
index 0000000..956fb47
Binary files /dev/null and b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png differ
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png differ
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg
new file mode 100644
index 0000000..3cd7897
Binary files /dev/null and b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg differ
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png
new file mode 100644
index 0000000..956fb47
Binary files /dev/null and b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png differ
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg
new file mode 100644
index 0000000..3cd7897
Binary files /dev/null and b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg differ
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png
new file mode 100644
index 0000000..d14294c
Binary files /dev/null and b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png differ
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter08_User/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png differ
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/auth/login.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/auth/login.html
new file mode 100644
index 0000000..17cb90d
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/auth/login.html
@@ -0,0 +1,27 @@
+{% extends "partials/base.html" %}
+{% block title %}Sign Up{% endblock %}
+{% block content %}
+
+
+
+
+
Log In
+ {% for field, errors in form.errors.items() %}
+ {% for error in errors %}
+
{{ error }}
+ {% endfor %}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/auth/register.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/auth/register.html
new file mode 100644
index 0000000..8521fb9
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/auth/register.html
@@ -0,0 +1,51 @@
+{% extends "partials/base.html" %}
+{% block title %}Sign Up{% endblock %}
+{% block content %}
+
+
+
+
+
Sign Up
+ {% for errors in form.errors.values() %}
+ {% for error in errors %}
+
{{ error }}
+ {% endfor %}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/auth/view_profile.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/auth/view_profile.html
new file mode 100644
index 0000000..815aac2
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/auth/view_profile.html
@@ -0,0 +1,17 @@
+{% extends "partials/base.html" %}
+{% block title %}{{ current_user.username }}'s Profile{% endblock %}
+
+{% block content %}
+
+
+
+
+
{{ current_user.username }}
+
Password: {{ current_user.password }}
+
+
+
+
+
+{% endblock %}
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/macros.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/macros.html
new file mode 100644
index 0000000..369bb50
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/macros.html
@@ -0,0 +1,5 @@
+{% macro is_active(endpoint_name) %}
+{% if endpoint_name == request.endpoint %}
+active-link
+{% endif %}
+{% endmacro %}
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/main/about.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/main/about.html
new file mode 100644
index 0000000..b47cf6d
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/main/about.html
@@ -0,0 +1,81 @@
+{% extends "partials/base.html" %}
+{% block title %}About Us{%endblock %}
+{% block content %}
+
+
+
+
✨ About GlobeTales
+
+ At GlobeTales , we believe that every journey is a story waiting to be told. Whether you're wandering through the lantern-lit alleys of Kyoto, sipping espresso in Rome, or gazing at the northern lights in Iceland – your travel moments matter.
+
+
+ Founded by a team of wanderlusters, photographers, and storytellers, GlobeTales was born from a simple idea: to connect travelers with authentic experiences and hidden gems across the globe. Join our growing community, and let’s make your next trip unforgettable.
+
+
+
+
+
+
+
+
+
Frequently Asked Questions
+
+
+
+
+
+ We focus on curated, authentic travel stories and hidden destinations, not just mainstream tourist spots. Each listing is handpicked for its uniqueness and cultural value.
+
+
+
+
+
+
+
+
+ Absolutely! We welcome contributions from travelers around the world. Simply sign up, and you can start submitting your travel tales, tips, and photos.
+
+
+
+
+
+
+
+
+ Yes! Browsing destinations, reading stories, and accessing our travel guides is completely free. Premium features like trip planning tools are optional.
+
+
+
+
+
+
+
+
+ Submit at least 3 unique travel stories with high-quality images. Our editors review content monthly and select standout travelers to feature on the homepage!
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/main/index.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/main/index.html
new file mode 100644
index 0000000..75f6ff9
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/main/index.html
@@ -0,0 +1,129 @@
+{% extends "partials/base.html" %}
+{% block title %}Home{% endblock %}
+{% block content %}
+
+
+
Travel the World in Style 🌍
+
Discover places, stories, and unforgettable adventures with GlobeTales.
+
+
+
Explore Destinations
+
+
+
+ 🤝 Our Trusted Partners
+
+
FlyGo Airlines
+
GlobeHotels
+
ExploreMore Tours
+
SafeTrip Insurance
+
PaySecure
+
+
+
+
🌎 The best tours
+
+
+
+
+
+
🇫🇷 Paris
+
The city of love, fashion, and timeless beauty.
+
+
+
+
+
+
+
+
🇯🇵 Tokyo
+
A fusion of ancient traditions and modern innovation.
+
+
+
+
+
+
+
+
🇺🇸 New York
+
The city that never sleeps. Lights, life, and skyscrapers.
+
+
+
+
+
+
+
+
+
❤️ What Our Travelers Say
+
+
+
+
+
+
"Best trip ever! The guides were amazing and the itinerary was perfect."
+
- Sarah M.
+
+
+
+
+
+
+
"GlobeTales made our vacation unforgettable. Highly recommended!"
+
- Alex P.
+
+
+
+
+
+
+
"Smooth booking, great communication, and fantastic experiences."
+
- Emily R.
+
+
+
+
+
+ 1
+ 2
+ 3
+
+
+
+
+
+
+
📧 Stay Updated!
+
Subscribe to get the latest deals and travel inspiration straight to your inbox.
+
+
+
+
+
+
+
Ready for Your Next Adventure?
+
Book your dream tour today with GlobeTales.
+
Get Started
+
+
+
+
+
+ {% for user in users %}
+
{{user}}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/partials/base.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/partials/base.html
new file mode 100644
index 0000000..b304467
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/partials/base.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ GlobeTales | {% block title %}{%endblock %}
+
+
+
+
+{% include "partials/nav.html" %}
+{% for message in get_flashed_messages(with_categories=True) if message %}
+
+{% endfor %}
+{% block content%}
+{% endblock %}
+{% include "partials/footer.html" %}
+
+
+
+
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/partials/footer.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/partials/footer.html
new file mode 100644
index 0000000..4527dab
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/partials/footer.html
@@ -0,0 +1,30 @@
+
+
+
+
+
GlobeTales
+
GlobeTales is your gateway to discovering the most beautiful places around the globe. Start your
+ journey today!
+
+
+
+
+
+
+ © 2025 GlobeTales. All rights reserved.
+
+
+
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/partials/nav.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/partials/nav.html
new file mode 100644
index 0000000..cc43bbb
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/partials/nav.html
@@ -0,0 +1,26 @@
+{% from "macros.html" import is_active %}
+
+
+
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/product/add_tour.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/product/add_tour.html
new file mode 100644
index 0000000..30877d5
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/product/add_tour.html
@@ -0,0 +1,58 @@
+{% extends "partials/base.html" %}
+{% block title %}Add Tour{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/product/tour_detail.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/product/tour_detail.html
new file mode 100644
index 0000000..6307436
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/product/tour_detail.html
@@ -0,0 +1,31 @@
+{% extends "partials/base.html" %}
+{% block title %}{{tour.title}}{%endblock %}
+{% block content %}
+
+
+
+
+
{{tour.title}}
+
Country: {{tour.country}}
+
+
+ {{tour.price}}
+ {{old_price}}
+
+
+
+ {{tour.description}}
+
+
+
+ Duration: {{tour.duration}}
+
+
+
+ Book Now
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/templates/product/trips.html b/Chapter08_User/Projects/Salome_Papashvili/src/templates/product/trips.html
new file mode 100644
index 0000000..869804a
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/templates/product/trips.html
@@ -0,0 +1,49 @@
+{% extends "partials/base.html" %}
+{% block title %}Trips{%endblock %}
+{% block content %}
+
+
+
+
🌎 Featured Trips
+ {% if current_user.is_authenticated %}
+
Create
+ {% endif %}
+
+
+ {% if tours %}
+ {% for tour in tours %}
+
+ {% endfor %}
+ {% else %}
+
No trips available.
+ {% endif %}
+
+
+
+
+
+
✈️ Plan Your Next Trip
+
Whether you're seeking a relaxing beach vacation or an adventurous mountain hike, GlobeTales
+ has the perfect destination for you. Start planning your trip today!
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/utils.py b/Chapter08_User/Projects/Salome_Papashvili/src/utils.py
new file mode 100644
index 0000000..cec4267
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/utils.py
@@ -0,0 +1,11 @@
+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("/")
+ return func(*args, **kwargs)
+ return admin_view
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/views/__init__.py b/Chapter08_User/Projects/Salome_Papashvili/src/views/__init__.py
new file mode 100644
index 0000000..0bac62b
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/views/__init__.py
@@ -0,0 +1,3 @@
+from src.views.auth.routes import auth_blueprint
+from src.views.product.routes import product_blueprint
+from src.views.main.routes import main_blueprint
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/views/auth/forms.py b/Chapter08_User/Projects/Salome_Papashvili/src/views/auth/forms.py
new file mode 100644
index 0000000..91facd4
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/views/auth/forms.py
@@ -0,0 +1,78 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, EmailField, SubmitField, DateField, RadioField, SelectField, \
+ IntegerField, FloatField
+from wtforms.validators import DataRequired, length, equal_to, ValidationError
+from string import ascii_uppercase, ascii_lowercase, ascii_letters, digits, punctuation
+from flask_wtf.file import FileField, FileSize, FileAllowed, FileRequired
+
+
+class LoginForm(FlaskForm):
+ username = StringField(
+ "Enter username",
+ validators=[DataRequired(message="Username is required")])
+ password = PasswordField(
+ "Enter password",
+ validators=[DataRequired(message="Password is required")])
+ login = SubmitField("Login")
+
+class RegisterForm(FlaskForm):
+ username = StringField("Enter username", validators=[DataRequired(message='Please enter your username')])
+ email = EmailField("Enter email", validators=[DataRequired(message='Please enter your email')])
+ password = PasswordField("Enter password",
+ validators=[DataRequired(message='Please enter your password'), length(min=8, max=64)])
+ repeat_password = PasswordField("Repeat password", validators=[DataRequired(message='Please confirm your password'),
+ equal_to("password",
+ message="Passwords must match.")])
+ birthday = DateField("Enter birthday", validators=[DataRequired(message='Please enter your birthday')])
+ gender = RadioField("Choose gender", choices=[(0, "Male"), (1, "Female")],
+ validators=[DataRequired(message='Please choose your gender')])
+ country = SelectField(
+ "Choose country",
+ choices=[
+ ("us", "United States"),
+ ("ca", "Canada"),
+ ("gb", "United Kingdom"),
+ ("au", "Australia"),
+ ("de", "Germany"),
+ ("fr", "France"),
+ ("it", "Italy"),
+ ("es", "Spain"),
+ ("jp", "Japan"),
+ ("cn", "China"),
+ ("in", "India"),
+ ("br", "Brazil"),
+ ("mx", "Mexico"),
+ ("ru", "Russia"),
+ ("za", "South Africa"),
+ ("ng", "Nigeria"),
+ ("eg", "Egypt")
+ ],
+ validators=[DataRequired()])
+ profile_image = FileField("Upload Profile Image",
+ validators=[FileSize(1024 * 1024), FileAllowed(["jpg", "png", "jpeg"])])
+ submit = SubmitField("Sign Up")
+
+ 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("Password must contain at least one uppercase letter")
+ if not contains_lowercase:
+ raise ValidationError("Password must contain at least one lowercase letter")
+ if not contains_digits:
+ raise ValidationError("Password must contain at least one digit (0-9)")
+ if not contains_symbols:
+ raise ValidationError("Password must contain at least one special character (e.g., !, @, #, etc.)")
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/views/auth/routes.py b/Chapter08_User/Projects/Salome_Papashvili/src/views/auth/routes.py
new file mode 100644
index 0000000..fd2606a
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/views/auth/routes.py
@@ -0,0 +1,51 @@
+from uuid import uuid4
+from os import path
+from flask import Blueprint, render_template, redirect, request, flash, get_flashed_messages
+from src.views.auth.forms import RegisterForm, LoginForm
+from src.config import Config
+from src.models import User
+from flask_login import login_user, logout_user, current_user, login_required
+
+auth_blueprint = Blueprint("auth", __name__)
+@auth_blueprint.route("/register", methods=["GET", "POST"])
+def register():
+ 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()
+ return redirect("/login")
+ else:
+ print(form.errors)
+ return render_template("auth/register.html", form=form)
+
+
+@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("Success!", "success")
+ return redirect("/")
+ else:
+ flash("Username or password is incorrect", "danger")
+ return render_template("auth/login.html", form=form)
+
+
+@auth_blueprint.route("/logout")
+def logout():
+ logout_user()
+ return redirect("/login")
+
+@auth_blueprint.route("/profile")
+@login_required
+def view_profile():
+ return render_template("auth/view_profile.html")
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/views/main/routes.py b/Chapter08_User/Projects/Salome_Papashvili/src/views/main/routes.py
new file mode 100644
index 0000000..a6860a0
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/views/main/routes.py
@@ -0,0 +1,11 @@
+from flask import Blueprint, render_template
+from flask_login import current_user
+main_blueprint = Blueprint("main", __name__)
+
+@main_blueprint.route("/")
+def index():
+ return render_template("main/index.html")
+
+@main_blueprint.route("/about")
+def about():
+ return render_template("main/about.html")
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/views/product/forms.py b/Chapter08_User/Projects/Salome_Papashvili/src/views/product/forms.py
new file mode 100644
index 0000000..24f3a75
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/views/product/forms.py
@@ -0,0 +1,107 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, SubmitField, SelectField, FloatField
+from wtforms.validators import DataRequired, length
+from flask_wtf.file import FileField, FileSize, FileAllowed, FileRequired
+
+class AddTourForm(FlaskForm):
+ title = StringField("Tour Title", validators=[DataRequired(message="Please enter a title"), length(max=100)])
+ country = SelectField(
+ "Country",
+ choices=[
+ ('', 'Select a country'),
+ ('AF', 'Afghanistan'),
+ ('AL', 'Albania'),
+ ('DZ', 'Algeria'),
+ ('AR', 'Argentina'),
+ ('AU', 'Australia'),
+ ('AT', 'Austria'),
+ ('BD', 'Bangladesh'),
+ ('BE', 'Belgium'),
+ ('BR', 'Brazil'),
+ ('BG', 'Bulgaria'),
+ ('CA', 'Canada'),
+ ('CL', 'Chile'),
+ ('CN', 'China'),
+ ('CO', 'Colombia'),
+ ('HR', 'Croatia'),
+ ('CZ', 'Czech Republic'),
+ ('DK', 'Denmark'),
+ ('EG', 'Egypt'),
+ ('EE', 'Estonia'),
+ ('FI', 'Finland'),
+ ('FR', 'France'),
+ ('DE', 'Germany'),
+ ('GR', 'Greece'),
+ ('HU', 'Hungary'),
+ ('IS', 'Iceland'),
+ ('IN', 'India'),
+ ('ID', 'Indonesia'),
+ ('IR', 'Iran'),
+ ('IQ', 'Iraq'),
+ ('IE', 'Ireland'),
+ ('IL', 'Israel'),
+ ('IT', 'Italy'),
+ ('JP', 'Japan'),
+ ('JO', 'Jordan'),
+ ('KZ', 'Kazakhstan'),
+ ('KE', 'Kenya'),
+ ('KR', 'South Korea'),
+ ('KW', 'Kuwait'),
+ ('LV', 'Latvia'),
+ ('LB', 'Lebanon'),
+ ('LT', 'Lithuania'),
+ ('MY', 'Malaysia'),
+ ('MX', 'Mexico'),
+ ('MA', 'Morocco'),
+ ('NL', 'Netherlands'),
+ ('NZ', 'New Zealand'),
+ ('NG', 'Nigeria'),
+ ('NO', 'Norway'),
+ ('PK', 'Pakistan'),
+ ('PE', 'Peru'),
+ ('PH', 'Philippines'),
+ ('PL', 'Poland'),
+ ('PT', 'Portugal'),
+ ('QA', 'Qatar'),
+ ('RO', 'Romania'),
+ ('RU', 'Russia'),
+ ('SA', 'Saudi Arabia'),
+ ('RS', 'Serbia'),
+ ('SG', 'Singapore'),
+ ('SK', 'Slovakia'),
+ ('SI', 'Slovenia'),
+ ('ZA', 'South Africa'),
+ ('ES', 'Spain'),
+ ('SE', 'Sweden'),
+ ('CH', 'Switzerland'),
+ ('SY', 'Syria'),
+ ('TH', 'Thailand'),
+ ('TR', 'Turkey'),
+ ('UA', 'Ukraine'),
+ ('AE', 'United Arab Emirates'),
+ ('GB', 'United Kingdom'),
+ ('US', 'United States'),
+ ('VN', 'Vietnam'),
+ ('YE', 'Yemen'),
+ ('ZW', 'Zimbabwe'),
+ ],
+ validators=[DataRequired(message="Please select a country")]
+ )
+ description = StringField("Description", validators=[DataRequired(message="Please enter a description")])
+ price = FloatField("Current Price",
+ validators=[DataRequired(message="Please enter the price")])
+ currency = SelectField(
+ "Currency",
+ choices=[
+ ("USD", "USD"),
+ ("EUR", "EUR"),
+ ("AUD", "AUD"),
+ ("GBP", "GBP"),
+ ("JPY", "JPY")
+ ],
+ validators=[DataRequired(message="Please select a currency")]
+ )
+ image = FileField("Upload Image",
+ validators=[FileSize(1024 * 1024), FileAllowed(["jpg", "png", "jpeg"])])
+ duration = StringField("Duration (e.g. 4 days)", validators=[DataRequired(message="Please enter duration")])
+ submit = SubmitField("Add Tour")
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/src/views/product/routes.py b/Chapter08_User/Projects/Salome_Papashvili/src/views/product/routes.py
new file mode 100644
index 0000000..32f6a79
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/src/views/product/routes.py
@@ -0,0 +1,79 @@
+from flask import Blueprint, render_template, redirect, url_for
+from uuid import uuid4
+from os import path
+from src.views.product.forms import AddTourForm
+from src.models.product import Tour
+from src.config import Config
+from flask_login import login_required
+from src.utils import admin_required
+
+product_blueprint = Blueprint("products", __name__)
+
+@product_blueprint.route("/trips")
+def trips():
+ tours = Tour.query.all()
+ return render_template("product/trips.html", tours=tours)
+
+@product_blueprint.route("/trips/")
+def tour_detail(tour_id):
+ chosen_tour = Tour.query.get(tour_id)
+ return render_template("product/tour_detail.html", tour=chosen_tour)
+
+@product_blueprint.route("/add_tour", methods=["GET", "POST"])
+@admin_required
+def add_tour():
+ form = AddTourForm()
+ 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_tour = Tour(country=form.country.data,
+ title=form.title.data,
+ description=form.description.data,
+ price=form.price.data,
+ currency=form.currency.data,
+ duration=form.duration.data,
+ image = filename)
+ new_tour.image = filename
+ new_tour.create()
+ return redirect(url_for("products.trips"))
+ else:
+ print(form.errors)
+ return render_template("product/add_tour.html", form=form)
+
+@product_blueprint.route("/edit_tour/", methods=["GET", "POST"])
+@admin_required
+def edit_tour(tour_id):
+ tour = Tour.query.get(tour_id)
+ form = AddTourForm(country=tour.country,
+ title=tour.title,
+ description=tour.description,
+ price=tour.price,
+ currency=tour.currency,
+ duration=tour.duration)
+ if form.validate_on_submit():
+ tour.country = form.country.data
+ tour.title = form.title.data
+ tour.description = form.description.data
+ tour.price = form.price.data
+ tour.currency = form.currency.data
+ tour.duration = form.duration.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))
+ tour.image = filename
+ tour.save()
+ return redirect(url_for("products.trips"))
+ return render_template("product/add_tour.html", form=form)
+
+
+@product_blueprint.route("/delete_tour/")
+@admin_required
+def delete_tour(tour_id):
+ tour = Tour.query.get(tour_id)
+ tour.delete()
+ return redirect(url_for("products.trips"))
\ No newline at end of file
diff --git a/Chapter08_User/Projects/Salome_Papashvili/uwsgi.conf b/Chapter08_User/Projects/Salome_Papashvili/uwsgi.conf
new file mode 100644
index 0000000..50a5ff7
--- /dev/null
+++ b/Chapter08_User/Projects/Salome_Papashvili/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..45cf2c7 100644
--- a/Chapter09_Admin/Projects/Readme.md
+++ b/Chapter09_Admin/Projects/Readme.md
@@ -2,5 +2,6 @@
მაგალითი:
- სახელი გვარი | [პროექტი](/მისამართი)
+- სალომე პაპაშვილი | [GlobeTales](/Chapter09_Admin/Projects/Salome_Papashvili/app.py)
### 2025 ზაფხული
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/app.py b/Chapter09_Admin/Projects/Salome_Papashvili/app.py
new file mode 100644
index 0000000..4c71782
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/app.py
@@ -0,0 +1,5 @@
+from src import create_app
+app = create_app()
+
+if __name__ == "__main__":
+ app.run(debug=True)
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/dev-requirements.txt b/Chapter09_Admin/Projects/Salome_Papashvili/dev-requirements.txt
new file mode 100644
index 0000000..b8a9de4
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/dev-requirements.txt differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/migrations/README b/Chapter09_Admin/Projects/Salome_Papashvili/migrations/README
new file mode 100644
index 0000000..0e04844
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/migrations/README
@@ -0,0 +1 @@
+Single-database configuration for Flask.
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/migrations/alembic.ini b/Chapter09_Admin/Projects/Salome_Papashvili/migrations/alembic.ini
new file mode 100644
index 0000000..ec9d45c
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/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/Salome_Papashvili/migrations/env.py b/Chapter09_Admin/Projects/Salome_Papashvili/migrations/env.py
new file mode 100644
index 0000000..4c97092
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/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/Salome_Papashvili/migrations/script.py.mako b/Chapter09_Admin/Projects/Salome_Papashvili/migrations/script.py.mako
new file mode 100644
index 0000000..2c01563
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/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/Salome_Papashvili/prod-requirements.txt b/Chapter09_Admin/Projects/Salome_Papashvili/prod-requirements.txt
new file mode 100644
index 0000000..42e0d89
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/prod-requirements.txt
@@ -0,0 +1,6 @@
+Flask==3.1.0
+Flask-Login==0.6.3
+Flask-Migrate==4.1.0
+Flask-SQLAlchemy==3.1.1
+Flask-WTF==1.2.2
+uwsgi
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/__init__.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/__init__.py
new file mode 100644
index 0000000..0dd22e5
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/__init__.py
@@ -0,0 +1,43 @@
+from flask import Flask
+from src.commands import init_db, populate_db
+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.models import User, Tour
+from src.admin_views.base import SecureModelView
+from src.admin_views import UserView, TourView
+from flask_admin.menu import MenuLink
+
+BLUEPRINTS = [main_blueprint, product_blueprint, auth_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):
+ db.init_app(app)
+ migrate.init_app(app, db)
+ login_manager.init_app(app)
+ login_manager.login_view = 'auth.login'
+ @login_manager.user_loader
+ def load_user(_id):
+ return User.query.get(_id)
+
+ admin.init_app(app)
+ admin.add_view(UserView(User, db.session))
+ admin.add_view(TourView(Tour, db.session))
+ admin.add_link(MenuLink("To site", url="/"))
+
+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)
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/__init__.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/__init__.py
new file mode 100644
index 0000000..50bac91
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/__init__.py
@@ -0,0 +1,2 @@
+from src.admin_views.user import UserView
+from src.admin_views.product import TourView
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/base.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/base.py
new file mode 100644
index 0000000..4c3ff55
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/base.py
@@ -0,0 +1,28 @@
+from flask_admin.contrib.sqla import ModelView
+from flask_admin import AdminIndexView
+from flask_login import current_user
+from flask import redirect, url_for, flash
+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 current_user.is_authenticated:
+ return redirect(url_for("auth.login"))
+ else:
+ flash("You do not have permission to access this page.", "warning")
+ return redirect(url_for("main.index"))
+
+
+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 current_user.is_authenticated:
+ return redirect(url_for("auth.login"))
+ else:
+ flash("You do not have permission to access the admin panel.", "warning")
+ return redirect(url_for("main.index"))
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/product.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/product.py
new file mode 100644
index 0000000..fce554d
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/product.py
@@ -0,0 +1,28 @@
+from src.admin_views.base import SecureModelView
+from flask_admin.form import ImageUploadField
+from src.config import Config
+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 TourView(SecureModelView):
+ create_modal = True
+ edit_modal = True
+ column_editable_list = ("price", "title")
+ column_filters = ("price", "title", "country")
+ column_formatters = {
+ "title": lambda v, c, m, n: m.title if len(m.title) < 24 else m.title[:16] + "...",
+ "description": lambda v, c, m, n: m.description if len(m.description) < 24 else m.description[:16] + "...",
+ "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/Salome_Papashvili/src/admin_views/user.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/user.py
new file mode 100644
index 0000000..f9bb31a
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/admin_views/user.py
@@ -0,0 +1,37 @@
+from src.admin_views.base import SecureModelView
+from wtforms.fields import SelectField
+from os import path
+from uuid import uuid4
+from flask_admin.form import ImageUploadField
+from src.config import Config
+
+def generate_filename(obj, file):
+ name, extension = path.splitext(file.filename)
+ return f"{uuid4()}{extension}"
+
+class UserView(SecureModelView):
+ can_create = True
+ can_edit = False
+ can_delete = False
+ can_view_details = True
+ create_modal = True
+ edit_modal = True
+ # column_exclude_list = ["_password"]
+ column_list = ["username", "role"]
+ column_details_list = ["username", "role", "profile_image"]
+ column_searchable_list = ["username", "role"]
+
+ form_overrides = {
+ "profile_image": ImageUploadField,
+ "role": SelectField
+ }
+
+ form_args = {
+ "profile_image": {
+ "base_path": Config.UPLOAD_PATH,
+ "namegen": generate_filename
+ },
+ "role": {
+ "choices": ["Admin", "Moderator", "User"]
+ }
+ }
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/commands.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/commands.py
new file mode 100644
index 0000000..6dd1415
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/commands.py
@@ -0,0 +1,83 @@
+from flask.cli import with_appcontext
+import click
+from src.ext import db
+from src.models import Tour, 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():
+ tours = [
+ {
+ "id": 0,
+ "country": "Italy",
+ "title": "Rome Ancient Wonders Tour",
+ "description": "Discover the Colosseum, Roman Forum, and Vatican City.",
+ "price": 279.99,
+ "currency": "EUR",
+ "image": "https://www.agoda.com/wp-content/uploads/2024/08/Colosseum-Rome-Featured.jpg",
+ "duration": "4 days",
+ },
+ {
+ "id": 1,
+ "country": "Egypt",
+ "title": "Cairo & Pyramids Adventure",
+ "description": "Visit the Great Pyramids of Giza and the Egyptian Museum.",
+ "price": 349.99,
+ "currency": "USD",
+ "image": "https://upload.wikimedia.org/wikipedia/commons/a/af/All_Gizah_Pyramids.jpg",
+ "duration": "5 days",
+ },
+ {
+ "id": 2,
+ "country": "Australia",
+ "title": "Sydney & Blue Mountains",
+ "description": "Explore Sydney Opera House, Harbour Bridge, and Blue Mountains.",
+ "price": 499.99,
+ "currency": "AUD",
+ "image": "https://www.sydneytravelguide.com.au/wp-content/uploads/2024/09/sydney-australia.jpg",
+ "duration": "6 days",
+ },
+ {
+ "id": 3,
+ "country": "Spain",
+ "title": "Barcelona",
+ "description": "Admire Sagrada Familia, Park Güell, and Gothic Quarter.",
+ "price": 259.99,
+ "currency": "EUR",
+ "image": "https://www.introducingbarcelona.com/f/espana/barcelona/barcelona.jpg",
+ "duration": "3 days",
+ },
+ {
+ "id": 4,
+ "country": "Turkey",
+ "title": "Istanbul Heritage Tour",
+ "description": "Visit Hagia Sophia, Blue Mosque, and Grand Bazaar.",
+ "price": 199.99,
+ "currency": "USD",
+ "image": "https://deih43ym53wif.cloudfront.net/small_cappadocia-turkey-shutterstock_1320608780_9fc0781106.jpeg",
+ "duration": "4 days",
+ }
+ ]
+
+ for tour in tours:
+ new_tour = Tour(country=tour["country"],
+ title=tour["title"],
+ description=tour["description"],
+ price=tour["price"],
+ currency=tour["currency"],
+ image=tour["image"],
+ duration=tour["duration"])
+ db.session.add(new_tour)
+ db.session.commit()
+
+ # Admin
+ User(username="Admin", password="password12345", profile_image="admin.png", role="Admin").create()
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/config.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/config.py
new file mode 100644
index 0000000..186856b
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/config.py
@@ -0,0 +1,8 @@
+from os import path
+
+class Config(object):
+ BASE_DIRECTORY = path.abspath(path.dirname(__file__))
+ SQLALCHEMY_DATABASE_URI = "sqlite:///database.db"
+ SECRET_KEY = "GJFKLDJKljklhhjkhjk@595jijkjd"
+ UPLOAD_PATH = path.join(BASE_DIRECTORY, "static", "upload")
+ FLASK_ADMIN_SWATCH = "journal"
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/ext.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/ext.py
new file mode 100644
index 0000000..62047ed
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/ext.py
@@ -0,0 +1,9 @@
+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(template_mode="bootstrap4", index_view=SecureIndexView(), base_template="admin/admin_base.html")
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/models/__init__.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/models/__init__.py
new file mode 100644
index 0000000..3a2935e
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/models/__init__.py
@@ -0,0 +1,3 @@
+from src.models.product import Tour
+from src.models.person import Person
+from src.models.user import User
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/models/base.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/models/base.py
new file mode 100644
index 0000000..2f9ab61
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/models/base.py
@@ -0,0 +1,16 @@
+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/Salome_Papashvili/src/models/person.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/models/person.py
new file mode 100644
index 0000000..696165c
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/models/person.py
@@ -0,0 +1,13 @@
+from src.ext import db
+from src.models.base import BaseModel
+class Person(BaseModel):
+ __tablename__ = "persons"
+
+ id = db.Column(db.Integer, primary_key=True)
+ username = db.Column(db.String, nullable=False)
+ email = db.Column(db.String, nullable=False, unique=True)
+ password = db.Column(db.String, nullable=False)
+ birthday = db.Column(db.Date, nullable=False)
+ gender = db.Column(db.String, nullable=False)
+ country = db.Column(db.String, nullable=False)
+ profile_image = db.Column(db.String)
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/models/product.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/models/product.py
new file mode 100644
index 0000000..4ca17f8
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/models/product.py
@@ -0,0 +1,12 @@
+from src.ext import db
+from src.models.base import BaseModel
+class Tour(BaseModel):
+ __tablename__ = "tours"
+ id = db.Column(db.Integer, primary_key=True)
+ country = db.Column(db.String)
+ title = db.Column(db.String)
+ description = db.Column(db.String)
+ price = db.Column(db.Float)
+ currency = db.Column(db.String)
+ image = db.Column(db.String)
+ duration = db.Column(db.String)
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/models/user.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/models/user.py
new file mode 100644
index 0000000..4bb1569
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/models/user.py
@@ -0,0 +1,23 @@
+from src.ext import db
+from flask_login import UserMixin
+from werkzeug.security import generate_password_hash, check_password_hash
+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, default='User')
+ def check_password(self, password):
+ return check_password_hash(self.password, password)
+ def is_admin(self):
+ return self.role == "Admin"
+ @property
+ def password(self):
+ print("GETTER")
+ return self._password
+ @password.setter
+ def password(self, password):
+ print("SETTER")
+ self._password = generate_password_hash(password)
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/styles/style.css b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/styles/style.css
new file mode 100644
index 0000000..e23ecd0
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/styles/style.css
@@ -0,0 +1,80 @@
+*
+{
+ list-style-type: none;
+}
+body
+{
+ background: linear-gradient(to right, #e0f7fa, #fff);
+}
+.hero
+{
+ height: 90vh;
+ background: linear-gradient(rgba(0,0,0,0.3), #0000005e),
+ url('https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8dHJhdmVsfGVufDB8fDB8fHww') center/cover no-repeat;
+ color: white;
+}
+.flex
+{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 30px;
+}
+.custom-hero
+{
+ height: 20vh;
+}
+h1
+{
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.6);
+}
+.nav-link
+{
+ color: #fff;
+}
+.active-link
+{
+ color: #268a97;
+}
+.card img
+{
+ height: 200px;
+ object-fit: cover;
+}
+.btn-custom
+{
+ background: #268a97;
+ border: #02C211;
+ color: #fff;
+}
+.btn-custom:hover
+{
+ background: #16626C;;
+ color: #fff;
+}
+footer
+{
+ background-color: #16626C;
+ color: white;
+}
+::-webkit-scrollbar
+{
+ display: block;
+ width: 12px;
+}
+::-webkit-scrollbar-thumb
+{
+ background-color: #268a97;
+}
+::-webkit-scrollbar-track
+{
+ background-color: #fff;
+}
+.dot
+{
+ border-radius: 50%;
+}
+.image
+{
+ width: 50%;
+}
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png
new file mode 100644
index 0000000..d14294c
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png
new file mode 100644
index 0000000..956fb47
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg
new file mode 100644
index 0000000..3cd7897
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png
new file mode 100644
index 0000000..956fb47
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/be2ca709-1f08-49bd-9839-ee7031292dfc.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/be2ca709-1f08-49bd-9839-ee7031292dfc.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/be2ca709-1f08-49bd-9839-ee7031292dfc.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg
new file mode 100644
index 0000000..3cd7897
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/ce84803b-d1a0-460f-b3cc-87332eff91e3.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/ce84803b-d1a0-460f-b3cc-87332eff91e3.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/ce84803b-d1a0-460f-b3cc-87332eff91e3.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/d610a890-14f6-470b-91f6-b8e3add2182c.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/d610a890-14f6-470b-91f6-b8e3add2182c.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/d610a890-14f6-470b-91f6-b8e3add2182c.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png
new file mode 100644
index 0000000..d14294c
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/fa898a0e-74a4-4661-ab18-796778100cc0.png b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/fa898a0e-74a4-4661-ab18-796778100cc0.png
new file mode 100644
index 0000000..15f336e
Binary files /dev/null and b/Chapter09_Admin/Projects/Salome_Papashvili/src/static/upload/fa898a0e-74a4-4661-ab18-796778100cc0.png differ
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/admin/admin_base.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/admin/admin_base.html
new file mode 100644
index 0000000..4f36ed6
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/admin/admin_base.html
@@ -0,0 +1,45 @@
+{% extends "admin/base.html" %}
+
+{% block page_body %}
+
+
+
+
+
+
+
+
+
+ {% block brand %}
+
{{ admin_view.admin.name }}
+ {% endblock %}
+ {% block main_menu %}
+
+ {% endblock %}
+
+ {% block menu_links %}
+
+ {{ layout.menu_links() }}
+
+ {% endblock %}
+ {% block access_control %}
+ {% endblock %}
+
+
+
+
+
+
+ {% 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/Salome_Papashvili/src/templates/auth/login.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/auth/login.html
new file mode 100644
index 0000000..17cb90d
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/auth/login.html
@@ -0,0 +1,27 @@
+{% extends "partials/base.html" %}
+{% block title %}Sign Up{% endblock %}
+{% block content %}
+
+
+
+
+
Log In
+ {% for field, errors in form.errors.items() %}
+ {% for error in errors %}
+
{{ error }}
+ {% endfor %}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/auth/register.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/auth/register.html
new file mode 100644
index 0000000..8521fb9
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/auth/register.html
@@ -0,0 +1,51 @@
+{% extends "partials/base.html" %}
+{% block title %}Sign Up{% endblock %}
+{% block content %}
+
+
+
+
+
Sign Up
+ {% for errors in form.errors.values() %}
+ {% for error in errors %}
+
{{ error }}
+ {% endfor %}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/auth/view_profile.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/auth/view_profile.html
new file mode 100644
index 0000000..815aac2
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/auth/view_profile.html
@@ -0,0 +1,17 @@
+{% extends "partials/base.html" %}
+{% block title %}{{ current_user.username }}'s Profile{% endblock %}
+
+{% block content %}
+
+
+
+
+
{{ current_user.username }}
+
Password: {{ current_user.password }}
+
+
+
+
+
+{% endblock %}
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/macros.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/macros.html
new file mode 100644
index 0000000..369bb50
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/macros.html
@@ -0,0 +1,5 @@
+{% macro is_active(endpoint_name) %}
+{% if endpoint_name == request.endpoint %}
+active-link
+{% endif %}
+{% endmacro %}
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/main/about.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/main/about.html
new file mode 100644
index 0000000..b47cf6d
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/main/about.html
@@ -0,0 +1,81 @@
+{% extends "partials/base.html" %}
+{% block title %}About Us{%endblock %}
+{% block content %}
+
+
+
+
✨ About GlobeTales
+
+ At GlobeTales , we believe that every journey is a story waiting to be told. Whether you're wandering through the lantern-lit alleys of Kyoto, sipping espresso in Rome, or gazing at the northern lights in Iceland – your travel moments matter.
+
+
+ Founded by a team of wanderlusters, photographers, and storytellers, GlobeTales was born from a simple idea: to connect travelers with authentic experiences and hidden gems across the globe. Join our growing community, and let’s make your next trip unforgettable.
+
+
+
+
+
+
+
+
+
Frequently Asked Questions
+
+
+
+
+
+ We focus on curated, authentic travel stories and hidden destinations, not just mainstream tourist spots. Each listing is handpicked for its uniqueness and cultural value.
+
+
+
+
+
+
+
+
+ Absolutely! We welcome contributions from travelers around the world. Simply sign up, and you can start submitting your travel tales, tips, and photos.
+
+
+
+
+
+
+
+
+ Yes! Browsing destinations, reading stories, and accessing our travel guides is completely free. Premium features like trip planning tools are optional.
+
+
+
+
+
+
+
+
+ Submit at least 3 unique travel stories with high-quality images. Our editors review content monthly and select standout travelers to feature on the homepage!
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/main/index.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/main/index.html
new file mode 100644
index 0000000..75f6ff9
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/main/index.html
@@ -0,0 +1,129 @@
+{% extends "partials/base.html" %}
+{% block title %}Home{% endblock %}
+{% block content %}
+
+
+
Travel the World in Style 🌍
+
Discover places, stories, and unforgettable adventures with GlobeTales.
+
+
+
Explore Destinations
+
+
+
+ 🤝 Our Trusted Partners
+
+
FlyGo Airlines
+
GlobeHotels
+
ExploreMore Tours
+
SafeTrip Insurance
+
PaySecure
+
+
+
+
🌎 The best tours
+
+
+
+
+
+
🇫🇷 Paris
+
The city of love, fashion, and timeless beauty.
+
+
+
+
+
+
+
+
🇯🇵 Tokyo
+
A fusion of ancient traditions and modern innovation.
+
+
+
+
+
+
+
+
🇺🇸 New York
+
The city that never sleeps. Lights, life, and skyscrapers.
+
+
+
+
+
+
+
+
+
❤️ What Our Travelers Say
+
+
+
+
+
+
"Best trip ever! The guides were amazing and the itinerary was perfect."
+
- Sarah M.
+
+
+
+
+
+
+
"GlobeTales made our vacation unforgettable. Highly recommended!"
+
- Alex P.
+
+
+
+
+
+
+
"Smooth booking, great communication, and fantastic experiences."
+
- Emily R.
+
+
+
+
+
+ 1
+ 2
+ 3
+
+
+
+
+
+
+
📧 Stay Updated!
+
Subscribe to get the latest deals and travel inspiration straight to your inbox.
+
+
+
+
+
+
+
Ready for Your Next Adventure?
+
Book your dream tour today with GlobeTales.
+
Get Started
+
+
+
+
+
+ {% for user in users %}
+
{{user}}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/partials/base.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/partials/base.html
new file mode 100644
index 0000000..b304467
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/partials/base.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ GlobeTales | {% block title %}{%endblock %}
+
+
+
+
+{% include "partials/nav.html" %}
+{% for message in get_flashed_messages(with_categories=True) if message %}
+
+{% endfor %}
+{% block content%}
+{% endblock %}
+{% include "partials/footer.html" %}
+
+
+
+
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/partials/footer.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/partials/footer.html
new file mode 100644
index 0000000..4527dab
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/partials/footer.html
@@ -0,0 +1,30 @@
+
+
+
+
+
GlobeTales
+
GlobeTales is your gateway to discovering the most beautiful places around the globe. Start your
+ journey today!
+
+
+
+
+
+
+ © 2025 GlobeTales. All rights reserved.
+
+
+
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/partials/nav.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/partials/nav.html
new file mode 100644
index 0000000..cc43bbb
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/partials/nav.html
@@ -0,0 +1,26 @@
+{% from "macros.html" import is_active %}
+
+
+
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/product/add_tour.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/product/add_tour.html
new file mode 100644
index 0000000..30877d5
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/product/add_tour.html
@@ -0,0 +1,58 @@
+{% extends "partials/base.html" %}
+{% block title %}Add Tour{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/product/tour_detail.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/product/tour_detail.html
new file mode 100644
index 0000000..6307436
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/product/tour_detail.html
@@ -0,0 +1,31 @@
+{% extends "partials/base.html" %}
+{% block title %}{{tour.title}}{%endblock %}
+{% block content %}
+
+
+
+
+
{{tour.title}}
+
Country: {{tour.country}}
+
+
+ {{tour.price}}
+ {{old_price}}
+
+
+
+ {{tour.description}}
+
+
+
+ Duration: {{tour.duration}}
+
+
+
+ Book Now
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/product/trips.html b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/product/trips.html
new file mode 100644
index 0000000..869804a
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/templates/product/trips.html
@@ -0,0 +1,49 @@
+{% extends "partials/base.html" %}
+{% block title %}Trips{%endblock %}
+{% block content %}
+
+
+
+
🌎 Featured Trips
+ {% if current_user.is_authenticated %}
+
Create
+ {% endif %}
+
+
+ {% if tours %}
+ {% for tour in tours %}
+
+ {% endfor %}
+ {% else %}
+
No trips available.
+ {% endif %}
+
+
+
+
+
+
✈️ Plan Your Next Trip
+
Whether you're seeking a relaxing beach vacation or an adventurous mountain hike, GlobeTales
+ has the perfect destination for you. Start planning your trip today!
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/utils.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/utils.py
new file mode 100644
index 0000000..cec4267
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/utils.py
@@ -0,0 +1,11 @@
+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("/")
+ return func(*args, **kwargs)
+ return admin_view
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/views/__init__.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/__init__.py
new file mode 100644
index 0000000..0bac62b
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/__init__.py
@@ -0,0 +1,3 @@
+from src.views.auth.routes import auth_blueprint
+from src.views.product.routes import product_blueprint
+from src.views.main.routes import main_blueprint
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/views/auth/forms.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/auth/forms.py
new file mode 100644
index 0000000..91facd4
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/auth/forms.py
@@ -0,0 +1,78 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, EmailField, SubmitField, DateField, RadioField, SelectField, \
+ IntegerField, FloatField
+from wtforms.validators import DataRequired, length, equal_to, ValidationError
+from string import ascii_uppercase, ascii_lowercase, ascii_letters, digits, punctuation
+from flask_wtf.file import FileField, FileSize, FileAllowed, FileRequired
+
+
+class LoginForm(FlaskForm):
+ username = StringField(
+ "Enter username",
+ validators=[DataRequired(message="Username is required")])
+ password = PasswordField(
+ "Enter password",
+ validators=[DataRequired(message="Password is required")])
+ login = SubmitField("Login")
+
+class RegisterForm(FlaskForm):
+ username = StringField("Enter username", validators=[DataRequired(message='Please enter your username')])
+ email = EmailField("Enter email", validators=[DataRequired(message='Please enter your email')])
+ password = PasswordField("Enter password",
+ validators=[DataRequired(message='Please enter your password'), length(min=8, max=64)])
+ repeat_password = PasswordField("Repeat password", validators=[DataRequired(message='Please confirm your password'),
+ equal_to("password",
+ message="Passwords must match.")])
+ birthday = DateField("Enter birthday", validators=[DataRequired(message='Please enter your birthday')])
+ gender = RadioField("Choose gender", choices=[(0, "Male"), (1, "Female")],
+ validators=[DataRequired(message='Please choose your gender')])
+ country = SelectField(
+ "Choose country",
+ choices=[
+ ("us", "United States"),
+ ("ca", "Canada"),
+ ("gb", "United Kingdom"),
+ ("au", "Australia"),
+ ("de", "Germany"),
+ ("fr", "France"),
+ ("it", "Italy"),
+ ("es", "Spain"),
+ ("jp", "Japan"),
+ ("cn", "China"),
+ ("in", "India"),
+ ("br", "Brazil"),
+ ("mx", "Mexico"),
+ ("ru", "Russia"),
+ ("za", "South Africa"),
+ ("ng", "Nigeria"),
+ ("eg", "Egypt")
+ ],
+ validators=[DataRequired()])
+ profile_image = FileField("Upload Profile Image",
+ validators=[FileSize(1024 * 1024), FileAllowed(["jpg", "png", "jpeg"])])
+ submit = SubmitField("Sign Up")
+
+ 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("Password must contain at least one uppercase letter")
+ if not contains_lowercase:
+ raise ValidationError("Password must contain at least one lowercase letter")
+ if not contains_digits:
+ raise ValidationError("Password must contain at least one digit (0-9)")
+ if not contains_symbols:
+ raise ValidationError("Password must contain at least one special character (e.g., !, @, #, etc.)")
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/views/auth/routes.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/auth/routes.py
new file mode 100644
index 0000000..fd2606a
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/auth/routes.py
@@ -0,0 +1,51 @@
+from uuid import uuid4
+from os import path
+from flask import Blueprint, render_template, redirect, request, flash, get_flashed_messages
+from src.views.auth.forms import RegisterForm, LoginForm
+from src.config import Config
+from src.models import User
+from flask_login import login_user, logout_user, current_user, login_required
+
+auth_blueprint = Blueprint("auth", __name__)
+@auth_blueprint.route("/register", methods=["GET", "POST"])
+def register():
+ 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()
+ return redirect("/login")
+ else:
+ print(form.errors)
+ return render_template("auth/register.html", form=form)
+
+
+@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("Success!", "success")
+ return redirect("/")
+ else:
+ flash("Username or password is incorrect", "danger")
+ return render_template("auth/login.html", form=form)
+
+
+@auth_blueprint.route("/logout")
+def logout():
+ logout_user()
+ return redirect("/login")
+
+@auth_blueprint.route("/profile")
+@login_required
+def view_profile():
+ return render_template("auth/view_profile.html")
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/views/main/routes.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/main/routes.py
new file mode 100644
index 0000000..a6860a0
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/main/routes.py
@@ -0,0 +1,11 @@
+from flask import Blueprint, render_template
+from flask_login import current_user
+main_blueprint = Blueprint("main", __name__)
+
+@main_blueprint.route("/")
+def index():
+ return render_template("main/index.html")
+
+@main_blueprint.route("/about")
+def about():
+ return render_template("main/about.html")
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/views/product/forms.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/product/forms.py
new file mode 100644
index 0000000..24f3a75
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/product/forms.py
@@ -0,0 +1,107 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, SubmitField, SelectField, FloatField
+from wtforms.validators import DataRequired, length
+from flask_wtf.file import FileField, FileSize, FileAllowed, FileRequired
+
+class AddTourForm(FlaskForm):
+ title = StringField("Tour Title", validators=[DataRequired(message="Please enter a title"), length(max=100)])
+ country = SelectField(
+ "Country",
+ choices=[
+ ('', 'Select a country'),
+ ('AF', 'Afghanistan'),
+ ('AL', 'Albania'),
+ ('DZ', 'Algeria'),
+ ('AR', 'Argentina'),
+ ('AU', 'Australia'),
+ ('AT', 'Austria'),
+ ('BD', 'Bangladesh'),
+ ('BE', 'Belgium'),
+ ('BR', 'Brazil'),
+ ('BG', 'Bulgaria'),
+ ('CA', 'Canada'),
+ ('CL', 'Chile'),
+ ('CN', 'China'),
+ ('CO', 'Colombia'),
+ ('HR', 'Croatia'),
+ ('CZ', 'Czech Republic'),
+ ('DK', 'Denmark'),
+ ('EG', 'Egypt'),
+ ('EE', 'Estonia'),
+ ('FI', 'Finland'),
+ ('FR', 'France'),
+ ('DE', 'Germany'),
+ ('GR', 'Greece'),
+ ('HU', 'Hungary'),
+ ('IS', 'Iceland'),
+ ('IN', 'India'),
+ ('ID', 'Indonesia'),
+ ('IR', 'Iran'),
+ ('IQ', 'Iraq'),
+ ('IE', 'Ireland'),
+ ('IL', 'Israel'),
+ ('IT', 'Italy'),
+ ('JP', 'Japan'),
+ ('JO', 'Jordan'),
+ ('KZ', 'Kazakhstan'),
+ ('KE', 'Kenya'),
+ ('KR', 'South Korea'),
+ ('KW', 'Kuwait'),
+ ('LV', 'Latvia'),
+ ('LB', 'Lebanon'),
+ ('LT', 'Lithuania'),
+ ('MY', 'Malaysia'),
+ ('MX', 'Mexico'),
+ ('MA', 'Morocco'),
+ ('NL', 'Netherlands'),
+ ('NZ', 'New Zealand'),
+ ('NG', 'Nigeria'),
+ ('NO', 'Norway'),
+ ('PK', 'Pakistan'),
+ ('PE', 'Peru'),
+ ('PH', 'Philippines'),
+ ('PL', 'Poland'),
+ ('PT', 'Portugal'),
+ ('QA', 'Qatar'),
+ ('RO', 'Romania'),
+ ('RU', 'Russia'),
+ ('SA', 'Saudi Arabia'),
+ ('RS', 'Serbia'),
+ ('SG', 'Singapore'),
+ ('SK', 'Slovakia'),
+ ('SI', 'Slovenia'),
+ ('ZA', 'South Africa'),
+ ('ES', 'Spain'),
+ ('SE', 'Sweden'),
+ ('CH', 'Switzerland'),
+ ('SY', 'Syria'),
+ ('TH', 'Thailand'),
+ ('TR', 'Turkey'),
+ ('UA', 'Ukraine'),
+ ('AE', 'United Arab Emirates'),
+ ('GB', 'United Kingdom'),
+ ('US', 'United States'),
+ ('VN', 'Vietnam'),
+ ('YE', 'Yemen'),
+ ('ZW', 'Zimbabwe'),
+ ],
+ validators=[DataRequired(message="Please select a country")]
+ )
+ description = StringField("Description", validators=[DataRequired(message="Please enter a description")])
+ price = FloatField("Current Price",
+ validators=[DataRequired(message="Please enter the price")])
+ currency = SelectField(
+ "Currency",
+ choices=[
+ ("USD", "USD"),
+ ("EUR", "EUR"),
+ ("AUD", "AUD"),
+ ("GBP", "GBP"),
+ ("JPY", "JPY")
+ ],
+ validators=[DataRequired(message="Please select a currency")]
+ )
+ image = FileField("Upload Image",
+ validators=[FileSize(1024 * 1024), FileAllowed(["jpg", "png", "jpeg"])])
+ duration = StringField("Duration (e.g. 4 days)", validators=[DataRequired(message="Please enter duration")])
+ submit = SubmitField("Add Tour")
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/src/views/product/routes.py b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/product/routes.py
new file mode 100644
index 0000000..32f6a79
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/src/views/product/routes.py
@@ -0,0 +1,79 @@
+from flask import Blueprint, render_template, redirect, url_for
+from uuid import uuid4
+from os import path
+from src.views.product.forms import AddTourForm
+from src.models.product import Tour
+from src.config import Config
+from flask_login import login_required
+from src.utils import admin_required
+
+product_blueprint = Blueprint("products", __name__)
+
+@product_blueprint.route("/trips")
+def trips():
+ tours = Tour.query.all()
+ return render_template("product/trips.html", tours=tours)
+
+@product_blueprint.route("/trips/")
+def tour_detail(tour_id):
+ chosen_tour = Tour.query.get(tour_id)
+ return render_template("product/tour_detail.html", tour=chosen_tour)
+
+@product_blueprint.route("/add_tour", methods=["GET", "POST"])
+@admin_required
+def add_tour():
+ form = AddTourForm()
+ 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_tour = Tour(country=form.country.data,
+ title=form.title.data,
+ description=form.description.data,
+ price=form.price.data,
+ currency=form.currency.data,
+ duration=form.duration.data,
+ image = filename)
+ new_tour.image = filename
+ new_tour.create()
+ return redirect(url_for("products.trips"))
+ else:
+ print(form.errors)
+ return render_template("product/add_tour.html", form=form)
+
+@product_blueprint.route("/edit_tour/", methods=["GET", "POST"])
+@admin_required
+def edit_tour(tour_id):
+ tour = Tour.query.get(tour_id)
+ form = AddTourForm(country=tour.country,
+ title=tour.title,
+ description=tour.description,
+ price=tour.price,
+ currency=tour.currency,
+ duration=tour.duration)
+ if form.validate_on_submit():
+ tour.country = form.country.data
+ tour.title = form.title.data
+ tour.description = form.description.data
+ tour.price = form.price.data
+ tour.currency = form.currency.data
+ tour.duration = form.duration.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))
+ tour.image = filename
+ tour.save()
+ return redirect(url_for("products.trips"))
+ return render_template("product/add_tour.html", form=form)
+
+
+@product_blueprint.route("/delete_tour/")
+@admin_required
+def delete_tour(tour_id):
+ tour = Tour.query.get(tour_id)
+ tour.delete()
+ return redirect(url_for("products.trips"))
\ No newline at end of file
diff --git a/Chapter09_Admin/Projects/Salome_Papashvili/uwsgi.conf b/Chapter09_Admin/Projects/Salome_Papashvili/uwsgi.conf
new file mode 100644
index 0000000..50a5ff7
--- /dev/null
+++ b/Chapter09_Admin/Projects/Salome_Papashvili/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/Salome_Papashvili/app.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/app.py
new file mode 100644
index 0000000..4c71782
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/app.py
@@ -0,0 +1,5 @@
+from src import create_app
+app = create_app()
+
+if __name__ == "__main__":
+ app.run(debug=True)
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/dev-requirements.txt b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/dev-requirements.txt
new file mode 100644
index 0000000..b8a9de4
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/dev-requirements.txt differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/migrations/README b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/migrations/README
new file mode 100644
index 0000000..0e04844
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/migrations/README
@@ -0,0 +1 @@
+Single-database configuration for Flask.
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/migrations/alembic.ini b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/migrations/alembic.ini
new file mode 100644
index 0000000..ec9d45c
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/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/Salome_Papashvili/migrations/env.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/migrations/env.py
new file mode 100644
index 0000000..4c97092
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/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/Salome_Papashvili/migrations/script.py.mako b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/migrations/script.py.mako
new file mode 100644
index 0000000..2c01563
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/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/Salome_Papashvili/prod-requirements.txt b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/prod-requirements.txt
new file mode 100644
index 0000000..42e0d89
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/prod-requirements.txt
@@ -0,0 +1,6 @@
+Flask==3.1.0
+Flask-Login==0.6.3
+Flask-Migrate==4.1.0
+Flask-SQLAlchemy==3.1.1
+Flask-WTF==1.2.2
+uwsgi
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/pytest.ini b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/pytest.ini
new file mode 100644
index 0000000..03f586d
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+pythonpath = .
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/__init__.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/__init__.py
new file mode 100644
index 0000000..ce42cd3
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/__init__.py
@@ -0,0 +1,44 @@
+from flask import Flask
+
+from src.commands import init_db_command, populate_db_command
+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.models import User, Tour
+from src.admin_views.base import SecureModelView
+from src.admin_views import UserView, TourView
+from flask_admin.menu import MenuLink
+
+BLUEPRINTS = [main_blueprint, product_blueprint, auth_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):
+ db.init_app(app)
+ migrate.init_app(app, db)
+ login_manager.init_app(app)
+ login_manager.login_view = 'auth.login'
+ @login_manager.user_loader
+ def load_user(_id):
+ return User.query.get(_id)
+
+ admin.init_app(app)
+ admin.add_view(UserView(User, db.session))
+ admin.add_view(TourView(Tour, db.session))
+ admin.add_link(MenuLink("To site", url="/"))
+
+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)
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/__init__.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/__init__.py
new file mode 100644
index 0000000..50bac91
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/__init__.py
@@ -0,0 +1,2 @@
+from src.admin_views.user import UserView
+from src.admin_views.product import TourView
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/base.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/base.py
new file mode 100644
index 0000000..4c3ff55
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/base.py
@@ -0,0 +1,28 @@
+from flask_admin.contrib.sqla import ModelView
+from flask_admin import AdminIndexView
+from flask_login import current_user
+from flask import redirect, url_for, flash
+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 current_user.is_authenticated:
+ return redirect(url_for("auth.login"))
+ else:
+ flash("You do not have permission to access this page.", "warning")
+ return redirect(url_for("main.index"))
+
+
+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 current_user.is_authenticated:
+ return redirect(url_for("auth.login"))
+ else:
+ flash("You do not have permission to access the admin panel.", "warning")
+ return redirect(url_for("main.index"))
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/product.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/product.py
new file mode 100644
index 0000000..fce554d
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/product.py
@@ -0,0 +1,28 @@
+from src.admin_views.base import SecureModelView
+from flask_admin.form import ImageUploadField
+from src.config import Config
+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 TourView(SecureModelView):
+ create_modal = True
+ edit_modal = True
+ column_editable_list = ("price", "title")
+ column_filters = ("price", "title", "country")
+ column_formatters = {
+ "title": lambda v, c, m, n: m.title if len(m.title) < 24 else m.title[:16] + "...",
+ "description": lambda v, c, m, n: m.description if len(m.description) < 24 else m.description[:16] + "...",
+ "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/Salome_Papashvili/src/admin_views/user.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/user.py
new file mode 100644
index 0000000..f9bb31a
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/admin_views/user.py
@@ -0,0 +1,37 @@
+from src.admin_views.base import SecureModelView
+from wtforms.fields import SelectField
+from os import path
+from uuid import uuid4
+from flask_admin.form import ImageUploadField
+from src.config import Config
+
+def generate_filename(obj, file):
+ name, extension = path.splitext(file.filename)
+ return f"{uuid4()}{extension}"
+
+class UserView(SecureModelView):
+ can_create = True
+ can_edit = False
+ can_delete = False
+ can_view_details = True
+ create_modal = True
+ edit_modal = True
+ # column_exclude_list = ["_password"]
+ column_list = ["username", "role"]
+ column_details_list = ["username", "role", "profile_image"]
+ column_searchable_list = ["username", "role"]
+
+ form_overrides = {
+ "profile_image": ImageUploadField,
+ "role": SelectField
+ }
+
+ form_args = {
+ "profile_image": {
+ "base_path": Config.UPLOAD_PATH,
+ "namegen": generate_filename
+ },
+ "role": {
+ "choices": ["Admin", "Moderator", "User"]
+ }
+ }
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/commands.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/commands.py
new file mode 100644
index 0000000..1e7bcbf
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/commands.py
@@ -0,0 +1,89 @@
+from flask.cli import with_appcontext
+import click
+from src.ext import db
+from src.models import Tour, User
+
+def init_db():
+ db.drop_all()
+ db.create_all()
+
+def populate_db():
+ tours = [
+ {
+ "id": 0,
+ "country": "Italy",
+ "title": "Rome Ancient Wonders Tour",
+ "description": "Discover the Colosseum, Roman Forum, and Vatican City.",
+ "price": 279.99,
+ "currency": "EUR",
+ "image": "https://www.agoda.com/wp-content/uploads/2024/08/Colosseum-Rome-Featured.jpg",
+ "duration": "4 days",
+ },
+ {
+ "id": 1,
+ "country": "Egypt",
+ "title": "Cairo & Pyramids Adventure",
+ "description": "Visit the Great Pyramids of Giza and the Egyptian Museum.",
+ "price": 349.99,
+ "currency": "USD",
+ "image": "https://upload.wikimedia.org/wikipedia/commons/a/af/All_Gizah_Pyramids.jpg",
+ "duration": "5 days",
+ },
+ {
+ "id": 2,
+ "country": "Australia",
+ "title": "Sydney & Blue Mountains",
+ "description": "Explore Sydney Opera House, Harbour Bridge, and Blue Mountains.",
+ "price": 499.99,
+ "currency": "AUD",
+ "image": "https://www.sydneytravelguide.com.au/wp-content/uploads/2024/09/sydney-australia.jpg",
+ "duration": "6 days",
+ },
+ {
+ "id": 3,
+ "country": "Spain",
+ "title": "Barcelona",
+ "description": "Admire Sagrada Familia, Park Güell, and Gothic Quarter.",
+ "price": 259.99,
+ "currency": "EUR",
+ "image": "https://www.introducingbarcelona.com/f/espana/barcelona/barcelona.jpg",
+ "duration": "3 days",
+ },
+ {
+ "id": 4,
+ "country": "Turkey",
+ "title": "Istanbul Heritage Tour",
+ "description": "Visit Hagia Sophia, Blue Mosque, and Grand Bazaar.",
+ "price": 199.99,
+ "currency": "USD",
+ "image": "https://deih43ym53wif.cloudfront.net/small_cappadocia-turkey-shutterstock_1320608780_9fc0781106.jpeg",
+ "duration": "4 days",
+ }
+ ]
+
+ for tour in tours:
+ new_tour = Tour(country=tour["country"],
+ title=tour["title"],
+ description=tour["description"],
+ price=tour["price"],
+ currency=tour["currency"],
+ image=tour["image"],
+ duration=tour["duration"])
+ db.session.add(new_tour)
+ db.session.commit()
+
+ # Admin
+ User(username="Admin", password="password12345", profile_image="admin.png", 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/Salome_Papashvili/src/config.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/config.py
new file mode 100644
index 0000000..186856b
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/config.py
@@ -0,0 +1,8 @@
+from os import path
+
+class Config(object):
+ BASE_DIRECTORY = path.abspath(path.dirname(__file__))
+ SQLALCHEMY_DATABASE_URI = "sqlite:///database.db"
+ SECRET_KEY = "GJFKLDJKljklhhjkhjk@595jijkjd"
+ UPLOAD_PATH = path.join(BASE_DIRECTORY, "static", "upload")
+ FLASK_ADMIN_SWATCH = "journal"
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/ext.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/ext.py
new file mode 100644
index 0000000..546a50f
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/ext.py
@@ -0,0 +1,10 @@
+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(template_mode="bootstrap4", index_view=SecureIndexView(), base_template="admin/admin_base.html")
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/__init__.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/__init__.py
new file mode 100644
index 0000000..3a2935e
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/__init__.py
@@ -0,0 +1,3 @@
+from src.models.product import Tour
+from src.models.person import Person
+from src.models.user import User
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/base.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/base.py
new file mode 100644
index 0000000..2f9ab61
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/base.py
@@ -0,0 +1,16 @@
+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/Salome_Papashvili/src/models/person.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/person.py
new file mode 100644
index 0000000..696165c
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/person.py
@@ -0,0 +1,13 @@
+from src.ext import db
+from src.models.base import BaseModel
+class Person(BaseModel):
+ __tablename__ = "persons"
+
+ id = db.Column(db.Integer, primary_key=True)
+ username = db.Column(db.String, nullable=False)
+ email = db.Column(db.String, nullable=False, unique=True)
+ password = db.Column(db.String, nullable=False)
+ birthday = db.Column(db.Date, nullable=False)
+ gender = db.Column(db.String, nullable=False)
+ country = db.Column(db.String, nullable=False)
+ profile_image = db.Column(db.String)
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/product.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/product.py
new file mode 100644
index 0000000..4ca17f8
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/product.py
@@ -0,0 +1,12 @@
+from src.ext import db
+from src.models.base import BaseModel
+class Tour(BaseModel):
+ __tablename__ = "tours"
+ id = db.Column(db.Integer, primary_key=True)
+ country = db.Column(db.String)
+ title = db.Column(db.String)
+ description = db.Column(db.String)
+ price = db.Column(db.Float)
+ currency = db.Column(db.String)
+ image = db.Column(db.String)
+ duration = db.Column(db.String)
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/user.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/user.py
new file mode 100644
index 0000000..4bb1569
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/models/user.py
@@ -0,0 +1,23 @@
+from src.ext import db
+from flask_login import UserMixin
+from werkzeug.security import generate_password_hash, check_password_hash
+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, default='User')
+ def check_password(self, password):
+ return check_password_hash(self.password, password)
+ def is_admin(self):
+ return self.role == "Admin"
+ @property
+ def password(self):
+ print("GETTER")
+ return self._password
+ @password.setter
+ def password(self, password):
+ print("SETTER")
+ self._password = generate_password_hash(password)
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/styles/style.css b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/styles/style.css
new file mode 100644
index 0000000..e23ecd0
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/styles/style.css
@@ -0,0 +1,80 @@
+*
+{
+ list-style-type: none;
+}
+body
+{
+ background: linear-gradient(to right, #e0f7fa, #fff);
+}
+.hero
+{
+ height: 90vh;
+ background: linear-gradient(rgba(0,0,0,0.3), #0000005e),
+ url('https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8dHJhdmVsfGVufDB8fDB8fHww') center/cover no-repeat;
+ color: white;
+}
+.flex
+{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 30px;
+}
+.custom-hero
+{
+ height: 20vh;
+}
+h1
+{
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.6);
+}
+.nav-link
+{
+ color: #fff;
+}
+.active-link
+{
+ color: #268a97;
+}
+.card img
+{
+ height: 200px;
+ object-fit: cover;
+}
+.btn-custom
+{
+ background: #268a97;
+ border: #02C211;
+ color: #fff;
+}
+.btn-custom:hover
+{
+ background: #16626C;;
+ color: #fff;
+}
+footer
+{
+ background-color: #16626C;
+ color: white;
+}
+::-webkit-scrollbar
+{
+ display: block;
+ width: 12px;
+}
+::-webkit-scrollbar-thumb
+{
+ background-color: #268a97;
+}
+::-webkit-scrollbar-track
+{
+ background-color: #fff;
+}
+.dot
+{
+ border-radius: 50%;
+}
+.image
+{
+ width: 50%;
+}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png
new file mode 100644
index 0000000..d14294c
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png
new file mode 100644
index 0000000..956fb47
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg
new file mode 100644
index 0000000..3cd7897
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png
new file mode 100644
index 0000000..956fb47
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/be2ca709-1f08-49bd-9839-ee7031292dfc.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/be2ca709-1f08-49bd-9839-ee7031292dfc.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/be2ca709-1f08-49bd-9839-ee7031292dfc.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg
new file mode 100644
index 0000000..3cd7897
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/ce84803b-d1a0-460f-b3cc-87332eff91e3.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/ce84803b-d1a0-460f-b3cc-87332eff91e3.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/ce84803b-d1a0-460f-b3cc-87332eff91e3.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/d610a890-14f6-470b-91f6-b8e3add2182c.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/d610a890-14f6-470b-91f6-b8e3add2182c.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/d610a890-14f6-470b-91f6-b8e3add2182c.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png
new file mode 100644
index 0000000..d14294c
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/fa898a0e-74a4-4661-ab18-796778100cc0.png b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/fa898a0e-74a4-4661-ab18-796778100cc0.png
new file mode 100644
index 0000000..15f336e
Binary files /dev/null and b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/static/upload/fa898a0e-74a4-4661-ab18-796778100cc0.png differ
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/admin/admin_base.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/admin/admin_base.html
new file mode 100644
index 0000000..4f36ed6
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/admin/admin_base.html
@@ -0,0 +1,45 @@
+{% extends "admin/base.html" %}
+
+{% block page_body %}
+
+
+
+
+
+
+
+
+
+ {% block brand %}
+
{{ admin_view.admin.name }}
+ {% endblock %}
+ {% block main_menu %}
+
+ {% endblock %}
+
+ {% block menu_links %}
+
+ {{ layout.menu_links() }}
+
+ {% endblock %}
+ {% block access_control %}
+ {% endblock %}
+
+
+
+
+
+
+ {% 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/Salome_Papashvili/src/templates/auth/login.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/auth/login.html
new file mode 100644
index 0000000..17cb90d
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/auth/login.html
@@ -0,0 +1,27 @@
+{% extends "partials/base.html" %}
+{% block title %}Sign Up{% endblock %}
+{% block content %}
+
+
+
+
+
Log In
+ {% for field, errors in form.errors.items() %}
+ {% for error in errors %}
+
{{ error }}
+ {% endfor %}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/auth/register.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/auth/register.html
new file mode 100644
index 0000000..8521fb9
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/auth/register.html
@@ -0,0 +1,51 @@
+{% extends "partials/base.html" %}
+{% block title %}Sign Up{% endblock %}
+{% block content %}
+
+
+
+
+
Sign Up
+ {% for errors in form.errors.values() %}
+ {% for error in errors %}
+
{{ error }}
+ {% endfor %}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/auth/view_profile.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/auth/view_profile.html
new file mode 100644
index 0000000..815aac2
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/auth/view_profile.html
@@ -0,0 +1,17 @@
+{% extends "partials/base.html" %}
+{% block title %}{{ current_user.username }}'s Profile{% endblock %}
+
+{% block content %}
+
+
+
+
+
{{ current_user.username }}
+
Password: {{ current_user.password }}
+
+
+
+
+
+{% endblock %}
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/macros.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/macros.html
new file mode 100644
index 0000000..369bb50
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/macros.html
@@ -0,0 +1,5 @@
+{% macro is_active(endpoint_name) %}
+{% if endpoint_name == request.endpoint %}
+active-link
+{% endif %}
+{% endmacro %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/main/about.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/main/about.html
new file mode 100644
index 0000000..b47cf6d
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/main/about.html
@@ -0,0 +1,81 @@
+{% extends "partials/base.html" %}
+{% block title %}About Us{%endblock %}
+{% block content %}
+
+
+
+
✨ About GlobeTales
+
+ At GlobeTales , we believe that every journey is a story waiting to be told. Whether you're wandering through the lantern-lit alleys of Kyoto, sipping espresso in Rome, or gazing at the northern lights in Iceland – your travel moments matter.
+
+
+ Founded by a team of wanderlusters, photographers, and storytellers, GlobeTales was born from a simple idea: to connect travelers with authentic experiences and hidden gems across the globe. Join our growing community, and let’s make your next trip unforgettable.
+
+
+
+
+
+
+
+
+
Frequently Asked Questions
+
+
+
+
+
+ We focus on curated, authentic travel stories and hidden destinations, not just mainstream tourist spots. Each listing is handpicked for its uniqueness and cultural value.
+
+
+
+
+
+
+
+
+ Absolutely! We welcome contributions from travelers around the world. Simply sign up, and you can start submitting your travel tales, tips, and photos.
+
+
+
+
+
+
+
+
+ Yes! Browsing destinations, reading stories, and accessing our travel guides is completely free. Premium features like trip planning tools are optional.
+
+
+
+
+
+
+
+
+ Submit at least 3 unique travel stories with high-quality images. Our editors review content monthly and select standout travelers to feature on the homepage!
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/main/index.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/main/index.html
new file mode 100644
index 0000000..75f6ff9
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/main/index.html
@@ -0,0 +1,129 @@
+{% extends "partials/base.html" %}
+{% block title %}Home{% endblock %}
+{% block content %}
+
+
+
Travel the World in Style 🌍
+
Discover places, stories, and unforgettable adventures with GlobeTales.
+
+
+
Explore Destinations
+
+
+
+ 🤝 Our Trusted Partners
+
+
FlyGo Airlines
+
GlobeHotels
+
ExploreMore Tours
+
SafeTrip Insurance
+
PaySecure
+
+
+
+
🌎 The best tours
+
+
+
+
+
+
🇫🇷 Paris
+
The city of love, fashion, and timeless beauty.
+
+
+
+
+
+
+
+
🇯🇵 Tokyo
+
A fusion of ancient traditions and modern innovation.
+
+
+
+
+
+
+
+
🇺🇸 New York
+
The city that never sleeps. Lights, life, and skyscrapers.
+
+
+
+
+
+
+
+
+
❤️ What Our Travelers Say
+
+
+
+
+
+
"Best trip ever! The guides were amazing and the itinerary was perfect."
+
- Sarah M.
+
+
+
+
+
+
+
"GlobeTales made our vacation unforgettable. Highly recommended!"
+
- Alex P.
+
+
+
+
+
+
+
"Smooth booking, great communication, and fantastic experiences."
+
- Emily R.
+
+
+
+
+
+ 1
+ 2
+ 3
+
+
+
+
+
+
+
📧 Stay Updated!
+
Subscribe to get the latest deals and travel inspiration straight to your inbox.
+
+
+
+
+
+
+
Ready for Your Next Adventure?
+
Book your dream tour today with GlobeTales.
+
Get Started
+
+
+
+
+
+ {% for user in users %}
+
{{user}}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/partials/base.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/partials/base.html
new file mode 100644
index 0000000..b304467
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/partials/base.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ GlobeTales | {% block title %}{%endblock %}
+
+
+
+
+{% include "partials/nav.html" %}
+{% for message in get_flashed_messages(with_categories=True) if message %}
+
+{% endfor %}
+{% block content%}
+{% endblock %}
+{% include "partials/footer.html" %}
+
+
+
+
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/partials/footer.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/partials/footer.html
new file mode 100644
index 0000000..4527dab
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/partials/footer.html
@@ -0,0 +1,30 @@
+
+
+
+
+
GlobeTales
+
GlobeTales is your gateway to discovering the most beautiful places around the globe. Start your
+ journey today!
+
+
+
+
+
+
+ © 2025 GlobeTales. All rights reserved.
+
+
+
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/partials/nav.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/partials/nav.html
new file mode 100644
index 0000000..cc43bbb
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/partials/nav.html
@@ -0,0 +1,26 @@
+{% from "macros.html" import is_active %}
+
+
+
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/product/add_tour.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/product/add_tour.html
new file mode 100644
index 0000000..30877d5
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/product/add_tour.html
@@ -0,0 +1,58 @@
+{% extends "partials/base.html" %}
+{% block title %}Add Tour{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/product/tour_detail.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/product/tour_detail.html
new file mode 100644
index 0000000..6307436
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/product/tour_detail.html
@@ -0,0 +1,31 @@
+{% extends "partials/base.html" %}
+{% block title %}{{tour.title}}{%endblock %}
+{% block content %}
+
+
+
+
+
{{tour.title}}
+
Country: {{tour.country}}
+
+
+ {{tour.price}}
+ {{old_price}}
+
+
+
+ {{tour.description}}
+
+
+
+ Duration: {{tour.duration}}
+
+
+
+ Book Now
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/product/trips.html b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/product/trips.html
new file mode 100644
index 0000000..3b6fb8a
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/templates/product/trips.html
@@ -0,0 +1,49 @@
+{% extends "partials/base.html" %}
+{% block title %}Trips{%endblock %}
+{% block content %}
+
+
+
+
🌎 Featured Trips
+ {% if current_user.is_authenticated %}
+
Create
+ {% endif %}
+
+
+ {% if tours %}
+ {% for tour in tours %}
+
+ {% endfor %}
+ {% else %}
+
No trips available.
+ {% endif %}
+
+
+
+
+
+
✈️ Plan Your Next Trip
+
Whether you're seeking a relaxing beach vacation or an adventurous mountain hike, GlobeTales
+ has the perfect destination for you. Start planning your trip today!
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/utils.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/utils.py
new file mode 100644
index 0000000..2398c66
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/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 not current_user.is_authenticated:
+ return redirect("/")
+ if current_user.is_authenticated and not current_user.is_admin():
+ return redirect("/")
+ return func(*args, **kwargs)
+ return admin_view
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/__init__.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/__init__.py
new file mode 100644
index 0000000..0bac62b
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/__init__.py
@@ -0,0 +1,3 @@
+from src.views.auth.routes import auth_blueprint
+from src.views.product.routes import product_blueprint
+from src.views.main.routes import main_blueprint
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/auth/forms.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/auth/forms.py
new file mode 100644
index 0000000..91facd4
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/auth/forms.py
@@ -0,0 +1,78 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, EmailField, SubmitField, DateField, RadioField, SelectField, \
+ IntegerField, FloatField
+from wtforms.validators import DataRequired, length, equal_to, ValidationError
+from string import ascii_uppercase, ascii_lowercase, ascii_letters, digits, punctuation
+from flask_wtf.file import FileField, FileSize, FileAllowed, FileRequired
+
+
+class LoginForm(FlaskForm):
+ username = StringField(
+ "Enter username",
+ validators=[DataRequired(message="Username is required")])
+ password = PasswordField(
+ "Enter password",
+ validators=[DataRequired(message="Password is required")])
+ login = SubmitField("Login")
+
+class RegisterForm(FlaskForm):
+ username = StringField("Enter username", validators=[DataRequired(message='Please enter your username')])
+ email = EmailField("Enter email", validators=[DataRequired(message='Please enter your email')])
+ password = PasswordField("Enter password",
+ validators=[DataRequired(message='Please enter your password'), length(min=8, max=64)])
+ repeat_password = PasswordField("Repeat password", validators=[DataRequired(message='Please confirm your password'),
+ equal_to("password",
+ message="Passwords must match.")])
+ birthday = DateField("Enter birthday", validators=[DataRequired(message='Please enter your birthday')])
+ gender = RadioField("Choose gender", choices=[(0, "Male"), (1, "Female")],
+ validators=[DataRequired(message='Please choose your gender')])
+ country = SelectField(
+ "Choose country",
+ choices=[
+ ("us", "United States"),
+ ("ca", "Canada"),
+ ("gb", "United Kingdom"),
+ ("au", "Australia"),
+ ("de", "Germany"),
+ ("fr", "France"),
+ ("it", "Italy"),
+ ("es", "Spain"),
+ ("jp", "Japan"),
+ ("cn", "China"),
+ ("in", "India"),
+ ("br", "Brazil"),
+ ("mx", "Mexico"),
+ ("ru", "Russia"),
+ ("za", "South Africa"),
+ ("ng", "Nigeria"),
+ ("eg", "Egypt")
+ ],
+ validators=[DataRequired()])
+ profile_image = FileField("Upload Profile Image",
+ validators=[FileSize(1024 * 1024), FileAllowed(["jpg", "png", "jpeg"])])
+ submit = SubmitField("Sign Up")
+
+ 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("Password must contain at least one uppercase letter")
+ if not contains_lowercase:
+ raise ValidationError("Password must contain at least one lowercase letter")
+ if not contains_digits:
+ raise ValidationError("Password must contain at least one digit (0-9)")
+ if not contains_symbols:
+ raise ValidationError("Password must contain at least one special character (e.g., !, @, #, etc.)")
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/auth/routes.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/auth/routes.py
new file mode 100644
index 0000000..fd2606a
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/auth/routes.py
@@ -0,0 +1,51 @@
+from uuid import uuid4
+from os import path
+from flask import Blueprint, render_template, redirect, request, flash, get_flashed_messages
+from src.views.auth.forms import RegisterForm, LoginForm
+from src.config import Config
+from src.models import User
+from flask_login import login_user, logout_user, current_user, login_required
+
+auth_blueprint = Blueprint("auth", __name__)
+@auth_blueprint.route("/register", methods=["GET", "POST"])
+def register():
+ 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()
+ return redirect("/login")
+ else:
+ print(form.errors)
+ return render_template("auth/register.html", form=form)
+
+
+@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("Success!", "success")
+ return redirect("/")
+ else:
+ flash("Username or password is incorrect", "danger")
+ return render_template("auth/login.html", form=form)
+
+
+@auth_blueprint.route("/logout")
+def logout():
+ logout_user()
+ return redirect("/login")
+
+@auth_blueprint.route("/profile")
+@login_required
+def view_profile():
+ return render_template("auth/view_profile.html")
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/main/routes.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/main/routes.py
new file mode 100644
index 0000000..a6860a0
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/main/routes.py
@@ -0,0 +1,11 @@
+from flask import Blueprint, render_template
+from flask_login import current_user
+main_blueprint = Blueprint("main", __name__)
+
+@main_blueprint.route("/")
+def index():
+ return render_template("main/index.html")
+
+@main_blueprint.route("/about")
+def about():
+ return render_template("main/about.html")
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/product/forms.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/product/forms.py
new file mode 100644
index 0000000..24f3a75
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/product/forms.py
@@ -0,0 +1,107 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, SubmitField, SelectField, FloatField
+from wtforms.validators import DataRequired, length
+from flask_wtf.file import FileField, FileSize, FileAllowed, FileRequired
+
+class AddTourForm(FlaskForm):
+ title = StringField("Tour Title", validators=[DataRequired(message="Please enter a title"), length(max=100)])
+ country = SelectField(
+ "Country",
+ choices=[
+ ('', 'Select a country'),
+ ('AF', 'Afghanistan'),
+ ('AL', 'Albania'),
+ ('DZ', 'Algeria'),
+ ('AR', 'Argentina'),
+ ('AU', 'Australia'),
+ ('AT', 'Austria'),
+ ('BD', 'Bangladesh'),
+ ('BE', 'Belgium'),
+ ('BR', 'Brazil'),
+ ('BG', 'Bulgaria'),
+ ('CA', 'Canada'),
+ ('CL', 'Chile'),
+ ('CN', 'China'),
+ ('CO', 'Colombia'),
+ ('HR', 'Croatia'),
+ ('CZ', 'Czech Republic'),
+ ('DK', 'Denmark'),
+ ('EG', 'Egypt'),
+ ('EE', 'Estonia'),
+ ('FI', 'Finland'),
+ ('FR', 'France'),
+ ('DE', 'Germany'),
+ ('GR', 'Greece'),
+ ('HU', 'Hungary'),
+ ('IS', 'Iceland'),
+ ('IN', 'India'),
+ ('ID', 'Indonesia'),
+ ('IR', 'Iran'),
+ ('IQ', 'Iraq'),
+ ('IE', 'Ireland'),
+ ('IL', 'Israel'),
+ ('IT', 'Italy'),
+ ('JP', 'Japan'),
+ ('JO', 'Jordan'),
+ ('KZ', 'Kazakhstan'),
+ ('KE', 'Kenya'),
+ ('KR', 'South Korea'),
+ ('KW', 'Kuwait'),
+ ('LV', 'Latvia'),
+ ('LB', 'Lebanon'),
+ ('LT', 'Lithuania'),
+ ('MY', 'Malaysia'),
+ ('MX', 'Mexico'),
+ ('MA', 'Morocco'),
+ ('NL', 'Netherlands'),
+ ('NZ', 'New Zealand'),
+ ('NG', 'Nigeria'),
+ ('NO', 'Norway'),
+ ('PK', 'Pakistan'),
+ ('PE', 'Peru'),
+ ('PH', 'Philippines'),
+ ('PL', 'Poland'),
+ ('PT', 'Portugal'),
+ ('QA', 'Qatar'),
+ ('RO', 'Romania'),
+ ('RU', 'Russia'),
+ ('SA', 'Saudi Arabia'),
+ ('RS', 'Serbia'),
+ ('SG', 'Singapore'),
+ ('SK', 'Slovakia'),
+ ('SI', 'Slovenia'),
+ ('ZA', 'South Africa'),
+ ('ES', 'Spain'),
+ ('SE', 'Sweden'),
+ ('CH', 'Switzerland'),
+ ('SY', 'Syria'),
+ ('TH', 'Thailand'),
+ ('TR', 'Turkey'),
+ ('UA', 'Ukraine'),
+ ('AE', 'United Arab Emirates'),
+ ('GB', 'United Kingdom'),
+ ('US', 'United States'),
+ ('VN', 'Vietnam'),
+ ('YE', 'Yemen'),
+ ('ZW', 'Zimbabwe'),
+ ],
+ validators=[DataRequired(message="Please select a country")]
+ )
+ description = StringField("Description", validators=[DataRequired(message="Please enter a description")])
+ price = FloatField("Current Price",
+ validators=[DataRequired(message="Please enter the price")])
+ currency = SelectField(
+ "Currency",
+ choices=[
+ ("USD", "USD"),
+ ("EUR", "EUR"),
+ ("AUD", "AUD"),
+ ("GBP", "GBP"),
+ ("JPY", "JPY")
+ ],
+ validators=[DataRequired(message="Please select a currency")]
+ )
+ image = FileField("Upload Image",
+ validators=[FileSize(1024 * 1024), FileAllowed(["jpg", "png", "jpeg"])])
+ duration = StringField("Duration (e.g. 4 days)", validators=[DataRequired(message="Please enter duration")])
+ submit = SubmitField("Add Tour")
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/product/routes.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/product/routes.py
new file mode 100644
index 0000000..32f6a79
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/src/views/product/routes.py
@@ -0,0 +1,79 @@
+from flask import Blueprint, render_template, redirect, url_for
+from uuid import uuid4
+from os import path
+from src.views.product.forms import AddTourForm
+from src.models.product import Tour
+from src.config import Config
+from flask_login import login_required
+from src.utils import admin_required
+
+product_blueprint = Blueprint("products", __name__)
+
+@product_blueprint.route("/trips")
+def trips():
+ tours = Tour.query.all()
+ return render_template("product/trips.html", tours=tours)
+
+@product_blueprint.route("/trips/")
+def tour_detail(tour_id):
+ chosen_tour = Tour.query.get(tour_id)
+ return render_template("product/tour_detail.html", tour=chosen_tour)
+
+@product_blueprint.route("/add_tour", methods=["GET", "POST"])
+@admin_required
+def add_tour():
+ form = AddTourForm()
+ 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_tour = Tour(country=form.country.data,
+ title=form.title.data,
+ description=form.description.data,
+ price=form.price.data,
+ currency=form.currency.data,
+ duration=form.duration.data,
+ image = filename)
+ new_tour.image = filename
+ new_tour.create()
+ return redirect(url_for("products.trips"))
+ else:
+ print(form.errors)
+ return render_template("product/add_tour.html", form=form)
+
+@product_blueprint.route("/edit_tour/", methods=["GET", "POST"])
+@admin_required
+def edit_tour(tour_id):
+ tour = Tour.query.get(tour_id)
+ form = AddTourForm(country=tour.country,
+ title=tour.title,
+ description=tour.description,
+ price=tour.price,
+ currency=tour.currency,
+ duration=tour.duration)
+ if form.validate_on_submit():
+ tour.country = form.country.data
+ tour.title = form.title.data
+ tour.description = form.description.data
+ tour.price = form.price.data
+ tour.currency = form.currency.data
+ tour.duration = form.duration.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))
+ tour.image = filename
+ tour.save()
+ return redirect(url_for("products.trips"))
+ return render_template("product/add_tour.html", form=form)
+
+
+@product_blueprint.route("/delete_tour/")
+@admin_required
+def delete_tour(tour_id):
+ tour = Tour.query.get(tour_id)
+ tour.delete()
+ return redirect(url_for("products.trips"))
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/auth/test_auth.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/auth/test_auth.py
new file mode 100644
index 0000000..167c218
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/auth/test_auth.py
@@ -0,0 +1,6 @@
+from flask_login import current_user
+
+def test_login(client):
+ with client:
+ client.post("/login", data={"username": "Admin", "password": "password12345"})
+ assert current_user.is_authenticated == True
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/conftest.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/conftest.py
new file mode 100644
index 0000000..10130e3
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/conftest.py
@@ -0,0 +1,36 @@
+import os
+import tempfile
+from src.ext import admin
+from src import create_app
+import pytest
+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/Salome_Papashvili/tests/main/test_main.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/main/test_main.py
new file mode 100644
index 0000000..3034217
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/main/test_main.py
@@ -0,0 +1,7 @@
+def test_index(client):
+ response = client.get("/")
+ assert response.status_code == 200
+
+def test_about(client):
+ response = client.get("/about")
+ assert response.status_code == 200
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/product/test_product.py b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/product/test_product.py
new file mode 100644
index 0000000..643f782
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/tests/product/test_product.py
@@ -0,0 +1,29 @@
+def test_add_product_unauthorized(client):
+ response = client.get("/add_tour", follow_redirects=True)
+ assert response.request.path == "/"
+
+def test_add_product(client):
+ with client:
+ client.post("/login", data={"username": "Admin", "password": "password12345"})
+ response = client.get("/add_tour", follow_redirects=True)
+ assert b"Add Tour" in response.data
+
+def test_edit_tour_unauthorized(client):
+ response = client.get("/edit_tour/1", follow_redirects=True)
+ assert response.request.path == "/"
+
+def test_delete_tour_unauthorized(client):
+ response = client.get("/delete_tour/1", follow_redirects=True)
+ assert response.request.path == "/"
+
+def test_edit_tour_authorized(client):
+ with client:
+ client.post("/login", data={"username": "Admin", "password": "password12345"})
+ response = client.get("/edit_tour/1", follow_redirects=True)
+ assert b"Add Tour" in response.data
+
+def test_delete_tour_authorized(client):
+ with client:
+ client.post("/login", data={"username": "Admin", "password": "password12345"})
+ response = client.get("/delete_tour/1", follow_redirects=True)
+ assert response.status_code == 200
\ No newline at end of file
diff --git a/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/uwsgi.conf b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/uwsgi.conf
new file mode 100644
index 0000000..50a5ff7
--- /dev/null
+++ b/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/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..cd0d1a7 100644
--- a/Chapter10_TestDrivenDevelopment/Projects/readme.md
+++ b/Chapter10_TestDrivenDevelopment/Projects/readme.md
@@ -1,6 +1,7 @@
# Unit ტესტინგის დირექტორია
- სახელი გვარი | [პროექტი](/მისამართი)
+- სალომე პაპაშვილი | [GlobeTales](/Chapter10_TestDrivenDevelopment/Projects/Salome_Papashvili/app.py)
### 2025 ზაფხული
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/Dockerfile b/Chapter11_Docker/Projects/Salome_Papashvili/Dockerfile
new file mode 100644
index 0000000..1703cfa
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/Dockerfile
@@ -0,0 +1,12 @@
+FROM python:3.12-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/Salome_Papashvili/app.py b/Chapter11_Docker/Projects/Salome_Papashvili/app.py
new file mode 100644
index 0000000..4c71782
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/app.py
@@ -0,0 +1,5 @@
+from src import create_app
+app = create_app()
+
+if __name__ == "__main__":
+ app.run(debug=True)
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/dev-requirements.txt b/Chapter11_Docker/Projects/Salome_Papashvili/dev-requirements.txt
new file mode 100644
index 0000000..b8a9de4
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/dev-requirements.txt differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/docker-compose.yml b/Chapter11_Docker/Projects/Salome_Papashvili/docker-compose.yml
new file mode 100644
index 0000000..76e136b
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/docker-compose.yml
@@ -0,0 +1,14 @@
+services:
+ nginx:
+ build: ./nginx
+ container_name: nginx
+ ports:
+ - "80:80"
+ restart: always
+
+ flask:
+ build: .
+ container_name: flask_docker
+ expose:
+ - 5000
+ restart: always
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/migrations/README b/Chapter11_Docker/Projects/Salome_Papashvili/migrations/README
new file mode 100644
index 0000000..0e04844
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/migrations/README
@@ -0,0 +1 @@
+Single-database configuration for Flask.
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/migrations/alembic.ini b/Chapter11_Docker/Projects/Salome_Papashvili/migrations/alembic.ini
new file mode 100644
index 0000000..ec9d45c
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/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/Salome_Papashvili/migrations/env.py b/Chapter11_Docker/Projects/Salome_Papashvili/migrations/env.py
new file mode 100644
index 0000000..4c97092
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/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/Salome_Papashvili/migrations/script.py.mako b/Chapter11_Docker/Projects/Salome_Papashvili/migrations/script.py.mako
new file mode 100644
index 0000000..2c01563
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/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/Salome_Papashvili/nginx/Dockerfile b/Chapter11_Docker/Projects/Salome_Papashvili/nginx/Dockerfile
new file mode 100644
index 0000000..baa066f
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/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/Salome_Papashvili/nginx/default.conf b/Chapter11_Docker/Projects/Salome_Papashvili/nginx/default.conf
new file mode 100644
index 0000000..b8876c0
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/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/Salome_Papashvili/prod-requirements.txt b/Chapter11_Docker/Projects/Salome_Papashvili/prod-requirements.txt
new file mode 100644
index 0000000..25f53ec
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/prod-requirements.txt
@@ -0,0 +1,9 @@
+Flask==3.1.0
+Flask-Login==0.6.3
+Flask-Migrate==4.1.0
+Flask-SQLAlchemy==3.1.1
+Flask-WTF==1.2.2
+Flask-Admin==1.6.1
+WTForms==3.1.2
+
+uwsgi
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/pytest.ini b/Chapter11_Docker/Projects/Salome_Papashvili/pytest.ini
new file mode 100644
index 0000000..03f586d
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+pythonpath = .
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/__init__.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/__init__.py
new file mode 100644
index 0000000..ce42cd3
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/__init__.py
@@ -0,0 +1,44 @@
+from flask import Flask
+
+from src.commands import init_db_command, populate_db_command
+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.models import User, Tour
+from src.admin_views.base import SecureModelView
+from src.admin_views import UserView, TourView
+from flask_admin.menu import MenuLink
+
+BLUEPRINTS = [main_blueprint, product_blueprint, auth_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):
+ db.init_app(app)
+ migrate.init_app(app, db)
+ login_manager.init_app(app)
+ login_manager.login_view = 'auth.login'
+ @login_manager.user_loader
+ def load_user(_id):
+ return User.query.get(_id)
+
+ admin.init_app(app)
+ admin.add_view(UserView(User, db.session))
+ admin.add_view(TourView(Tour, db.session))
+ admin.add_link(MenuLink("To site", url="/"))
+
+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)
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/__init__.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/__init__.py
new file mode 100644
index 0000000..50bac91
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/__init__.py
@@ -0,0 +1,2 @@
+from src.admin_views.user import UserView
+from src.admin_views.product import TourView
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/base.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/base.py
new file mode 100644
index 0000000..4c3ff55
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/base.py
@@ -0,0 +1,28 @@
+from flask_admin.contrib.sqla import ModelView
+from flask_admin import AdminIndexView
+from flask_login import current_user
+from flask import redirect, url_for, flash
+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 current_user.is_authenticated:
+ return redirect(url_for("auth.login"))
+ else:
+ flash("You do not have permission to access this page.", "warning")
+ return redirect(url_for("main.index"))
+
+
+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 current_user.is_authenticated:
+ return redirect(url_for("auth.login"))
+ else:
+ flash("You do not have permission to access the admin panel.", "warning")
+ return redirect(url_for("main.index"))
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/product.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/product.py
new file mode 100644
index 0000000..fce554d
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/product.py
@@ -0,0 +1,28 @@
+from src.admin_views.base import SecureModelView
+from flask_admin.form import ImageUploadField
+from src.config import Config
+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 TourView(SecureModelView):
+ create_modal = True
+ edit_modal = True
+ column_editable_list = ("price", "title")
+ column_filters = ("price", "title", "country")
+ column_formatters = {
+ "title": lambda v, c, m, n: m.title if len(m.title) < 24 else m.title[:16] + "...",
+ "description": lambda v, c, m, n: m.description if len(m.description) < 24 else m.description[:16] + "...",
+ "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/Salome_Papashvili/src/admin_views/user.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/user.py
new file mode 100644
index 0000000..f9bb31a
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/admin_views/user.py
@@ -0,0 +1,37 @@
+from src.admin_views.base import SecureModelView
+from wtforms.fields import SelectField
+from os import path
+from uuid import uuid4
+from flask_admin.form import ImageUploadField
+from src.config import Config
+
+def generate_filename(obj, file):
+ name, extension = path.splitext(file.filename)
+ return f"{uuid4()}{extension}"
+
+class UserView(SecureModelView):
+ can_create = True
+ can_edit = False
+ can_delete = False
+ can_view_details = True
+ create_modal = True
+ edit_modal = True
+ # column_exclude_list = ["_password"]
+ column_list = ["username", "role"]
+ column_details_list = ["username", "role", "profile_image"]
+ column_searchable_list = ["username", "role"]
+
+ form_overrides = {
+ "profile_image": ImageUploadField,
+ "role": SelectField
+ }
+
+ form_args = {
+ "profile_image": {
+ "base_path": Config.UPLOAD_PATH,
+ "namegen": generate_filename
+ },
+ "role": {
+ "choices": ["Admin", "Moderator", "User"]
+ }
+ }
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/commands.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/commands.py
new file mode 100644
index 0000000..1e7bcbf
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/commands.py
@@ -0,0 +1,89 @@
+from flask.cli import with_appcontext
+import click
+from src.ext import db
+from src.models import Tour, User
+
+def init_db():
+ db.drop_all()
+ db.create_all()
+
+def populate_db():
+ tours = [
+ {
+ "id": 0,
+ "country": "Italy",
+ "title": "Rome Ancient Wonders Tour",
+ "description": "Discover the Colosseum, Roman Forum, and Vatican City.",
+ "price": 279.99,
+ "currency": "EUR",
+ "image": "https://www.agoda.com/wp-content/uploads/2024/08/Colosseum-Rome-Featured.jpg",
+ "duration": "4 days",
+ },
+ {
+ "id": 1,
+ "country": "Egypt",
+ "title": "Cairo & Pyramids Adventure",
+ "description": "Visit the Great Pyramids of Giza and the Egyptian Museum.",
+ "price": 349.99,
+ "currency": "USD",
+ "image": "https://upload.wikimedia.org/wikipedia/commons/a/af/All_Gizah_Pyramids.jpg",
+ "duration": "5 days",
+ },
+ {
+ "id": 2,
+ "country": "Australia",
+ "title": "Sydney & Blue Mountains",
+ "description": "Explore Sydney Opera House, Harbour Bridge, and Blue Mountains.",
+ "price": 499.99,
+ "currency": "AUD",
+ "image": "https://www.sydneytravelguide.com.au/wp-content/uploads/2024/09/sydney-australia.jpg",
+ "duration": "6 days",
+ },
+ {
+ "id": 3,
+ "country": "Spain",
+ "title": "Barcelona",
+ "description": "Admire Sagrada Familia, Park Güell, and Gothic Quarter.",
+ "price": 259.99,
+ "currency": "EUR",
+ "image": "https://www.introducingbarcelona.com/f/espana/barcelona/barcelona.jpg",
+ "duration": "3 days",
+ },
+ {
+ "id": 4,
+ "country": "Turkey",
+ "title": "Istanbul Heritage Tour",
+ "description": "Visit Hagia Sophia, Blue Mosque, and Grand Bazaar.",
+ "price": 199.99,
+ "currency": "USD",
+ "image": "https://deih43ym53wif.cloudfront.net/small_cappadocia-turkey-shutterstock_1320608780_9fc0781106.jpeg",
+ "duration": "4 days",
+ }
+ ]
+
+ for tour in tours:
+ new_tour = Tour(country=tour["country"],
+ title=tour["title"],
+ description=tour["description"],
+ price=tour["price"],
+ currency=tour["currency"],
+ image=tour["image"],
+ duration=tour["duration"])
+ db.session.add(new_tour)
+ db.session.commit()
+
+ # Admin
+ User(username="Admin", password="password12345", profile_image="admin.png", 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/Salome_Papashvili/src/config.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/config.py
new file mode 100644
index 0000000..186856b
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/config.py
@@ -0,0 +1,8 @@
+from os import path
+
+class Config(object):
+ BASE_DIRECTORY = path.abspath(path.dirname(__file__))
+ SQLALCHEMY_DATABASE_URI = "sqlite:///database.db"
+ SECRET_KEY = "GJFKLDJKljklhhjkhjk@595jijkjd"
+ UPLOAD_PATH = path.join(BASE_DIRECTORY, "static", "upload")
+ FLASK_ADMIN_SWATCH = "journal"
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/ext.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/ext.py
new file mode 100644
index 0000000..546a50f
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/ext.py
@@ -0,0 +1,10 @@
+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(template_mode="bootstrap4", index_view=SecureIndexView(), base_template="admin/admin_base.html")
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/models/__init__.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/models/__init__.py
new file mode 100644
index 0000000..3a2935e
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/models/__init__.py
@@ -0,0 +1,3 @@
+from src.models.product import Tour
+from src.models.person import Person
+from src.models.user import User
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/models/base.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/models/base.py
new file mode 100644
index 0000000..2f9ab61
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/models/base.py
@@ -0,0 +1,16 @@
+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/Salome_Papashvili/src/models/person.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/models/person.py
new file mode 100644
index 0000000..696165c
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/models/person.py
@@ -0,0 +1,13 @@
+from src.ext import db
+from src.models.base import BaseModel
+class Person(BaseModel):
+ __tablename__ = "persons"
+
+ id = db.Column(db.Integer, primary_key=True)
+ username = db.Column(db.String, nullable=False)
+ email = db.Column(db.String, nullable=False, unique=True)
+ password = db.Column(db.String, nullable=False)
+ birthday = db.Column(db.Date, nullable=False)
+ gender = db.Column(db.String, nullable=False)
+ country = db.Column(db.String, nullable=False)
+ profile_image = db.Column(db.String)
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/models/product.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/models/product.py
new file mode 100644
index 0000000..4ca17f8
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/models/product.py
@@ -0,0 +1,12 @@
+from src.ext import db
+from src.models.base import BaseModel
+class Tour(BaseModel):
+ __tablename__ = "tours"
+ id = db.Column(db.Integer, primary_key=True)
+ country = db.Column(db.String)
+ title = db.Column(db.String)
+ description = db.Column(db.String)
+ price = db.Column(db.Float)
+ currency = db.Column(db.String)
+ image = db.Column(db.String)
+ duration = db.Column(db.String)
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/models/user.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/models/user.py
new file mode 100644
index 0000000..4bb1569
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/models/user.py
@@ -0,0 +1,23 @@
+from src.ext import db
+from flask_login import UserMixin
+from werkzeug.security import generate_password_hash, check_password_hash
+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, default='User')
+ def check_password(self, password):
+ return check_password_hash(self.password, password)
+ def is_admin(self):
+ return self.role == "Admin"
+ @property
+ def password(self):
+ print("GETTER")
+ return self._password
+ @password.setter
+ def password(self, password):
+ print("SETTER")
+ self._password = generate_password_hash(password)
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/styles/style.css b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/styles/style.css
new file mode 100644
index 0000000..e23ecd0
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/styles/style.css
@@ -0,0 +1,80 @@
+*
+{
+ list-style-type: none;
+}
+body
+{
+ background: linear-gradient(to right, #e0f7fa, #fff);
+}
+.hero
+{
+ height: 90vh;
+ background: linear-gradient(rgba(0,0,0,0.3), #0000005e),
+ url('https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8dHJhdmVsfGVufDB8fDB8fHww') center/cover no-repeat;
+ color: white;
+}
+.flex
+{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 30px;
+}
+.custom-hero
+{
+ height: 20vh;
+}
+h1
+{
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.6);
+}
+.nav-link
+{
+ color: #fff;
+}
+.active-link
+{
+ color: #268a97;
+}
+.card img
+{
+ height: 200px;
+ object-fit: cover;
+}
+.btn-custom
+{
+ background: #268a97;
+ border: #02C211;
+ color: #fff;
+}
+.btn-custom:hover
+{
+ background: #16626C;;
+ color: #fff;
+}
+footer
+{
+ background-color: #16626C;
+ color: white;
+}
+::-webkit-scrollbar
+{
+ display: block;
+ width: 12px;
+}
+::-webkit-scrollbar-thumb
+{
+ background-color: #268a97;
+}
+::-webkit-scrollbar-track
+{
+ background-color: #fff;
+}
+.dot
+{
+ border-radius: 50%;
+}
+.image
+{
+ width: 50%;
+}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png
new file mode 100644
index 0000000..d14294c
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/2b3f5c09-b24d-474b-8577-c3a9754a989c.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/6af691be-6de1-4660-b075-497cdc7c6926.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png
new file mode 100644
index 0000000..956fb47
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/737fd0f0-aa4f-456f-afb5-b64571cb86b1.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/88d70f53-a081-4b42-8c29-fac317211607.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg
new file mode 100644
index 0000000..3cd7897
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/89749c93-c5eb-47a2-8718-c0183657e3a3.jpg differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png
new file mode 100644
index 0000000..956fb47
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/bd49a79a-4e04-4aac-bfc7-a15545f69b9a.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/be2ca709-1f08-49bd-9839-ee7031292dfc.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/be2ca709-1f08-49bd-9839-ee7031292dfc.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/be2ca709-1f08-49bd-9839-ee7031292dfc.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg
new file mode 100644
index 0000000..3cd7897
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/cbbef381-9deb-4e21-bc53-788d71674b78.jpg differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/ce84803b-d1a0-460f-b3cc-87332eff91e3.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/ce84803b-d1a0-460f-b3cc-87332eff91e3.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/ce84803b-d1a0-460f-b3cc-87332eff91e3.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/d610a890-14f6-470b-91f6-b8e3add2182c.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/d610a890-14f6-470b-91f6-b8e3add2182c.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/d610a890-14f6-470b-91f6-b8e3add2182c.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png
new file mode 100644
index 0000000..d14294c
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/decf5ae8-5df6-4413-a7f1-233c25a46528.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png
new file mode 100644
index 0000000..1edd5a7
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/ef7622e8-c278-4855-ba45-1e2bb2696900.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/fa898a0e-74a4-4661-ab18-796778100cc0.png b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/fa898a0e-74a4-4661-ab18-796778100cc0.png
new file mode 100644
index 0000000..15f336e
Binary files /dev/null and b/Chapter11_Docker/Projects/Salome_Papashvili/src/static/upload/fa898a0e-74a4-4661-ab18-796778100cc0.png differ
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/admin/admin_base.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/admin/admin_base.html
new file mode 100644
index 0000000..4f36ed6
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/admin/admin_base.html
@@ -0,0 +1,45 @@
+{% extends "admin/base.html" %}
+
+{% block page_body %}
+
+
+
+
+
+
+
+
+
+ {% block brand %}
+
{{ admin_view.admin.name }}
+ {% endblock %}
+ {% block main_menu %}
+
+ {% endblock %}
+
+ {% block menu_links %}
+
+ {{ layout.menu_links() }}
+
+ {% endblock %}
+ {% block access_control %}
+ {% endblock %}
+
+
+
+
+
+
+ {% 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/Salome_Papashvili/src/templates/auth/login.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/auth/login.html
new file mode 100644
index 0000000..17cb90d
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/auth/login.html
@@ -0,0 +1,27 @@
+{% extends "partials/base.html" %}
+{% block title %}Sign Up{% endblock %}
+{% block content %}
+
+
+
+
+
Log In
+ {% for field, errors in form.errors.items() %}
+ {% for error in errors %}
+
{{ error }}
+ {% endfor %}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/auth/register.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/auth/register.html
new file mode 100644
index 0000000..8521fb9
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/auth/register.html
@@ -0,0 +1,51 @@
+{% extends "partials/base.html" %}
+{% block title %}Sign Up{% endblock %}
+{% block content %}
+
+
+
+
+
Sign Up
+ {% for errors in form.errors.values() %}
+ {% for error in errors %}
+
{{ error }}
+ {% endfor %}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/auth/view_profile.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/auth/view_profile.html
new file mode 100644
index 0000000..815aac2
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/auth/view_profile.html
@@ -0,0 +1,17 @@
+{% extends "partials/base.html" %}
+{% block title %}{{ current_user.username }}'s Profile{% endblock %}
+
+{% block content %}
+
+
+
+
+
{{ current_user.username }}
+
Password: {{ current_user.password }}
+
+
+
+
+
+{% endblock %}
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/macros.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/macros.html
new file mode 100644
index 0000000..369bb50
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/macros.html
@@ -0,0 +1,5 @@
+{% macro is_active(endpoint_name) %}
+{% if endpoint_name == request.endpoint %}
+active-link
+{% endif %}
+{% endmacro %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/main/about.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/main/about.html
new file mode 100644
index 0000000..b47cf6d
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/main/about.html
@@ -0,0 +1,81 @@
+{% extends "partials/base.html" %}
+{% block title %}About Us{%endblock %}
+{% block content %}
+
+
+
+
✨ About GlobeTales
+
+ At GlobeTales , we believe that every journey is a story waiting to be told. Whether you're wandering through the lantern-lit alleys of Kyoto, sipping espresso in Rome, or gazing at the northern lights in Iceland – your travel moments matter.
+
+
+ Founded by a team of wanderlusters, photographers, and storytellers, GlobeTales was born from a simple idea: to connect travelers with authentic experiences and hidden gems across the globe. Join our growing community, and let’s make your next trip unforgettable.
+
+
+
+
+
+
+
+
+
Frequently Asked Questions
+
+
+
+
+
+ We focus on curated, authentic travel stories and hidden destinations, not just mainstream tourist spots. Each listing is handpicked for its uniqueness and cultural value.
+
+
+
+
+
+
+
+
+ Absolutely! We welcome contributions from travelers around the world. Simply sign up, and you can start submitting your travel tales, tips, and photos.
+
+
+
+
+
+
+
+
+ Yes! Browsing destinations, reading stories, and accessing our travel guides is completely free. Premium features like trip planning tools are optional.
+
+
+
+
+
+
+
+
+ Submit at least 3 unique travel stories with high-quality images. Our editors review content monthly and select standout travelers to feature on the homepage!
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/main/index.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/main/index.html
new file mode 100644
index 0000000..75f6ff9
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/main/index.html
@@ -0,0 +1,129 @@
+{% extends "partials/base.html" %}
+{% block title %}Home{% endblock %}
+{% block content %}
+
+
+
Travel the World in Style 🌍
+
Discover places, stories, and unforgettable adventures with GlobeTales.
+
+
+
Explore Destinations
+
+
+
+ 🤝 Our Trusted Partners
+
+
FlyGo Airlines
+
GlobeHotels
+
ExploreMore Tours
+
SafeTrip Insurance
+
PaySecure
+
+
+
+
🌎 The best tours
+
+
+
+
+
+
🇫🇷 Paris
+
The city of love, fashion, and timeless beauty.
+
+
+
+
+
+
+
+
🇯🇵 Tokyo
+
A fusion of ancient traditions and modern innovation.
+
+
+
+
+
+
+
+
🇺🇸 New York
+
The city that never sleeps. Lights, life, and skyscrapers.
+
+
+
+
+
+
+
+
+
❤️ What Our Travelers Say
+
+
+
+
+
+
"Best trip ever! The guides were amazing and the itinerary was perfect."
+
- Sarah M.
+
+
+
+
+
+
+
"GlobeTales made our vacation unforgettable. Highly recommended!"
+
- Alex P.
+
+
+
+
+
+
+
"Smooth booking, great communication, and fantastic experiences."
+
- Emily R.
+
+
+
+
+
+ 1
+ 2
+ 3
+
+
+
+
+
+
+
📧 Stay Updated!
+
Subscribe to get the latest deals and travel inspiration straight to your inbox.
+
+
+
+
+
+
+
Ready for Your Next Adventure?
+
Book your dream tour today with GlobeTales.
+
Get Started
+
+
+
+
+
+ {% for user in users %}
+
{{user}}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/partials/base.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/partials/base.html
new file mode 100644
index 0000000..b304467
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/partials/base.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ GlobeTales | {% block title %}{%endblock %}
+
+
+
+
+{% include "partials/nav.html" %}
+{% for message in get_flashed_messages(with_categories=True) if message %}
+
+{% endfor %}
+{% block content%}
+{% endblock %}
+{% include "partials/footer.html" %}
+
+
+
+
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/partials/footer.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/partials/footer.html
new file mode 100644
index 0000000..4527dab
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/partials/footer.html
@@ -0,0 +1,30 @@
+
+
+
+
+
GlobeTales
+
GlobeTales is your gateway to discovering the most beautiful places around the globe. Start your
+ journey today!
+
+
+
+
+
+
+ © 2025 GlobeTales. All rights reserved.
+
+
+
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/partials/nav.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/partials/nav.html
new file mode 100644
index 0000000..cc43bbb
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/partials/nav.html
@@ -0,0 +1,26 @@
+{% from "macros.html" import is_active %}
+
+
+
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/product/add_tour.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/product/add_tour.html
new file mode 100644
index 0000000..30877d5
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/product/add_tour.html
@@ -0,0 +1,58 @@
+{% extends "partials/base.html" %}
+{% block title %}Add Tour{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/product/tour_detail.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/product/tour_detail.html
new file mode 100644
index 0000000..6307436
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/product/tour_detail.html
@@ -0,0 +1,31 @@
+{% extends "partials/base.html" %}
+{% block title %}{{tour.title}}{%endblock %}
+{% block content %}
+
+
+
+
+
{{tour.title}}
+
Country: {{tour.country}}
+
+
+ {{tour.price}}
+ {{old_price}}
+
+
+
+ {{tour.description}}
+
+
+
+ Duration: {{tour.duration}}
+
+
+
+ Book Now
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/product/trips.html b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/product/trips.html
new file mode 100644
index 0000000..3b6fb8a
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/templates/product/trips.html
@@ -0,0 +1,49 @@
+{% extends "partials/base.html" %}
+{% block title %}Trips{%endblock %}
+{% block content %}
+
+
+
+
🌎 Featured Trips
+ {% if current_user.is_authenticated %}
+
Create
+ {% endif %}
+
+
+ {% if tours %}
+ {% for tour in tours %}
+
+ {% endfor %}
+ {% else %}
+
No trips available.
+ {% endif %}
+
+
+
+
+
+
✈️ Plan Your Next Trip
+
Whether you're seeking a relaxing beach vacation or an adventurous mountain hike, GlobeTales
+ has the perfect destination for you. Start planning your trip today!
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/utils.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/utils.py
new file mode 100644
index 0000000..2398c66
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/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 not current_user.is_authenticated:
+ return redirect("/")
+ if current_user.is_authenticated and not current_user.is_admin():
+ return redirect("/")
+ return func(*args, **kwargs)
+ return admin_view
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/views/__init__.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/__init__.py
new file mode 100644
index 0000000..0bac62b
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/__init__.py
@@ -0,0 +1,3 @@
+from src.views.auth.routes import auth_blueprint
+from src.views.product.routes import product_blueprint
+from src.views.main.routes import main_blueprint
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/views/auth/forms.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/auth/forms.py
new file mode 100644
index 0000000..91facd4
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/auth/forms.py
@@ -0,0 +1,78 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, EmailField, SubmitField, DateField, RadioField, SelectField, \
+ IntegerField, FloatField
+from wtforms.validators import DataRequired, length, equal_to, ValidationError
+from string import ascii_uppercase, ascii_lowercase, ascii_letters, digits, punctuation
+from flask_wtf.file import FileField, FileSize, FileAllowed, FileRequired
+
+
+class LoginForm(FlaskForm):
+ username = StringField(
+ "Enter username",
+ validators=[DataRequired(message="Username is required")])
+ password = PasswordField(
+ "Enter password",
+ validators=[DataRequired(message="Password is required")])
+ login = SubmitField("Login")
+
+class RegisterForm(FlaskForm):
+ username = StringField("Enter username", validators=[DataRequired(message='Please enter your username')])
+ email = EmailField("Enter email", validators=[DataRequired(message='Please enter your email')])
+ password = PasswordField("Enter password",
+ validators=[DataRequired(message='Please enter your password'), length(min=8, max=64)])
+ repeat_password = PasswordField("Repeat password", validators=[DataRequired(message='Please confirm your password'),
+ equal_to("password",
+ message="Passwords must match.")])
+ birthday = DateField("Enter birthday", validators=[DataRequired(message='Please enter your birthday')])
+ gender = RadioField("Choose gender", choices=[(0, "Male"), (1, "Female")],
+ validators=[DataRequired(message='Please choose your gender')])
+ country = SelectField(
+ "Choose country",
+ choices=[
+ ("us", "United States"),
+ ("ca", "Canada"),
+ ("gb", "United Kingdom"),
+ ("au", "Australia"),
+ ("de", "Germany"),
+ ("fr", "France"),
+ ("it", "Italy"),
+ ("es", "Spain"),
+ ("jp", "Japan"),
+ ("cn", "China"),
+ ("in", "India"),
+ ("br", "Brazil"),
+ ("mx", "Mexico"),
+ ("ru", "Russia"),
+ ("za", "South Africa"),
+ ("ng", "Nigeria"),
+ ("eg", "Egypt")
+ ],
+ validators=[DataRequired()])
+ profile_image = FileField("Upload Profile Image",
+ validators=[FileSize(1024 * 1024), FileAllowed(["jpg", "png", "jpeg"])])
+ submit = SubmitField("Sign Up")
+
+ 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("Password must contain at least one uppercase letter")
+ if not contains_lowercase:
+ raise ValidationError("Password must contain at least one lowercase letter")
+ if not contains_digits:
+ raise ValidationError("Password must contain at least one digit (0-9)")
+ if not contains_symbols:
+ raise ValidationError("Password must contain at least one special character (e.g., !, @, #, etc.)")
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/views/auth/routes.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/auth/routes.py
new file mode 100644
index 0000000..fd2606a
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/auth/routes.py
@@ -0,0 +1,51 @@
+from uuid import uuid4
+from os import path
+from flask import Blueprint, render_template, redirect, request, flash, get_flashed_messages
+from src.views.auth.forms import RegisterForm, LoginForm
+from src.config import Config
+from src.models import User
+from flask_login import login_user, logout_user, current_user, login_required
+
+auth_blueprint = Blueprint("auth", __name__)
+@auth_blueprint.route("/register", methods=["GET", "POST"])
+def register():
+ 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()
+ return redirect("/login")
+ else:
+ print(form.errors)
+ return render_template("auth/register.html", form=form)
+
+
+@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("Success!", "success")
+ return redirect("/")
+ else:
+ flash("Username or password is incorrect", "danger")
+ return render_template("auth/login.html", form=form)
+
+
+@auth_blueprint.route("/logout")
+def logout():
+ logout_user()
+ return redirect("/login")
+
+@auth_blueprint.route("/profile")
+@login_required
+def view_profile():
+ return render_template("auth/view_profile.html")
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/views/main/routes.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/main/routes.py
new file mode 100644
index 0000000..a6860a0
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/main/routes.py
@@ -0,0 +1,11 @@
+from flask import Blueprint, render_template
+from flask_login import current_user
+main_blueprint = Blueprint("main", __name__)
+
+@main_blueprint.route("/")
+def index():
+ return render_template("main/index.html")
+
+@main_blueprint.route("/about")
+def about():
+ return render_template("main/about.html")
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/views/product/forms.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/product/forms.py
new file mode 100644
index 0000000..24f3a75
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/product/forms.py
@@ -0,0 +1,107 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, SubmitField, SelectField, FloatField
+from wtforms.validators import DataRequired, length
+from flask_wtf.file import FileField, FileSize, FileAllowed, FileRequired
+
+class AddTourForm(FlaskForm):
+ title = StringField("Tour Title", validators=[DataRequired(message="Please enter a title"), length(max=100)])
+ country = SelectField(
+ "Country",
+ choices=[
+ ('', 'Select a country'),
+ ('AF', 'Afghanistan'),
+ ('AL', 'Albania'),
+ ('DZ', 'Algeria'),
+ ('AR', 'Argentina'),
+ ('AU', 'Australia'),
+ ('AT', 'Austria'),
+ ('BD', 'Bangladesh'),
+ ('BE', 'Belgium'),
+ ('BR', 'Brazil'),
+ ('BG', 'Bulgaria'),
+ ('CA', 'Canada'),
+ ('CL', 'Chile'),
+ ('CN', 'China'),
+ ('CO', 'Colombia'),
+ ('HR', 'Croatia'),
+ ('CZ', 'Czech Republic'),
+ ('DK', 'Denmark'),
+ ('EG', 'Egypt'),
+ ('EE', 'Estonia'),
+ ('FI', 'Finland'),
+ ('FR', 'France'),
+ ('DE', 'Germany'),
+ ('GR', 'Greece'),
+ ('HU', 'Hungary'),
+ ('IS', 'Iceland'),
+ ('IN', 'India'),
+ ('ID', 'Indonesia'),
+ ('IR', 'Iran'),
+ ('IQ', 'Iraq'),
+ ('IE', 'Ireland'),
+ ('IL', 'Israel'),
+ ('IT', 'Italy'),
+ ('JP', 'Japan'),
+ ('JO', 'Jordan'),
+ ('KZ', 'Kazakhstan'),
+ ('KE', 'Kenya'),
+ ('KR', 'South Korea'),
+ ('KW', 'Kuwait'),
+ ('LV', 'Latvia'),
+ ('LB', 'Lebanon'),
+ ('LT', 'Lithuania'),
+ ('MY', 'Malaysia'),
+ ('MX', 'Mexico'),
+ ('MA', 'Morocco'),
+ ('NL', 'Netherlands'),
+ ('NZ', 'New Zealand'),
+ ('NG', 'Nigeria'),
+ ('NO', 'Norway'),
+ ('PK', 'Pakistan'),
+ ('PE', 'Peru'),
+ ('PH', 'Philippines'),
+ ('PL', 'Poland'),
+ ('PT', 'Portugal'),
+ ('QA', 'Qatar'),
+ ('RO', 'Romania'),
+ ('RU', 'Russia'),
+ ('SA', 'Saudi Arabia'),
+ ('RS', 'Serbia'),
+ ('SG', 'Singapore'),
+ ('SK', 'Slovakia'),
+ ('SI', 'Slovenia'),
+ ('ZA', 'South Africa'),
+ ('ES', 'Spain'),
+ ('SE', 'Sweden'),
+ ('CH', 'Switzerland'),
+ ('SY', 'Syria'),
+ ('TH', 'Thailand'),
+ ('TR', 'Turkey'),
+ ('UA', 'Ukraine'),
+ ('AE', 'United Arab Emirates'),
+ ('GB', 'United Kingdom'),
+ ('US', 'United States'),
+ ('VN', 'Vietnam'),
+ ('YE', 'Yemen'),
+ ('ZW', 'Zimbabwe'),
+ ],
+ validators=[DataRequired(message="Please select a country")]
+ )
+ description = StringField("Description", validators=[DataRequired(message="Please enter a description")])
+ price = FloatField("Current Price",
+ validators=[DataRequired(message="Please enter the price")])
+ currency = SelectField(
+ "Currency",
+ choices=[
+ ("USD", "USD"),
+ ("EUR", "EUR"),
+ ("AUD", "AUD"),
+ ("GBP", "GBP"),
+ ("JPY", "JPY")
+ ],
+ validators=[DataRequired(message="Please select a currency")]
+ )
+ image = FileField("Upload Image",
+ validators=[FileSize(1024 * 1024), FileAllowed(["jpg", "png", "jpeg"])])
+ duration = StringField("Duration (e.g. 4 days)", validators=[DataRequired(message="Please enter duration")])
+ submit = SubmitField("Add Tour")
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/src/views/product/routes.py b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/product/routes.py
new file mode 100644
index 0000000..32f6a79
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/src/views/product/routes.py
@@ -0,0 +1,79 @@
+from flask import Blueprint, render_template, redirect, url_for
+from uuid import uuid4
+from os import path
+from src.views.product.forms import AddTourForm
+from src.models.product import Tour
+from src.config import Config
+from flask_login import login_required
+from src.utils import admin_required
+
+product_blueprint = Blueprint("products", __name__)
+
+@product_blueprint.route("/trips")
+def trips():
+ tours = Tour.query.all()
+ return render_template("product/trips.html", tours=tours)
+
+@product_blueprint.route("/trips/")
+def tour_detail(tour_id):
+ chosen_tour = Tour.query.get(tour_id)
+ return render_template("product/tour_detail.html", tour=chosen_tour)
+
+@product_blueprint.route("/add_tour", methods=["GET", "POST"])
+@admin_required
+def add_tour():
+ form = AddTourForm()
+ 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_tour = Tour(country=form.country.data,
+ title=form.title.data,
+ description=form.description.data,
+ price=form.price.data,
+ currency=form.currency.data,
+ duration=form.duration.data,
+ image = filename)
+ new_tour.image = filename
+ new_tour.create()
+ return redirect(url_for("products.trips"))
+ else:
+ print(form.errors)
+ return render_template("product/add_tour.html", form=form)
+
+@product_blueprint.route("/edit_tour/", methods=["GET", "POST"])
+@admin_required
+def edit_tour(tour_id):
+ tour = Tour.query.get(tour_id)
+ form = AddTourForm(country=tour.country,
+ title=tour.title,
+ description=tour.description,
+ price=tour.price,
+ currency=tour.currency,
+ duration=tour.duration)
+ if form.validate_on_submit():
+ tour.country = form.country.data
+ tour.title = form.title.data
+ tour.description = form.description.data
+ tour.price = form.price.data
+ tour.currency = form.currency.data
+ tour.duration = form.duration.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))
+ tour.image = filename
+ tour.save()
+ return redirect(url_for("products.trips"))
+ return render_template("product/add_tour.html", form=form)
+
+
+@product_blueprint.route("/delete_tour/")
+@admin_required
+def delete_tour(tour_id):
+ tour = Tour.query.get(tour_id)
+ tour.delete()
+ return redirect(url_for("products.trips"))
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/tests/auth/test_auth.py b/Chapter11_Docker/Projects/Salome_Papashvili/tests/auth/test_auth.py
new file mode 100644
index 0000000..167c218
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/tests/auth/test_auth.py
@@ -0,0 +1,6 @@
+from flask_login import current_user
+
+def test_login(client):
+ with client:
+ client.post("/login", data={"username": "Admin", "password": "password12345"})
+ assert current_user.is_authenticated == True
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/tests/conftest.py b/Chapter11_Docker/Projects/Salome_Papashvili/tests/conftest.py
new file mode 100644
index 0000000..10130e3
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/tests/conftest.py
@@ -0,0 +1,36 @@
+import os
+import tempfile
+from src.ext import admin
+from src import create_app
+import pytest
+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/Salome_Papashvili/tests/main/test_main.py b/Chapter11_Docker/Projects/Salome_Papashvili/tests/main/test_main.py
new file mode 100644
index 0000000..3034217
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/tests/main/test_main.py
@@ -0,0 +1,7 @@
+def test_index(client):
+ response = client.get("/")
+ assert response.status_code == 200
+
+def test_about(client):
+ response = client.get("/about")
+ assert response.status_code == 200
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/tests/product/test_product.py b/Chapter11_Docker/Projects/Salome_Papashvili/tests/product/test_product.py
new file mode 100644
index 0000000..643f782
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/tests/product/test_product.py
@@ -0,0 +1,29 @@
+def test_add_product_unauthorized(client):
+ response = client.get("/add_tour", follow_redirects=True)
+ assert response.request.path == "/"
+
+def test_add_product(client):
+ with client:
+ client.post("/login", data={"username": "Admin", "password": "password12345"})
+ response = client.get("/add_tour", follow_redirects=True)
+ assert b"Add Tour" in response.data
+
+def test_edit_tour_unauthorized(client):
+ response = client.get("/edit_tour/1", follow_redirects=True)
+ assert response.request.path == "/"
+
+def test_delete_tour_unauthorized(client):
+ response = client.get("/delete_tour/1", follow_redirects=True)
+ assert response.request.path == "/"
+
+def test_edit_tour_authorized(client):
+ with client:
+ client.post("/login", data={"username": "Admin", "password": "password12345"})
+ response = client.get("/edit_tour/1", follow_redirects=True)
+ assert b"Add Tour" in response.data
+
+def test_delete_tour_authorized(client):
+ with client:
+ client.post("/login", data={"username": "Admin", "password": "password12345"})
+ response = client.get("/delete_tour/1", follow_redirects=True)
+ assert response.status_code == 200
\ No newline at end of file
diff --git a/Chapter11_Docker/Projects/Salome_Papashvili/uwsgi.ini b/Chapter11_Docker/Projects/Salome_Papashvili/uwsgi.ini
new file mode 100644
index 0000000..50a5ff7
--- /dev/null
+++ b/Chapter11_Docker/Projects/Salome_Papashvili/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..81404a5 100644
--- a/Chapter11_Docker/Projects/readme.md
+++ b/Chapter11_Docker/Projects/readme.md
@@ -1,5 +1,6 @@
# დოკერის დირექტორია
- სახელი გვარი | [პროექტი](/მისამართი)
+- სალომე პაპაშვილი | [GlobeTales](https://hub.docker.com/r/kiwiscreams/globetales)
### 2025 ზაფხული
\ No newline at end of file