diff --git a/labs/lab-6/app/.env b/labs/lab-6/app/.env new file mode 100644 index 0000000..c80b622 --- /dev/null +++ b/labs/lab-6/app/.env @@ -0,0 +1,2 @@ +FLASK_APP=app.py +FLASK_DEBUG=1 \ No newline at end of file diff --git a/labs/lab-6/app/app.py b/labs/lab-6/app/app.py new file mode 100644 index 0000000..106888a --- /dev/null +++ b/labs/lab-6/app/app.py @@ -0,0 +1,39 @@ +from flask import Flask, render_template, send_from_directory +from flask_migrate import Migrate +from sqlalchemy.exc import SQLAlchemyError +from models import db, Category, Image +from auth import bp as auth_bp, init_login_manager +from courses import bp as courses_bp + +app = Flask(__name__) +application = app + +app.config.from_pyfile('config.py') + +db.init_app(app) +migrate = Migrate(app, db) + +init_login_manager(app) + +@app.errorhandler(SQLAlchemyError) +def handle_sqlalchemy_error(err): + error_msg = ('Возникла ошибка при подключении к базе данных. ' + 'Повторите попытку позже.') + return f'{error_msg} (Подробнее: {err})', 500 + +app.register_blueprint(auth_bp) +app.register_blueprint(courses_bp) + +@app.route('/') +def index(): + categories = db.session.execute(db.select(Category)).scalars() + return render_template( + 'index.html', + categories=categories, + ) + +@app.route('/images/') +def image(image_id): + img = db.get_or_404(Image, image_id) + return send_from_directory(app.config['UPLOAD_FOLDER'], + img.storage_filename) diff --git a/labs/lab-6/app/auth.py b/labs/lab-6/app/auth.py new file mode 100644 index 0000000..cfe6764 --- /dev/null +++ b/labs/lab-6/app/auth.py @@ -0,0 +1,38 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask_login import LoginManager, login_user, logout_user, login_required +from models import db, User + +bp = Blueprint('auth', __name__, url_prefix='/auth') + +def init_login_manager(app): + login_manager = LoginManager() + login_manager.login_view = 'auth.login' + login_manager.login_message = 'Для доступа к данной странице необходимо пройти процедуру аутентификации.' + login_manager.login_message_category = 'warning' + login_manager.user_loader(load_user) + login_manager.init_app(app) + +def load_user(user_id): + user = db.session.execute(db.select(User).filter_by(id=user_id)).scalar() + return user + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + login = request.form.get('login') + password = request.form.get('password') + if login and password: + user = db.session.execute(db.select(User).filter_by(login=login)).scalar() + if user and user.check_password(password): + login_user(user) + flash('Вы успешно аутентифицированы.', 'success') + next = request.args.get('next') + return redirect(next or url_for('index')) + flash('Введены неверные логин и/или пароль.', 'danger') + return render_template('auth/login.html') + +@bp.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('index')) diff --git a/labs/lab-6/app/config.py b/labs/lab-6/app/config.py new file mode 100644 index 0000000..ea62ca6 --- /dev/null +++ b/labs/lab-6/app/config.py @@ -0,0 +1,9 @@ +import os + +SECRET_KEY = 'secret-key' + +SQLALCHEMY_DATABASE_URI = 'sqlite:///project.db' +SQLALCHEMY_TRACK_MODIFICATIONS = False +SQLALCHEMY_ECHO = True + +UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'media', 'images') diff --git a/labs/lab-6/app/courses.py b/labs/lab-6/app/courses.py new file mode 100644 index 0000000..ede93f0 --- /dev/null +++ b/labs/lab-6/app/courses.py @@ -0,0 +1,148 @@ +from flask import Blueprint, render_template, request, flash, redirect, url_for +from flask_login import login_required, current_user +from sqlalchemy.exc import IntegrityError +from models import db, Course, Category, User, Review # Review добавлен для работы с отзывами +from tools import CoursesFilter, ImageSaver + +bp = Blueprint('courses', __name__, url_prefix='/courses') + +COURSE_PARAMS = [ + 'author_id', 'name', 'category_id', 'short_desc', 'full_desc' +] + +def params(): + return { p: request.form.get(p) or None for p in COURSE_PARAMS } + +def search_params(): + return { + 'name': request.args.get('name'), + 'category_ids': [x for x in request.args.getlist('category_ids') if x], + } + +@bp.route('/') +def index(): + courses = CoursesFilter(**search_params()).perform() + pagination = db.paginate(courses) + courses = pagination.items + categories = db.session.execute(db.select(Category)).scalars() + return render_template('courses/index.html', + courses=courses, + categories=categories, + pagination=pagination, + search_params=search_params()) + +@bp.route('/new') +@login_required +def new(): + course = Course() + categories = db.session.execute(db.select(Category)).scalars() + users = db.session.execute(db.select(User)).scalars() + return render_template('courses/new.html', + categories=categories, + users=users, + course=course) + +@bp.route('/create', methods=['POST']) +@login_required +def create(): + f = request.files.get('background_img') + img = None + course = Course() + try: + if f and f.filename: + img = ImageSaver(f).save() + + image_id = img.id if img else None + course = Course(**params(), background_image_id=image_id) + db.session.add(course) + db.session.commit() + except IntegrityError as err: + flash(f'Возникла ошибка при записи данных в БД. Проверьте корректность введённых данных. ({err})', 'danger') + db.session.rollback() + categories = db.session.execute(db.select(Category)).scalars() + users = db.session.execute(db.select(User)).scalars() + return render_template('courses/new.html', + categories=categories, + users=users, + course=course) + + flash(f'Курс {course.name} был успешно добавлен!', 'success') + + return redirect(url_for('courses.index')) + +@bp.route('/') +def show(course_id): + course = db.get_or_404(Course, course_id) + # Последние 5 отзывов + reviews = db.session.execute( + db.select(Review) + .filter_by(course_id=course_id) + .order_by(Review.created_at.desc()) + .limit(5) + ).scalars().all() + # Отзыв текущего пользователя + user_review = None + if current_user.is_authenticated: + user_review = db.session.execute( + db.select(Review).filter_by(course_id=course_id, user_id=current_user.id) + ).scalar() + return render_template('courses/show.html', course=course, reviews=reviews, user_review=user_review) + +# Страница всех отзывов с пагинацией +@bp.route('//reviews') +def reviews(course_id): + course = db.get_or_404(Course, course_id) + sort = request.args.get('sort', 'new') + query = db.select(Review).filter_by(course_id=course_id) + # Порядок сортировки + if sort == 'positive': + query = query.order_by(Review.rating.desc(), Review.created_at.desc()) + elif sort == 'negative': + query = query.order_by(Review.rating.asc(), Review.created_at.desc()) + else: + query = query.order_by(Review.created_at.desc()) + pagination = db.paginate(query, per_page=5) + reviews_list = pagination.items + # Отзыв текущего пользователя + user_review = None + if current_user.is_authenticated: + user_review = db.session.execute( + db.select(Review).filter_by(course_id=course_id, user_id=current_user.id) + ).scalar() + return render_template('courses/reviews.html', + course=course, + reviews=reviews_list, + pagination=pagination, + sort=sort, + user_review=user_review) + +# Сохранение отзыва +@bp.route('//reviews/create', methods=['POST']) +@login_required +def create_review(course_id): + course = db.get_or_404(Course, course_id) + existing = db.session.execute( + db.select(Review).filter_by(course_id=course_id, user_id=current_user.id) + ).scalar() + if existing: + flash('Вы уже оставили отзыв к этому курсу.', 'warning') + return redirect(url_for('courses.show', course_id=course_id)) + + rating = request.form.get('rating', type=int) + text = request.form.get('text', '').strip() + + review = Review( + rating=rating, + text=text, + course_id=course_id, + user_id=current_user.id + ) + db.session.add(review) + # Пересчёт рейтинга курса + course.rating_sum += rating + course.rating_num += 1 + db.session.commit() + flash('Отзыв успешно добавлен!', 'success') + + next_page = request.form.get('next') or url_for('courses.show', course_id=course_id) + return redirect(next_page) diff --git a/labs/lab-6/app/instance/project.db b/labs/lab-6/app/instance/project.db new file mode 100644 index 0000000..551dc7c Binary files /dev/null and b/labs/lab-6/app/instance/project.db differ diff --git a/labs/lab-6/app/media/images/0bad1c48-16d0-4206-800a-dbacbfe5a628.jpg b/labs/lab-6/app/media/images/0bad1c48-16d0-4206-800a-dbacbfe5a628.jpg new file mode 100644 index 0000000..84b017a Binary files /dev/null and b/labs/lab-6/app/media/images/0bad1c48-16d0-4206-800a-dbacbfe5a628.jpg differ diff --git a/labs/lab-6/app/media/images/3da05479-69b6-493e-bef2-b998cf05abf5.jpg b/labs/lab-6/app/media/images/3da05479-69b6-493e-bef2-b998cf05abf5.jpg new file mode 100644 index 0000000..ea1446a Binary files /dev/null and b/labs/lab-6/app/media/images/3da05479-69b6-493e-bef2-b998cf05abf5.jpg differ diff --git a/labs/lab-6/app/media/images/6cdbc143-9a90-4438-97bd-4e1856766838.jpg b/labs/lab-6/app/media/images/6cdbc143-9a90-4438-97bd-4e1856766838.jpg new file mode 100644 index 0000000..84b9739 Binary files /dev/null and b/labs/lab-6/app/media/images/6cdbc143-9a90-4438-97bd-4e1856766838.jpg differ diff --git a/labs/lab-6/app/media/images/cf74d25f-2613-4e74-a237-2933fe317b15.png b/labs/lab-6/app/media/images/cf74d25f-2613-4e74-a237-2933fe317b15.png new file mode 100644 index 0000000..43acf48 Binary files /dev/null and b/labs/lab-6/app/media/images/cf74d25f-2613-4e74-a237-2933fe317b15.png differ diff --git a/labs/lab-6/app/migrations/README b/labs/lab-6/app/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/labs/lab-6/app/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/labs/lab-6/app/migrations/alembic.ini b/labs/lab-6/app/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/labs/lab-6/app/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/labs/lab-6/app/migrations/env.py b/labs/lab-6/app/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/labs/lab-6/app/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/labs/lab-6/app/migrations/script.py.mako b/labs/lab-6/app/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/labs/lab-6/app/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/labs/lab-6/app/migrations/versions/9f3eafbe74db_.py b/labs/lab-6/app/migrations/versions/9f3eafbe74db_.py new file mode 100644 index 0000000..f1ee2b3 --- /dev/null +++ b/labs/lab-6/app/migrations/versions/9f3eafbe74db_.py @@ -0,0 +1,89 @@ +"""empty message + +Revision ID: 9f3eafbe74db +Revises: +Create Date: 2026-02-25 16:02:49.708125 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9f3eafbe74db' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('categories', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['parent_id'], ['categories.id'], name=op.f('fk_categories_parent_id_categories')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_categories')) + ) + op.create_table('images', + sa.Column('id', sa.String(length=100), nullable=False), + sa.Column('file_name', sa.String(length=100), nullable=False), + sa.Column('mime_type', sa.String(length=100), nullable=False), + sa.Column('md5_hash', sa.String(length=100), nullable=False), + sa.Column('object_id', sa.Integer(), nullable=True), + sa.Column('object_type', sa.String(length=100), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_images')), + sa.UniqueConstraint('md5_hash', name=op.f('uq_images_md5_hash')) + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('first_name', sa.String(length=100), nullable=False), + sa.Column('last_name', sa.String(length=100), nullable=False), + sa.Column('middle_name', sa.String(length=100), nullable=True), + sa.Column('login', sa.String(length=100), nullable=False), + sa.Column('password_hash', sa.String(length=200), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_users')), + sa.UniqueConstraint('login', name=op.f('uq_users_login')) + ) + op.create_table('courses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('short_desc', sa.Text(), nullable=False), + sa.Column('full_desc', sa.Text(), nullable=False), + sa.Column('rating_sum', sa.Integer(), nullable=False), + sa.Column('rating_num', sa.Integer(), nullable=False), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('author_id', sa.Integer(), nullable=False), + sa.Column('background_image_id', sa.String(length=100), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], name=op.f('fk_courses_author_id_users')), + sa.ForeignKeyConstraint(['background_image_id'], ['images.id'], name=op.f('fk_courses_background_image_id_images')), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], name=op.f('fk_courses_category_id_categories')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_courses')) + ) + # ### end Alembic commands ### + data_upgrades() # Заполнение таблицы categories начальными данными + + +# Начальные данные +def data_upgrades(): + """Заполнение категорий.""" + table = sa.sql.table('categories', sa.sql.column('name', sa.String)) + op.bulk_insert(table, + [ + {'name': 'Программирование'}, + {'name': 'Математика'}, + {'name': 'Языкознание'}, + ] + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('courses') + op.drop_table('users') + op.drop_table('images') + op.drop_table('categories') + # ### end Alembic commands ### diff --git a/labs/lab-6/app/migrations/versions/a1b2c3d4e5f6_add_reviews.py b/labs/lab-6/app/migrations/versions/a1b2c3d4e5f6_add_reviews.py new file mode 100644 index 0000000..d3ecb5a --- /dev/null +++ b/labs/lab-6/app/migrations/versions/a1b2c3d4e5f6_add_reviews.py @@ -0,0 +1,35 @@ +"""add reviews table + +Revision ID: a1b2c3d4e5f6 +Revises: 9f3eafbe74db +Create Date: 2026-02-25 18:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = '9f3eafbe74db' +branch_labels = None +depends_on = None + + +def upgrade(): + # Создание таблицы отзывов + op.create_table('reviews', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('rating', sa.Integer(), nullable=False), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('course_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['course_id'], ['courses.id'], name=op.f('fk_reviews_course_id_courses')), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_reviews_user_id_users')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_reviews')) + ) + + +def downgrade(): + op.drop_table('reviews') diff --git a/labs/lab-6/app/models.py b/labs/lab-6/app/models.py new file mode 100644 index 0000000..7954fca --- /dev/null +++ b/labs/lab-6/app/models.py @@ -0,0 +1,125 @@ +import os +from typing import Optional, Union, List +from datetime import datetime +import sqlalchemy as sa +from werkzeug.security import check_password_hash, generate_password_hash +from flask_login import UserMixin +from flask import url_for +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, ForeignKey, DateTime, Text, Integer, MetaData + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention={ + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" + }) + +db = SQLAlchemy(model_class=Base) + +class Category(Base): + __tablename__ = 'categories' + + id = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("categories.id")) + + def __repr__(self): + return '' % self.name + + +class User(Base, UserMixin): + __tablename__ = 'users' + + id: Mapped[int] = mapped_column(primary_key=True) + first_name: Mapped[str] = mapped_column(String(100)) + last_name: Mapped[str] = mapped_column(String(100)) + middle_name: Mapped[Optional[str]] = mapped_column(String(100)) + login: Mapped[str] = mapped_column(String(100), unique=True) + password_hash: Mapped[str] = mapped_column(String(200)) + created_at: Mapped[datetime] = mapped_column(default=datetime.now) + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + @property + def full_name(self): + return ' '.join([self.last_name, self.first_name, self.middle_name or '']) + + def __repr__(self): + return '' % self.login + +class Course(Base): + __tablename__ = 'courses' + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + short_desc: Mapped[str] = mapped_column(Text) + full_desc: Mapped[str] = mapped_column(Text) + rating_sum: Mapped[int] = mapped_column(default=0) + rating_num: Mapped[int] = mapped_column(default=0) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id")) + author_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + background_image_id: Mapped[str] = mapped_column(ForeignKey("images.id")) + created_at: Mapped[datetime] = mapped_column(default=datetime.now) + + author: Mapped["User"] = relationship() + category: Mapped["Category"] = relationship(lazy=False) + bg_image: Mapped["Image"] = relationship() + # Связь с отзывами + reviews: Mapped[List["Review"]] = relationship(back_populates='course') + + def __repr__(self): + return '' % self.name + + @property + def rating(self): + if self.rating_num > 0: + return self.rating_sum / self.rating_num + return 0 + +class Image(db.Model): + __tablename__ = 'images' + + id: Mapped[str] = mapped_column(String(100), primary_key=True) + file_name: Mapped[str] = mapped_column(String(100)) + mime_type: Mapped[str] = mapped_column(String(100)) + md5_hash: Mapped[str] = mapped_column(String(100), unique=True) + object_id: Mapped[Optional[int]] + object_type: Mapped[Optional[str]] = mapped_column(String(100)) + created_at: Mapped[datetime] = mapped_column(default=datetime.now) + + def __repr__(self): + return '' % self.file_name + + @property + def storage_filename(self): + _, ext = os.path.splitext(self.file_name) + return self.id + ext + + @property + def url(self): + return url_for('image', image_id=self.id) + +# Модель отзыва +class Review(Base): + __tablename__ = 'reviews' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + rating: Mapped[int] = mapped_column(Integer, nullable=False) + text: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + course_id: Mapped[int] = mapped_column(ForeignKey('courses.id')) + user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) + + # Связи с курсом и пользователем + course: Mapped['Course'] = relationship(back_populates='reviews') + user: Mapped['User'] = relationship() diff --git a/labs/lab-6/app/static/images/default-profile-picture-300x300.jpeg b/labs/lab-6/app/static/images/default-profile-picture-300x300.jpeg new file mode 100644 index 0000000..a6ebb06 Binary files /dev/null and b/labs/lab-6/app/static/images/default-profile-picture-300x300.jpeg differ diff --git a/labs/lab-6/app/static/images/polytech_logo.png b/labs/lab-6/app/static/images/polytech_logo.png new file mode 100644 index 0000000..fba1391 Binary files /dev/null and b/labs/lab-6/app/static/images/polytech_logo.png differ diff --git a/labs/lab-6/app/static/main.js b/labs/lab-6/app/static/main.js new file mode 100644 index 0000000..83a4244 --- /dev/null +++ b/labs/lab-6/app/static/main.js @@ -0,0 +1,36 @@ +'use strict'; + +function imagePreviewHandler(event) { + if (event.target.files && event.target.files[0]) { + let reader = new FileReader(); + reader.onload = function (e) { + let img = document.querySelector('.background-preview > img'); + img.src = e.target.result; + if (img.classList.contains('d-none')) { + let label = document.querySelector('.background-preview > label'); + label.classList.add('d-none'); + img.classList.remove('d-none'); + } + } + reader.readAsDataURL(event.target.files[0]); + } +} + +function openLink(event) { + let row = event.target.closest('.row'); + if (row.dataset.url) { + window.location = row.dataset.url; + } +} + + + +window.onload = function() { + let background_img_field = document.getElementById('background_img'); + if (background_img_field) { + background_img_field.onchange = imagePreviewHandler; + } + for (let course_elm of document.querySelectorAll('.courses-list .row')) { + course_elm.onclick = openLink; + } +} \ No newline at end of file diff --git a/labs/lab-6/app/static/styles.css b/labs/lab-6/app/static/styles.css new file mode 100644 index 0000000..1f84604 --- /dev/null +++ b/labs/lab-6/app/static/styles.css @@ -0,0 +1,448 @@ +/* === Переменные темы === */ +:root { + --red: #e63946; + --red-light: #ff6b6b; + --red-dark: #c1121f; + --red-bg: rgba(230, 57, 70, 0.15); + --red-glow: rgba(230, 57, 70, 0.3); + + --bg: #0d0d0d; + --bg-darker: #080808; + --card: #141414; + --card-hover: #1a1a1a; + --elevated: #1e1e1e; + --input-bg: #1a1a1a; + + --text: #ffffff; + --text-sec: #b0b0b0; + --text-muted: #666666; + + --border: #2a2a2a; + --border-lt: #3a3a3a; + + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --info: #3b82f6; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 24px; + --radius-full: 9999px; + + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* === Базовые стили === */ +*, *::before, *::after { box-sizing: border-box; } + +html, body { + background-color: var(--bg); + color: var(--text); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 16px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + min-height: 100%; +} + +h1, h2, h3, h4, h5, h6 { + font-family: 'Raleway', sans-serif; + font-weight: 700; + color: var(--text); + letter-spacing: -0.02em; + line-height: 1.2; +} + +a { color: var(--red); text-decoration: none; transition: var(--transition); } +a:hover { color: var(--red-light); } + +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border-lt); border-radius: var(--radius-full); } +::-webkit-scrollbar-thumb:hover { background: var(--red); } + +::selection { background: var(--red); color: #fff; } + +/* === Навбар === */ +.site-navbar { + background: rgba(13, 13, 13, 0.92) !important; + backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); + padding: 0.75rem 0; + position: sticky; + top: 0; + z-index: 300; +} + +.logo { max-width: 160px; } +.logo img { filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3)); transition: var(--transition); } +.logo img:hover { filter: drop-shadow(0 2px 12px var(--red-glow)); transform: translateY(-1px); } + +.nav-link-item { + color: var(--text-sec); + font-size: 0.9rem; + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: var(--radius-sm); + transition: var(--transition); + display: inline-flex; + align-items: center; + gap: 6px; + text-decoration: none; +} +.nav-link-item:hover { + color: var(--text); + background: var(--red-bg); +} + +.profile-avatar { max-width: 40px; cursor: pointer; } +.profile-avatar img { border: 2px solid var(--border-lt); transition: var(--transition); } +.profile-avatar img:hover { border-color: var(--red); } + +/* === Dropdown === */ +.site-dropdown { + background: var(--card) !important; + border: 1px solid var(--border-lt) !important; + border-radius: var(--radius-md) !important; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); +} +.site-dropdown .dropdown-item { + color: var(--text-sec); + transition: var(--transition); + font-size: 0.9rem; +} +.site-dropdown .dropdown-item:hover { background: var(--red-bg); color: var(--text); } +.site-dropdown .dropdown-divider { border-color: var(--border); } +.site-dropdown .dropdown-item-text { color: var(--text-muted); } + +/* === Кнопки === */ +.btn-primary-red { + display: inline-flex; + align-items: center; + gap: 6px; + background: linear-gradient(135deg, var(--red) 0%, var(--red-dark) 100%); + color: #fff; + border: none; + border-radius: var(--radius-md); + padding: 0.6rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + text-decoration: none; + box-shadow: 0 4px 15px var(--red-glow); +} +.btn-primary-red:hover { + color: #fff; + transform: translateY(-2px); + box-shadow: 0 6px 20px var(--red-glow); +} + +/* Bootstrap переопределения */ +.btn-dark, .btn-dark:focus { + background: linear-gradient(135deg, var(--red) 0%, var(--red-dark) 100%) !important; + border: none !important; + color: #fff !important; + border-radius: var(--radius-md); + font-weight: 600; + transition: var(--transition); + box-shadow: 0 4px 15px var(--red-glow); +} +.btn-dark:hover { + background: linear-gradient(135deg, var(--red-light) 0%, var(--red) 100%) !important; + color: #fff !important; + transform: translateY(-2px); + box-shadow: 0 6px 20px var(--red-glow); +} + +.btn-outline-light { + border-color: var(--border-lt) !important; + color: var(--text-sec) !important; + border-radius: var(--radius-md); + transition: var(--transition); +} +.btn-outline-light:hover { + border-color: var(--red) !important; + background: var(--red-bg) !important; + color: var(--text) !important; +} + +/* === Alerts === */ +.site-alert { + border: none; + border-left: 4px solid; +} +.alert-success { background: rgba(34, 197, 94, 0.15) !important; border-left-color: var(--success); color: var(--text) !important; } +.alert-danger { background: rgba(239, 68, 68, 0.15) !important; border-left-color: var(--danger); color: var(--text) !important; } +.alert-warning { background: rgba(245, 158, 11, 0.15) !important; border-left-color: var(--warning); color: var(--text) !important; } +.alert-info { background: rgba(59, 130, 246, 0.15) !important; border-left-color: var(--info); color: var(--text) !important; } +.btn-close-white { filter: invert(1); } + +/* === Bootstrap card override === */ +.card { + background: var(--card) !important; + border: 1px solid var(--border) !important; + border-radius: var(--radius-md) !important; + color: var(--text) !important; +} +.card-header { + background: var(--elevated) !important; + border-bottom: 1px solid var(--border) !important; + color: var(--text) !important; +} +.card-body { color: var(--text-sec) !important; } +.card-text { color: var(--text-sec); } + +/* === Формы === */ +.form-control, .form-select { + background: var(--input-bg) !important; + border: 1px solid var(--border) !important; + border-radius: var(--radius-md) !important; + color: var(--text) !important; + font-size: 0.9rem; + padding: 0.7rem 1rem; + transition: var(--transition); +} +.form-control:focus, .form-select:focus { + background: var(--input-bg) !important; + border-color: var(--red) !important; + box-shadow: 0 0 0 3px var(--red-bg) !important; + color: var(--text) !important; + outline: none; +} +.form-control::placeholder { color: var(--text-muted); } +.form-select option { background: var(--card); color: var(--text); } +label { color: var(--text-sec); font-size: 0.9rem; margin-bottom: 0.4rem; display: block; } + +/* === Главная страница === */ +.hero-section { + min-height: calc(100vh - 70px); + background: var(--bg); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 3rem 1rem; + position: relative; +} +.hero-section::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, rgba(230,57,70,0.08) 0%, transparent 70%); + pointer-events: none; +} +.hero-title { + font-size: clamp(2rem, 5vw, 3.5rem); + background: linear-gradient(135deg, #fff 0%, var(--text-sec) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 1.5rem; +} +.hero-desc { + color: var(--text-sec); + max-width: 600px; + margin: 0 auto 2rem; + font-size: 1.05rem; +} +.hero-search { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + padding: 2rem; + max-width: 700px; + margin: 0 auto; +} + +/* === Заголовок курса (hero) === */ +.title-area { + background-color: var(--bg-darker); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + color: var(--text); + font-size: 1.1rem; + min-height: calc(65vh); +} +.title-area h1 { font-size: clamp(1.8rem, 4vw, 3rem); } +.title-area p { color: rgba(255,255,255,0.85); } + +/* === Список курсов === */ +.courses-list .row { + background: var(--card) !important; + border: 1px solid var(--border) !important; + border-radius: var(--radius-lg); + transition: var(--transition); + cursor: pointer; + margin-bottom: 1rem; + padding: 1rem; +} +.courses-list .row:hover { + border-color: var(--red) !important; + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(0,0,0,0.4); +} +.courses-list h4 { color: var(--text); } +.courses-list .text-muted { color: var(--text-muted) !important; } + +.course-logo { + min-width: 160px; + width: 100%; + height: 160px; + background-repeat: no-repeat; + background-position: center; + background-size: cover; + border-radius: var(--radius-md); +} + +.rating { color: var(--red); font-weight: 600; white-space: nowrap; } + +/* === Фоновый preview === */ +.background-preview { + border: 2px dashed var(--border-lt); + border-radius: var(--radius-md); + flex-grow: 1; + background: var(--input-bg); + min-height: 200px; +} + +/* === Пагинация === */ +.pagination { gap: 0.4rem; } +.page-link { + background: var(--card) !important; + border: 1px solid var(--border) !important; + color: var(--text-sec) !important; + border-radius: var(--radius-md) !important; + min-width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + transition: var(--transition); +} +.page-link:hover { + border-color: var(--red) !important; + color: var(--red) !important; + background: var(--card) !important; +} +.page-item.active .page-link { + background: var(--red-bg) !important; + border-color: var(--red) !important; + color: var(--red) !important; + font-weight: 600; +} +.page-item.disabled .page-link { opacity: 0.4; } + +/* === Отзывы === */ +.review-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem 1.5rem; + transition: var(--transition); + margin-bottom: 1rem; +} +.review-card:hover { border-color: var(--border-lt); } + +.review-stars { color: var(--red); letter-spacing: 2px; font-size: 1rem; } +.review-author { color: var(--text); font-weight: 600; font-size: 0.95rem; } +.review-date { color: var(--text-muted); font-size: 0.8rem; } +.review-text { color: var(--text-sec); margin-top: 0.5rem; line-height: 1.6; } + +.review-form-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; +} +.review-form-card h5 { color: var(--text); font-family: 'Raleway', sans-serif; margin-bottom: 1rem; } + +.section-divider { + border: none; + border-top: 1px solid var(--border); + margin: 2rem 0; +} + +/* === Секции курса === */ +.course-section { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 2rem; + margin-bottom: 2rem; +} +.course-section h2 { + font-size: 1.4rem; + color: var(--text); + margin-bottom: 1.2rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* === Бейдж рейтинга === */ +.rating-badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--red-bg); + color: var(--red); + border-radius: var(--radius-full); + padding: 0.25rem 0.75rem; + font-weight: 700; + font-size: 0.95rem; +} + +/* === Footer === */ +.site-footer { + background: var(--bg-darker); + border-top: 1px solid var(--border); + padding: 1.75rem 0 1.25rem; + margin-top: auto; +} +.footer-content { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1rem; +} +.footer-logo { opacity: 0.75; } +.footer-info .footer-name { + color: var(--text); + font-weight: 600; + font-size: 0.9rem; +} +.footer-info .footer-group { + color: var(--text-muted); + font-size: 0.8rem; +} +.footer-links { display: flex; gap: 1rem; align-items: center; } +.footer-links a { + color: var(--text-muted); + font-size: 1.1rem; + transition: var(--transition); + text-decoration: none; +} +.footer-links a:hover { + color: var(--red); + transform: translateY(-2px); + text-shadow: 0 0 10px var(--red-glow); +} +.footer-copy { color: var(--text-muted); font-size: 0.8rem; text-align: center; } + +/* === Адаптивность === */ +@media (max-width: 768px) { + .footer-content { flex-direction: column; text-align: center; } + .footer-links { justify-content: center; } + .footer-logo { display: none; } + .hero-section { padding: 2rem 1rem; } + .course-logo { height: 120px; } + .courses-list .row { flex-direction: column; } +} diff --git a/labs/lab-6/app/templates/auth/login.html b/labs/lab-6/app/templates/auth/login.html new file mode 100644 index 0000000..761c90a --- /dev/null +++ b/labs/lab-6/app/templates/auth/login.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Вход в систему

+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+{% endblock %} diff --git a/labs/lab-6/app/templates/base.html b/labs/lab-6/app/templates/base.html new file mode 100644 index 0000000..132d571 --- /dev/null +++ b/labs/lab-6/app/templates/base.html @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + Образовательный портал | Московский Политех + + + +
+ +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, msg in messages %} + + {% endfor %} + {% endwith %} +
+ +
+ {% block content %} + {% endblock %} +
+ +
+
+ +
+
+ + + + + + + diff --git a/labs/lab-6/app/templates/courses/index.html b/labs/lab-6/app/templates/courses/index.html new file mode 100644 index 0000000..4a0cc94 --- /dev/null +++ b/labs/lab-6/app/templates/courses/index.html @@ -0,0 +1,69 @@ +{% extends 'base.html' %} +{% from 'pagination.html' import render_pagination %} + +{% block content %} +
+ +

Каталог курсов

+ +
+
+
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+ {% for course in courses %} +
+
+ +
+
+
+

{{ course.name }}

+ + {{ "%.2f" | format(course.rating) }} + +
+

{{ course.author.full_name }}

+

{{ course.short_desc | truncate(200) }}

+
+
+ {% endfor %} +
+ +
+ {{ render_pagination(pagination, request.endpoint, search_params) }} +
+ + {% if current_user.is_authenticated %} + + {% endif %} + +
+{% endblock %} diff --git a/labs/lab-6/app/templates/courses/new.html b/labs/lab-6/app/templates/courses/new.html new file mode 100644 index 0000000..025c97c --- /dev/null +++ b/labs/lab-6/app/templates/courses/new.html @@ -0,0 +1,64 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Создание курса

