This commit is contained in:
EDeev 2026-03-13 13:14:58 +03:00
parent 9f00e2a3ae
commit 12ce86bc41
31 changed files with 1801 additions and 0 deletions

2
labs/lab-6/app/.env Normal file
View file

@ -0,0 +1,2 @@
FLASK_APP=app.py
FLASK_DEBUG=1

39
labs/lab-6/app/app.py Normal file
View 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
View 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
View 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
View 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)

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

View file

@ -0,0 +1 @@
Single-database configuration for Flask.

View 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

View 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()

View 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"}

View 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 ###

View file

@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View 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;
}
}

View 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; }
}

View 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 %}

View 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">&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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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">&laquo;</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">&raquo;</span>
</a>
</li>
</ul>
</nav>
{% endmacro %}

53
labs/lab-6/app/tools.py Normal file
View 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()

View 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