lab 6
This commit is contained in:
parent
9f00e2a3ae
commit
12ce86bc41
31 changed files with 1801 additions and 0 deletions
2
labs/lab-6/app/.env
Normal file
2
labs/lab-6/app/.env
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
FLASK_APP=app.py
|
||||
FLASK_DEBUG=1
|
||||
39
labs/lab-6/app/app.py
Normal file
39
labs/lab-6/app/app.py
Normal file
|
|
@ -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/<image_id>')
|
||||
def image(image_id):
|
||||
img = db.get_or_404(Image, image_id)
|
||||
return send_from_directory(app.config['UPLOAD_FOLDER'],
|
||||
img.storage_filename)
|
||||
38
labs/lab-6/app/auth.py
Normal file
38
labs/lab-6/app/auth.py
Normal file
|
|
@ -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'))
|
||||
9
labs/lab-6/app/config.py
Normal file
9
labs/lab-6/app/config.py
Normal file
|
|
@ -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')
|
||||
148
labs/lab-6/app/courses.py
Normal file
148
labs/lab-6/app/courses.py
Normal file
|
|
@ -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('/<int:course_id>')
|
||||
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('/<int:course_id>/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('/<int:course_id>/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)
|
||||
BIN
labs/lab-6/app/instance/project.db
Normal file
BIN
labs/lab-6/app/instance/project.db
Normal file
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 483 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 970 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
1
labs/lab-6/app/migrations/README
Normal file
1
labs/lab-6/app/migrations/README
Normal file
|
|
@ -0,0 +1 @@
|
|||
Single-database configuration for Flask.
|
||||
50
labs/lab-6/app/migrations/alembic.ini
Normal file
50
labs/lab-6/app/migrations/alembic.ini
Normal file
|
|
@ -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
|
||||
113
labs/lab-6/app/migrations/env.py
Normal file
113
labs/lab-6/app/migrations/env.py
Normal file
|
|
@ -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()
|
||||
24
labs/lab-6/app/migrations/script.py.mako
Normal file
24
labs/lab-6/app/migrations/script.py.mako
Normal file
|
|
@ -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"}
|
||||
89
labs/lab-6/app/migrations/versions/9f3eafbe74db_.py
Normal file
89
labs/lab-6/app/migrations/versions/9f3eafbe74db_.py
Normal file
|
|
@ -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 ###
|
||||
|
|
@ -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')
|
||||
125
labs/lab-6/app/models.py
Normal file
125
labs/lab-6/app/models.py
Normal file
|
|
@ -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 '<Category %r>' % 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 '<User %r>' % 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 '<Course %r>' % 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 '<Image %r>' % 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()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
BIN
labs/lab-6/app/static/images/polytech_logo.png
Normal file
BIN
labs/lab-6/app/static/images/polytech_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
36
labs/lab-6/app/static/main.js
Normal file
36
labs/lab-6/app/static/main.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
448
labs/lab-6/app/static/styles.css
Normal file
448
labs/lab-6/app/static/styles.css
Normal file
|
|
@ -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; }
|
||||
}
|
||||
33
labs/lab-6/app/templates/auth/login.html
Normal file
33
labs/lab-6/app/templates/auth/login.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex h-100" style="min-height: calc(100vh - 130px); align-items: center;">
|
||||
<form class="m-auto review-form-card" method="POST" style="max-width: 28rem; width:100%;">
|
||||
<h2 class="mb-4 text-center">Вход в систему</h2>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="login">Логин</label>
|
||||
<input autocomplete="off" type="text" class="form-control" name="login" id="login"
|
||||
placeholder="Введите логин" value="{{ request.form.login or '' }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password">Пароль</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Введите пароль">
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="remember_me" name="remember_me">
|
||||
<label class="form-check-label" for="remember_me" style="display:inline;">
|
||||
Запомнить меня
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary-red w-100" style="justify-content:center; height:44px; display:flex; align-items:center;">
|
||||
<i class="fas fa-sign-in-alt me-2"></i>Войти
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
103
labs/lab-6/app/templates/base.html
Normal file
103
labs/lab-6/app/templates/base.html
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<!doctype html>
|
||||
<html lang="ru" class="h-100">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="icon" href="https://deev.space/media/favicon.ico" type="image/x-icon">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@400;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
|
||||
<title>Образовательный портал | Московский Политех</title>
|
||||
</head>
|
||||
|
||||
<body class="d-flex flex-column h-100">
|
||||
<header>
|
||||
<nav class="site-navbar navbar navbar-dark">
|
||||
<div class="container-fluid px-4">
|
||||
<div class="logo navbar-brand">
|
||||
<a href="{{ url_for('index') }}">
|
||||
<img class="img-fluid" src="{{ url_for('static', filename='images/polytech_logo.png') }}" alt="polytech-logo">
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<a class="nav-link-item" href="{{ url_for('courses.index') }}">
|
||||
<i class="fas fa-graduation-cap"></i> Курсы
|
||||
</a>
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="dropdown d-flex">
|
||||
<div class="profile-avatar" title="{{ current_user.full_name }}" id="dropdownMenuButton"
|
||||
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<img class="img-fluid rounded-circle cursor-pointer"
|
||||
src="{{ url_for('static', filename='images/default-profile-picture-300x300.jpeg') }}" alt="profile-pic">
|
||||
</div>
|
||||
<div class="dropdown-menu dropdown-menu-end site-dropdown" aria-labelledby="dropdownMenuButton">
|
||||
<span class="dropdown-item-text small px-3 text-muted" style="color: white;">{{ current_user.full_name }}</span>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||
<i class="fas fa-sign-out-alt me-2"></i>Выйти
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<a class="btn-primary-red" href="{{ url_for('auth.login') }}">
|
||||
<i class="fas fa-sign-in-alt me-1"></i>Войти
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="alerts-area">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, msg in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show m-0 rounded-0 site-alert" role="alert">
|
||||
{% if category == 'success' %}<i class="fas fa-check-circle me-2"></i>
|
||||
{% elif category == 'danger' %}<i class="fas fa-exclamation-circle me-2"></i>
|
||||
{% elif category == 'warning' %}<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{% else %}<i class="fas fa-info-circle me-2"></i>{% endif %}
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<main class="main flex-grow-1">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-info">
|
||||
<div class="footer-name">Деев Егор Викторович, ст. гр. 241-327</div>
|
||||
</div>
|
||||
<div class="footer-copy">© 2026 Московский политехнический университет</div>
|
||||
<div class="footer-links">
|
||||
<a href="mailto:egor@deev.space" title="Email"><i class="fas fa-envelope"></i></a>
|
||||
<a href="https://deev.space" target="_blank" title="Сайт"><i class="fas fa-globe"></i></a>
|
||||
<a href="https://t.me/Egor_Deev" target="_blank" title="Telegram"><i class="fab fa-telegram"></i></a>
|
||||
<a href="https://github.com/EDeev" target="_blank" title="GitHub"><i class="fab fa-github"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<script defer src="{{ url_for('static', filename='main.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
69
labs/lab-6/app/templates/courses/index.html
Normal file
69
labs/lab-6/app/templates/courses/index.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{% extends 'base.html' %}
|
||||
{% from 'pagination.html' import render_pagination %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5 mb-5">
|
||||
|
||||
<h2 class="mb-4 text-center text-uppercase">Каталог курсов</h2>
|
||||
|
||||
<div class="review-form-card mb-4">
|
||||
<form class="row align-items-end g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="course-name">Название курса</label>
|
||||
<input autocomplete="off" type="text" class="form-control" id="course-name" name="name"
|
||||
value="{{ request.args.get('name') or '' }}" placeholder="Введите название...">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="course-category">Категория</label>
|
||||
<select class="form-select" id="course-category" name="category_ids">
|
||||
<option value="">Все категории</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {% if category.id | string in request.args.getlist('category_ids') %}selected{% endif %}>{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn-primary-red w-100" style="height:44px; justify-content:center; display:flex; align-items:center;">
|
||||
<i class="fas fa-search me-1"></i>Найти
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="courses-list mt-3 mb-3">
|
||||
{% for course in courses %}
|
||||
<div class="row p-3 border rounded mb-3" data-url="{{ url_for('courses.show', course_id=course.id) }}">
|
||||
<div class="col-md-3 mb-3 mb-md-0 d-flex align-items-center justify-content-center">
|
||||
<div class="course-logo" style="background-image: url({{ url_for('image', image_id=course.background_image_id) }});">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<div class="d-flex align-items-start">
|
||||
<h4 class="text-uppercase mb-2">{{ course.name }}</h4>
|
||||
<span class="rating ms-auto">
|
||||
<i class="fas fa-star me-1"></i>{{ "%.2f" | format(course.rating) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-muted my-2 mb-3">{{ course.author.full_name }}</p>
|
||||
<p style="color: var(--text-sec);">{{ course.short_desc | truncate(200) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
{{ render_pagination(pagination, request.endpoint, search_params) }}
|
||||
</div>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="text-center my-4">
|
||||
<a class="btn-primary-red" href="{{ url_for('courses.new') }}" style="font-size:1rem; padding: 0.8rem 2rem;">
|
||||
<i class="fas fa-plus me-2"></i>Создать курс
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
64
labs/lab-6/app/templates/courses/new.html
Normal file
64
labs/lab-6/app/templates/courses/new.html
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5 mb-5">
|
||||
<h2 class="my-3 text-center text-uppercase">Создание курса</h2>
|
||||
|
||||
<div class="review-form-card mt-4">
|
||||
<form method="POST" action="{{ url_for('courses.create') }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="author_id" value="{{ current_user.id }}">
|
||||
<div class="mb-3 row">
|
||||
<div class="col-md-6 d-flex flex-column">
|
||||
<div class="mb-3">
|
||||
<label for="name">Название</label>
|
||||
<input class="form-control" type="text" name="name" id="name" value="{{ course.name or '' }}" placeholder="Введите название курса">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="category">Категория</label>
|
||||
<select class="form-select" name="category_id" id="category">
|
||||
{% for category in categories %}
|
||||
<option {% if course.category_id == category.id | string %}selected{% endif %} value="{{ category.id }}">{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="teachers">Преподаватели</label>
|
||||
<select class="form-select" name="teachers_ids" id="teachers" multiple>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if user.id == current_user.id or course.author_id == user.id | string %}selected{% endif %}>{{ user.full_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3 d-flex flex-column flex-grow-1">
|
||||
<label for="short_description">Краткое описание</label>
|
||||
<textarea class="form-control flex-grow-1" name="short_desc" id="short_description" cols="30" rows="5" placeholder="Краткое описание курса...">{{ course.short_desc or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 d-flex flex-column">
|
||||
<div class="mb-3">
|
||||
<label for="background_img">Фоновое изображение</label>
|
||||
<input class="form-control" type="file" name="background_img" id="background_img" accept="image/*">
|
||||
</div>
|
||||
<div class="mb-3 background-preview p-3 rounded d-flex align-items-center justify-content-center">
|
||||
<img src="#" class="img-fluid d-none" alt="">
|
||||
<label for="background_img" class="btn-primary-red" style="cursor:pointer;">
|
||||
<i class="fas fa-image me-1"></i>Выбрать изображение
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="full_description">Полное описание</label>
|
||||
<textarea class="form-control" name="full_desc" id="full_description" cols="30" rows="10" placeholder="Подробное описание курса...">{{ course.full_desc or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 text-center">
|
||||
<button type="submit" class="btn-primary-red" style="padding: 0.75rem 2.5rem;">
|
||||
<i class="fas fa-save me-1"></i>Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
117
labs/lab-6/app/templates/courses/reviews.html
Normal file
117
labs/lab-6/app/templates/courses/reviews.html
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
{% extends 'base.html' %}
|
||||
{% from 'pagination.html' import render_pagination %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
|
||||
<div class="d-flex align-items-center mb-4 gap-3 flex-wrap">
|
||||
<h1 style="font-size: 1.8rem; margin-bottom:0;">
|
||||
Отзывы: <span style="color: var(--red);">{{ course.name }}</span>
|
||||
</h1>
|
||||
<a href="{{ url_for('courses.show', course_id=course.id) }}" class="btn-primary-red ms-auto">
|
||||
<i class="fas fa-arrow-left me-1"></i>К курсу
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Фильтр сортировки #}
|
||||
<div class="review-form-card mb-4">
|
||||
<form method="GET" action="{{ url_for('courses.reviews', course_id=course.id) }}" class="d-flex align-items-end gap-3 flex-wrap">
|
||||
<div>
|
||||
<label for="sort">Порядок сортировки</label>
|
||||
<select class="form-select" name="sort" id="sort" style="min-width:220px;">
|
||||
<option value="new" {% if sort == 'new' %}selected{% endif %}>Сначала новые</option>
|
||||
<option value="positive" {% if sort == 'positive' %}selected{% endif %}>Сначала положительные</option>
|
||||
<option value="negative" {% if sort == 'negative' %}selected{% endif %}>Сначала отрицательные</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary-red">
|
||||
<i class="fas fa-filter me-1"></i>Применить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Список отзывов #}
|
||||
{% if reviews %}
|
||||
{% for review in reviews %}
|
||||
<div class="review-card">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div>
|
||||
<span class="review-author">{{ review.user.full_name }}</span>
|
||||
<span class="review-date ms-2">{{ review.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
|
||||
</div>
|
||||
<div class="review-stars">
|
||||
{% for i in range(5) %}
|
||||
{% if i < review.rating %}<i class="fas fa-star"></i>{% else %}<i class="far fa-star"></i>{% endif %}
|
||||
{% endfor %}
|
||||
<span class="ms-1" style="color: var(--text-muted); font-size:0.82rem;">({{ review.rating }}/5)</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="review-text mb-0">{{ review.text }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Пагинация с сохранением сортировки #}
|
||||
<div class="mt-4">
|
||||
{{ render_pagination(pagination, 'courses.reviews', {'course_id': course.id, 'sort': sort}) }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="review-form-card text-center py-5">
|
||||
<i class="fas fa-comment-slash fa-3x mb-3" style="color: var(--text-muted);"></i>
|
||||
<p style="color: var(--text-muted);">Отзывов пока нет. Будьте первым!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Форма отзыва внизу страницы #}
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="mt-5">
|
||||
{% if user_review %}
|
||||
<div class="review-form-card">
|
||||
<h5><i class="fas fa-user me-2" style="color: var(--red);"></i>Ваш отзыв</h5>
|
||||
<div class="review-card" style="background: var(--elevated);">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div>
|
||||
<span class="review-author">{{ current_user.full_name }}</span>
|
||||
<span class="review-date ms-2">{{ user_review.created_at.strftime('%d.%m.%Y') }}</span>
|
||||
</div>
|
||||
<div class="review-stars">
|
||||
{% for i in range(5) %}
|
||||
{% if i < user_review.rating %}<i class="fas fa-star"></i>{% else %}<i class="far fa-star"></i>{% endif %}
|
||||
{% endfor %}
|
||||
<span class="ms-1" style="color: var(--text-muted); font-size:0.82rem;">({{ user_review.rating }}/5)</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="review-text mb-0">{{ user_review.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Форма добавления отзыва #}
|
||||
<div class="review-form-card">
|
||||
<h5><i class="fas fa-edit me-2" style="color: var(--red);"></i>Оставить отзыв</h5>
|
||||
<form method="POST" action="{{ url_for('courses.create_review', course_id=course.id) }}">
|
||||
<input type="hidden" name="next" value="{{ url_for('courses.reviews', course_id=course.id, sort=sort) }}">
|
||||
<div class="mb-3">
|
||||
<label for="rating">Оценка</label>
|
||||
<select class="form-select" name="rating" id="rating">
|
||||
<option value="5">5 — Отлично</option>
|
||||
<option value="4">4 — Хорошо</option>
|
||||
<option value="3">3 — Удовлетворительно</option>
|
||||
<option value="2">2 — Неудовлетворительно</option>
|
||||
<option value="1">1 — Плохо</option>
|
||||
<option value="0">0 — Ужасно</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="text">Текст отзыва</label>
|
||||
<textarea class="form-control" name="text" id="text" rows="4" placeholder="Поделитесь впечатлением о курсе..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary-red">
|
||||
<i class="fas fa-paper-plane me-1"></i>Отправить отзыв
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
119
labs/lab-6/app/templates/courses/show.html
Normal file
119
labs/lab-6/app/templates/courses/show.html
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="title-area position-relative" style="background-image: url({{ course.bg_image.url }});">
|
||||
<div class="h-100 w-100 py-5 d-flex text-center position-absolute" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||
<div class="m-auto">
|
||||
<h1 class="title mb-3">{{ course.name }}</h1>
|
||||
<p class="mb-3 mx-auto">
|
||||
{# Категория и рейтинг #}
|
||||
<span class="badge-red me-2">{{ course.category.name }}</span>
|
||||
<span class="rating-badge"><i class="fas fa-star me-1"></i>{{ "%.2f" | format(course.rating) }}</span>
|
||||
</p>
|
||||
<div class="container">
|
||||
<p class="w-75 mb-5 mx-auto" style="color: rgba(255,255,255,0.85);">
|
||||
{{ course.short_desc }}
|
||||
</p>
|
||||
</div>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="#" class="btn-primary-red">
|
||||
<i class="fas fa-play me-1"></i>Перейти к материалам
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-light btn-lg" disabled>Записаться</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-5">
|
||||
|
||||
<section class="course-section">
|
||||
<h2>О курсе</h2>
|
||||
<p style="color: var(--text-sec);">{{ course.full_desc }}</p>
|
||||
</section>
|
||||
|
||||
{# Блок последних отзывов #}
|
||||
<section class="course-section">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h2 class="mb-0">Последние отзывы</h2>
|
||||
<a href="{{ url_for('courses.reviews', course_id=course.id) }}" class="btn-primary-red">
|
||||
<i class="fas fa-list me-1"></i>Все отзывы
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if reviews %}
|
||||
{% for review in reviews %}
|
||||
<div class="review-card">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div>
|
||||
<span class="review-author">{{ review.user.full_name }}</span>
|
||||
<span class="review-date ms-2">{{ review.created_at.strftime('%d.%m.%Y') }}</span>
|
||||
</div>
|
||||
<div class="review-stars">
|
||||
{% for i in range(5) %}
|
||||
{% if i < review.rating %}<i class="fas fa-star"></i>{% else %}<i class="far fa-star"></i>{% endif %}
|
||||
{% endfor %}
|
||||
<span class="ms-1" style="color: var(--text-muted); font-size:0.82rem;">({{ review.rating }}/5)</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="review-text mb-0">{{ review.text }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted);">Отзывов пока нет. Будьте первым!</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# Форма отзыва / Уже оставленный отзыв #}
|
||||
{% if current_user.is_authenticated %}
|
||||
<section class="course-section">
|
||||
{% if user_review %}
|
||||
<h2>Ваш отзыв</h2>
|
||||
<div class="review-card">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div>
|
||||
<span class="review-author">{{ current_user.full_name }}</span>
|
||||
<span class="review-date ms-2">{{ user_review.created_at.strftime('%d.%m.%Y') }}</span>
|
||||
</div>
|
||||
<div class="review-stars">
|
||||
{% for i in range(5) %}
|
||||
{% if i < user_review.rating %}<i class="fas fa-star"></i>{% else %}<i class="far fa-star"></i>{% endif %}
|
||||
{% endfor %}
|
||||
<span class="ms-1" style="color: var(--text-muted); font-size:0.82rem;">({{ user_review.rating }}/5)</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="review-text mb-0">{{ user_review.text }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Форма нового отзыва #}
|
||||
<h2>Оставить отзыв</h2>
|
||||
<div class="review-form-card">
|
||||
<form method="POST" action="{{ url_for('courses.create_review', course_id=course.id) }}">
|
||||
<input type="hidden" name="next" value="{{ url_for('courses.show', course_id=course.id) }}">
|
||||
<div class="mb-3">
|
||||
<label for="rating">Оценка</label>
|
||||
<select class="form-select" name="rating" id="rating">
|
||||
<option value="5">5 — Отлично</option>
|
||||
<option value="4">4 — Хорошо</option>
|
||||
<option value="3">3 — Удовлетворительно</option>
|
||||
<option value="2">2 — Неудовлетворительно</option>
|
||||
<option value="1">1 — Плохо</option>
|
||||
<option value="0">0 — Ужасно</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="text">Текст отзыва</label>
|
||||
<textarea class="form-control" name="text" id="text" rows="4" placeholder="Поделитесь впечатлением о курсе..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary-red">
|
||||
<i class="fas fa-paper-plane me-1"></i>Отправить отзыв
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
40
labs/lab-6/app/templates/index.html
Normal file
40
labs/lab-6/app/templates/index.html
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero-section">
|
||||
<div class="m-auto w-100">
|
||||
<h1 class="hero-title mb-4">Образовательный портал</h1>
|
||||
|
||||
<p class="hero-desc">
|
||||
Мы создаём и продвигаем технологии онлайн-образования как эффективный
|
||||
образовательный инструмент, который будет способствовать повышению
|
||||
доступности и качества образования.
|
||||
</p>
|
||||
|
||||
<div class="hero-search">
|
||||
<form action="{{ url_for('courses.index') }}" class="row align-items-end g-3">
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<label for="course-name">Название курса</label>
|
||||
<input autocomplete="off" type="text" class="form-control" id="course-name" name="name" placeholder="Введите название...">
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 col-md-4">
|
||||
<label for="course-category">Категория</label>
|
||||
<select class="form-select" id="course-category" name="category_ids">
|
||||
<option value="">Все категории</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}">{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 col-md-2">
|
||||
<button type="submit" class="btn-primary-red w-100" style="height:44px; justify-content:center; display:flex; align-items:center;">
|
||||
<i class="fas fa-search me-1"></i>Найти
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
labs/lab-6/app/templates/pagination.html
Normal file
26
labs/lab-6/app/templates/pagination.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{% macro render_pagination(pagination, endpoint, params={}) %}
|
||||
{% set per_page = pagination.per_page %}
|
||||
<nav>
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for(endpoint, page=(pagination.page - 1), per_page=per_page, **params) if pagination.has_prev else '#' }}" aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{%- for page in pagination.iter_pages() %}
|
||||
{% if page %}
|
||||
<li class="page-item {% if page == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for(endpoint, page=page, per_page=per_page, **params) }}">
|
||||
{{ page }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{%- endfor %}
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for(endpoint, page=(pagination.page + 1), per_page=per_page, **params) if pagination.has_next else '#' }}" aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
53
labs/lab-6/app/tools.py
Normal file
53
labs/lab-6/app/tools.py
Normal file
|
|
@ -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()
|
||||
20
labs/lab-6/requirements.txt
Normal file
20
labs/lab-6/requirements.txt
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue