mirror of
https://github.com/EDeev/web-dev.git
synced 2026-06-15 19:11:12 +03:00
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