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 %} +
+ {{ form.hidden_tag() }} +
+ {{ form.username(class="form-control", placeholder="Enter username") }} +
+
+ {{ form.password(class="form-control", placeholder="Enter password") }} +
+ {{ form.login(class="btn btn-custom w-100 py-2") }} +
+
+
+
+{% 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 %} +
+ {{ form.hidden_tag() }} +
+
+ {{ form.profile_image(placeholder=form.profile_image.label.text, class="form-control", accept=".jpg, .png, .jpeg") }} +
+
+ {{ form.username(placeholder=form.username.label.text, class="form-control") }} +
+
+ {{ form.email(placeholder=form.email.label.text, class="form-control") }} +
+
+ +
+
+ {{ form.password(placeholder=form.password.label.text, class="form-control") }} +
+
+ {{ form.repeat_password(placeholder=form.repeat_password.label.text, class="form-control") }} +
+
+ +
+ {{ form.country(placeholder=form.country.label.text, class="form-select") }} +
+
+ {{ form.birthday(placeholder=form.birthday.label.text, class="form-control") }} +
+
+ {{ form.gender(placeholder=form.gender.label.text) }} +
+ {{ form.submit(placeholder=form.submit.label.text, class="btn btn-custom w-100 py-2") }} +
+
+
+
+{% 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 }}

+
+
+ + {{ current_user.username }} +
+{% 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

+
+
+
+
+

✨ 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. +

+
+ Travel the world +
+
+
+
+
+

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 +
+
🇫🇷 Paris
+

The city of love, fashion, and timeless beauty.

+
+
+
+
+
+ Tokyo +
+
🇯🇵 Tokyo
+

A fusion of ancient traditions and modern innovation.

+
+
+
+
+
+ New York +
+
🇺🇸 New York
+

The city that never sleeps. Lights, life, and skyscrapers.

+
+
+
+
+
+ +
+
+

❤️ What Our Travelers Say

+ +
+
+
+
+

📧 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 %} +
+
{{message[1]}}
+
+{% 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 @@ + \ 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 %} +
+
+
+

Add New Tour

+
+ {{ form.hidden_tag() }} + {% if form.errors %} +
+
    + {% for field_errors in form.errors.values() %} + {% for error in field_errors %} +
  • {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {{ form.title(class="form-control", placeholder="Enter tour title") }} +
+ +
+ {{ form.country(class="form-select", placeholder="Enter country") }} +
+ +
+ {{ form.description(class="form-control", placeholder="Enter description") }} +
+ +
+ {{ form.price(class="form-control", placeholder="Enter current price") }} +
+ +
+ {{ form.currency(class="form-select") }} +
+ +
+ {{ form.image(class="form-control", accept=".jpg, .png, .jpeg") }} +
+ +
+ {{ form.duration(class="form-control", placeholder="e.g. 4 days") }} +
+ +
+ {{ form.submit(class="btn btn-custom") }} +
+
+
+
+
+{% 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}}
  • +
+ +
+ +
+
+
+ {{tour.title}} +
+{% 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 %} +
+
+

Explore world🌎

+
+
+ +
+

🌎 Featured Trips

+ {% if current_user.is_authenticated %} + Create + {% endif %} + +
+ {% if tours %} + {% for tour in tours %} +
+
+ {{ tour.title }} +
+

{{ tour.title }}

+
{{ tour.price }}$
+ Explore {{ tour.country }} + {% if current_user.is_authenticated and current_user.is_admin() %} + Edit + Delete + {% endif %} +
+
+
+ {% 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!

+
+ Get Started +
+
+{% 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 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 %} +
+ {{ form.hidden_tag() }} +
+ {{ form.username(class="form-control", placeholder="Enter username") }} +
+
+ {{ form.password(class="form-control", placeholder="Enter password") }} +
+ {{ form.login(class="btn btn-custom w-100 py-2") }} +
+
+
+
+{% 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 %} +
+ {{ form.hidden_tag() }} +
+
+ {{ form.profile_image(placeholder=form.profile_image.label.text, class="form-control", accept=".jpg, .png, .jpeg") }} +
+
+ {{ form.username(placeholder=form.username.label.text, class="form-control") }} +
+
+ {{ form.email(placeholder=form.email.label.text, class="form-control") }} +
+
+ +
+
+ {{ form.password(placeholder=form.password.label.text, class="form-control") }} +
+
+ {{ form.repeat_password(placeholder=form.repeat_password.label.text, class="form-control") }} +
+
+ +
+ {{ form.country(placeholder=form.country.label.text, class="form-select") }} +
+
+ {{ form.birthday(placeholder=form.birthday.label.text, class="form-control") }} +
+
+ {{ form.gender(placeholder=form.gender.label.text) }} +
+ {{ form.submit(placeholder=form.submit.label.text, class="btn btn-custom w-100 py-2") }} +
+
+
+
+{% 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 }}