+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/labs/lab-6/app/templates/courses/reviews.html b/labs/lab-6/app/templates/courses/reviews.html new file mode 100644 index 0000000..3b96ad1 --- /dev/null +++ b/labs/lab-6/app/templates/courses/reviews.html @@ -0,0 +1,117 @@ +{% extends 'base.html' %} +{% from 'pagination.html' import render_pagination %} + +{% block content %} +
+ +
+

+ Отзывы: {{ course.name }} +

+ + К курсу + +
+ + {# Фильтр сортировки #} +
+
+
+ + +
+ +
+
+ + {# Список отзывов #} + {% if reviews %} + {% for review in reviews %} +
+
+
+ {{ review.user.full_name }} + {{ review.created_at.strftime('%d.%m.%Y %H:%M') }} +
+
+ {% for i in range(5) %} + {% if i < review.rating %}{% else %}{% endif %} + {% endfor %} + ({{ review.rating }}/5) +
+
+

{{ review.text }}

+
+ {% endfor %} + + {# Пагинация с сохранением сортировки #} +
+ {{ render_pagination(pagination, 'courses.reviews', {'course_id': course.id, 'sort': sort}) }} +
+ {% else %} +
+ +

Отзывов пока нет. Будьте первым!

+
+ {% endif %} + + {# Форма отзыва внизу страницы #} + {% if current_user.is_authenticated %} +
+ {% if user_review %} +
+
Ваш отзыв
+
+
+
+ {{ current_user.full_name }} + {{ user_review.created_at.strftime('%d.%m.%Y') }} +
+
+ {% for i in range(5) %} + {% if i < user_review.rating %}{% else %}{% endif %} + {% endfor %} + ({{ user_review.rating }}/5) +
+
+

{{ user_review.text }}

+
+
+ {% else %} + {# Форма добавления отзыва #} +
+
Оставить отзыв
+
+ +
+ + +
+
+ + +
+ +
+
+ {% endif %} +
+ {% endif %} + +
+{% endblock %} diff --git a/labs/lab-6/app/templates/courses/show.html b/labs/lab-6/app/templates/courses/show.html new file mode 100644 index 0000000..f7996cb --- /dev/null +++ b/labs/lab-6/app/templates/courses/show.html @@ -0,0 +1,119 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+

{{ course.name }}

+

+ {# Категория и рейтинг #} + {{ course.category.name }} + {{ "%.2f" | format(course.rating) }} +

+
+

+ {{ course.short_desc }} +

+
+ {% if current_user.is_authenticated %} + + Перейти к материалам + + {% else %} + + {% endif %} +
+
+
+ +
+ +
+

О курсе

+

{{ course.full_desc }}

+
+ + {# Блок последних отзывов #} +
+
+

Последние отзывы

+ + Все отзывы + +
+ + {% if reviews %} + {% for review in reviews %} +
+
+
+ {{ review.user.full_name }} + {{ review.created_at.strftime('%d.%m.%Y') }} +
+
+ {% for i in range(5) %} + {% if i < review.rating %}{% else %}{% endif %} + {% endfor %} + ({{ review.rating }}/5) +
+
+

{{ review.text }}

+
+ {% endfor %} + {% else %} +

Отзывов пока нет. Будьте первым!

+ {% endif %} +
+ + {# Форма отзыва / Уже оставленный отзыв #} + {% if current_user.is_authenticated %} +
+ {% if user_review %} +

Ваш отзыв

+
+
+
+ {{ current_user.full_name }} + {{ user_review.created_at.strftime('%d.%m.%Y') }} +
+
+ {% for i in range(5) %} + {% if i < user_review.rating %}{% else %}{% endif %} + {% endfor %} + ({{ user_review.rating }}/5) +
+
+

{{ user_review.text }}

+
+ {% else %} + {# Форма нового отзыва #} +

Оставить отзыв

+
+
+ +
+ + +
+
+ + +
+ +
+
+ {% endif %} +
+ {% endif %} + +
+{% endblock %} diff --git a/labs/lab-6/app/templates/index.html b/labs/lab-6/app/templates/index.html new file mode 100644 index 0000000..ba3cf7a --- /dev/null +++ b/labs/lab-6/app/templates/index.html @@ -0,0 +1,40 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Образовательный портал

+ +

+ Мы создаём и продвигаем технологии онлайн-образования как эффективный + образовательный инструмент, который будет способствовать повышению + доступности и качества образования. +

+ + +
+
+{% endblock %} diff --git a/labs/lab-6/app/templates/pagination.html b/labs/lab-6/app/templates/pagination.html new file mode 100644 index 0000000..bae5627 --- /dev/null +++ b/labs/lab-6/app/templates/pagination.html @@ -0,0 +1,26 @@ +{% macro render_pagination(pagination, endpoint, params={}) %} + {% set per_page = pagination.per_page %} + +{% endmacro %} \ No newline at end of file diff --git a/labs/lab-6/app/tools.py b/labs/lab-6/app/tools.py new file mode 100644 index 0000000..7119e24 --- /dev/null +++ b/labs/lab-6/app/tools.py @@ -0,0 +1,53 @@ +import hashlib +import uuid +import os +from werkzeug.utils import secure_filename +from flask import current_app +from models import db, Course, Image + +class CoursesFilter: + def __init__(self, name, category_ids): + self.name = name + self.category_ids = category_ids + self.query = db.select(Course) + + def perform(self): + self.__filter_by_name() + self.__filter_by_category_ids() + return self.query.order_by(Course.created_at.desc()) + + def __filter_by_name(self): + if self.name: + self.query = self.query.filter( + Course.name.ilike('%' + self.name + '%')) + + def __filter_by_category_ids(self): + if self.category_ids: + self.query = self.query.filter( + Course.category_id.in_(self.category_ids)) + +class ImageSaver: + def __init__(self, file): + self.file = file + + def save(self): + self.img = self.__find_by_md5_hash() + if self.img is not None: + return self.img + file_name = secure_filename(self.file.filename) + self.img = Image( + id=str(uuid.uuid4()), + file_name=file_name, + mime_type=self.file.mimetype, + md5_hash=self.md5_hash) + self.file.save( + os.path.join(current_app.config['UPLOAD_FOLDER'], + self.img.storage_filename)) + db.session.add(self.img) + db.session.commit() + return self.img + + def __find_by_md5_hash(self): + self.md5_hash = hashlib.md5(self.file.read()).hexdigest() + self.file.seek(0) + return db.session.execute(db.select(Image).filter(Image.md5_hash == self.md5_hash)).scalar() diff --git a/labs/lab-6/requirements.txt b/labs/lab-6/requirements.txt new file mode 100644 index 0000000..628b3c1 --- /dev/null +++ b/labs/lab-6/requirements.txt @@ -0,0 +1,20 @@ +alembic==1.13.1 +blinker==1.8.2 +click==8.1.7 +flask==3.0.3 +Flask-Login==0.6.3 +Flask-Migrate==4.0.7 +flask-sqlalchemy==3.1.1 +greenlet==3.0.3 +importlib-metadata==7.1.0 +importlib-resources==6.4.0 +itsdangerous==2.2.0 +jinja2==3.1.4 +Mako==1.3.3 +MarkupSafe==2.1.5 +mysql-connector-python==8.4.0 +python-dotenv==1.0.1 +SQLAlchemy==2.0.30 +typing-extensions==4.11.0 +werkzeug==3.0.3 +zipp==3.18.1