+
+
+ + {{ current_user.username }} +
+{% 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

+
+
+
+
+

✨ 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. +

+
+ Travel the world +
+
+
+
+
+

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 +
+
🇫🇷 Paris
+

The city of love, fashion, and timeless beauty.

+
+
+
+
+
+ Tokyo +
+
🇯🇵 Tokyo
+

A fusion of ancient traditions and modern innovation.

+
+
+
+
+
+ New York +
+
🇺🇸 New York
+

The city that never sleeps. Lights, life, and skyscrapers.

+
+
+
+
+
+ +
+
+

❤️ What Our Travelers Say

+ +
+
+
+
+

📧 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 %} +
+
{{message[1]}}
+
+{% 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!

+
+
+
Quick Links
+ +
+
+
Follow Us
+ + + + +
+ +
+
+ © 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 %} +
+
+
+

Add New Tour

+
+ {{ form.hidden_tag() }} + {% if form.errors %} +
+
    + {% for field_errors in form.errors.values() %} + {% for error in field_errors %} +
  • {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {{ form.title(class="form-control", placeholder="Enter tour title") }} +
+ +
+ {{ form.country(class="form-select", placeholder="Enter country") }} +
+ +
+ {{ form.description(class="form-control", placeholder="Enter description") }} +
+ +
+ {{ form.price(class="form-control", placeholder="Enter current price") }} +
+ +
+ {{ form.currency(class="form-select") }} +
+ +
+ {{ form.image(class="form-control", accept=".jpg, .png, .jpeg") }} +
+ +
+ {{ form.duration(class="form-control", placeholder="e.g. 4 days") }} +
+ +
+ {{ form.submit(class="btn btn-custom") }} +
+
+
+
+
+{% 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}}
  • +
+ +
+ +
+
+
+ {{tour.title}} +
+{% 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 %} +
+
+

Explore world🌎

+
+
+ +
+

🌎 Featured Trips

+ {% if current_user.is_authenticated %} + Create + {% endif %} + +
+ {% if tours %} + {% for tour in tours %} +
+
+ {{ tour.title }} +
+

{{ tour.title }}

+
{{ tour.price }}$
+ Explore {{ tour.country }} + {% if current_user.is_authenticated and current_user.is_admin() %} + Edit + Delete + {% endif %} +
+
+
+ {% 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 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 %} +
+ {{ form.hidden_tag() }} +
+ {{ form.username(class="form-control", placeholder="Enter username") }} +
+
+ {{ form.password(class="form-control", placeholder="Enter password") }} +
+ {{ form.login(class="btn btn-custom w-100 py-2") }} +
+
+
+
+{% 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 %} +
+ {{ form.hidden_tag() }} +
+
+ {{ form.profile_image(placeholder=form.profile_image.label.text, class="form-control", accept=".jpg, .png, .jpeg") }} +
+
+ {{ form.username(placeholder=form.username.label.text, class="form-control") }} +
+
+ {{ form.email(placeholder=form.email.label.text, class="form-control") }} +
+
+ +
+
+ {{ form.password(placeholder=form.password.label.text, class="form-control") }} +
+
+ {{ form.repeat_password(placeholder=form.repeat_password.label.text, class="form-control") }} +
+
+ +
+ {{ form.country(placeholder=form.country.label.text, class="form-select") }} +
+
+ {{ form.birthday(placeholder=form.birthday.label.text, class="form-control") }} +
+
+ {{ form.gender(placeholder=form.gender.label.text) }} +
+ {{ form.submit(placeholder=form.submit.label.text, class="btn btn-custom w-100 py-2") }} +
+
+
+
+{% 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 }}

+
+
+ + {{ current_user.username }} +
+{% 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

+
+
+
+
+

✨ 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. +

+
+ Travel the world +
+
+
+
+
+

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 +
+
🇫🇷 Paris
+

The city of love, fashion, and timeless beauty.

+
+
+
+
+
+ Tokyo +
+
🇯🇵 Tokyo
+

A fusion of ancient traditions and modern innovation.

+
+
+
+
+
+ New York +
+
🇺🇸 New York
+

The city that never sleeps. Lights, life, and skyscrapers.

+
+
+
+
+
+ +
+
+

❤️ What Our Travelers Say

+ +
+
+
+
+

📧 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 %} +
+
{{message[1]}}
+
+{% 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!

+
+
+
Quick Links
+ +
+
+
Follow Us
+ + + + +
+ +
+
+ © 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 %} +
+
+
+

Add New Tour

+
+ {{ form.hidden_tag() }} + {% if form.errors %} +
+
    + {% for field_errors in form.errors.values() %} + {% for error in field_errors %} +
  • {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {{ form.title(class="form-control", placeholder="Enter tour title") }} +
+ +
+ {{ form.country(class="form-select", placeholder="Enter country") }} +
+ +
+ {{ form.description(class="form-control", placeholder="Enter description") }} +
+ +
+ {{ form.price(class="form-control", placeholder="Enter current price") }} +
+ +
+ {{ form.currency(class="form-select") }} +
+ +
+ {{ form.image(class="form-control", accept=".jpg, .png, .jpeg") }} +
+ +
+ {{ form.duration(class="form-control", placeholder="e.g. 4 days") }} +
+ +
+ {{ form.submit(class="btn btn-custom") }} +
+
+
+
+
+{% 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}}
  • +
+ +
+ +
+
+
+ {{tour.title}} +
+{% 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 %} +
+
+

Explore world🌎

+
+
+ +
+

🌎 Featured Trips

+ {% if current_user.is_authenticated %} + Create + {% endif %} + +
+ {% if tours %} + {% for tour in tours %} +
+
+ {{ tour.title }} +
+

{{ tour.title }}

+
{{ tour.price }}$
+ Explore {{ tour.country }} + {% if current_user.is_authenticated and current_user.is_admin() %} + Edit + Delete +{% endif %} +
+
+
+ {% 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 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 %} +
+ {{ form.hidden_tag() }} +
+ {{ form.username(class="form-control", placeholder="Enter username") }} +
+
+ {{ form.password(class="form-control", placeholder="Enter password") }} +
+ {{ form.login(class="btn btn-custom w-100 py-2") }} +
+
+
+
+{% 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 %} +
+ {{ form.hidden_tag() }} +
+
+ {{ form.profile_image(placeholder=form.profile_image.label.text, class="form-control", accept=".jpg, .png, .jpeg") }} +
+
+ {{ form.username(placeholder=form.username.label.text, class="form-control") }} +
+
+ {{ form.email(placeholder=form.email.label.text, class="form-control") }} +
+
+ +
+
+ {{ form.password(placeholder=form.password.label.text, class="form-control") }} +
+
+ {{ form.repeat_password(placeholder=form.repeat_password.label.text, class="form-control") }} +
+
+ +
+ {{ form.country(placeholder=form.country.label.text, class="form-select") }} +
+
+ {{ form.birthday(placeholder=form.birthday.label.text, class="form-control") }} +
+
+ {{ form.gender(placeholder=form.gender.label.text) }} +
+ {{ form.submit(placeholder=form.submit.label.text, class="btn btn-custom w-100 py-2") }} +
+
+
+
+{% 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 }}

+
+
+ + {{ current_user.username }} +
+{% 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

+
+
+
+
+

✨ 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. +

+
+ Travel the world +
+
+
+
+
+

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 +
+
🇫🇷 Paris
+

The city of love, fashion, and timeless beauty.

+
+
+
+
+
+ Tokyo +
+
🇯🇵 Tokyo
+

A fusion of ancient traditions and modern innovation.

+
+
+
+
+
+ New York +
+
🇺🇸 New York
+

The city that never sleeps. Lights, life, and skyscrapers.

+
+
+
+
+
+ +
+
+

❤️ What Our Travelers Say

+ +
+
+
+
+

📧 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 %} +
+
{{message[1]}}
+
+{% 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!

+
+
+
Quick Links
+ +
+
+
Follow Us
+ + + + +
+ +
+
+ © 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 %} +
+
+
+

Add New Tour

+
+ {{ form.hidden_tag() }} + {% if form.errors %} +
+
    + {% for field_errors in form.errors.values() %} + {% for error in field_errors %} +
  • {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {{ form.title(class="form-control", placeholder="Enter tour title") }} +
+ +
+ {{ form.country(class="form-select", placeholder="Enter country") }} +
+ +
+ {{ form.description(class="form-control", placeholder="Enter description") }} +
+ +
+ {{ form.price(class="form-control", placeholder="Enter current price") }} +
+ +
+ {{ form.currency(class="form-select") }} +
+ +
+ {{ form.image(class="form-control", accept=".jpg, .png, .jpeg") }} +
+ +
+ {{ form.duration(class="form-control", placeholder="e.g. 4 days") }} +
+ +
+ {{ form.submit(class="btn btn-custom") }} +
+
+
+
+
+{% 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}}
  • +
+ +
+ +
+
+
+ {{tour.title}} +
+{% 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 %} +
+
+

Explore world🌎

+
+
+ +
+

🌎 Featured Trips

+ {% if current_user.is_authenticated %} + Create + {% endif %} + +
+ {% if tours %} + {% for tour in tours %} +
+
+ {{ tour.title }} +
+

{{ tour.title }}

+
{{ tour.price }}$
+ Explore {{ tour.country }} + {% if current_user.is_authenticated and current_user.is_admin() %} + Edit + Delete +{% endif %} +
+
+
+ {% 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