diff --git a/ex/app/__init__.py b/ex/app/__init__.py new file mode 100644 index 0000000..3e568a5 --- /dev/null +++ b/ex/app/__init__.py @@ -0,0 +1,446 @@ +import os +import hashlib +from functools import wraps +from flask import (Flask, render_template, request, redirect, url_for, + session, flash, g, send_from_directory) +from werkzeug.security import check_password_hash +import sqlite3 +import bleach +import markdown + +app = Flask(__name__) +app.secret_key = 'exam-secret-key-2026' + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(__file__), 'static', 'uploads') +app.config['DATABASE'] = os.path.join(_ROOT, 'db', 'library.db') +app.config['PER_PAGE'] = 10 + +ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [ + 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'pre', 'code', 'blockquote', 'ul', 'ol', 'li', 'hr', + 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td' +] +ALLOWED_ATTRS = {**bleach.sanitizer.ALLOWED_ATTRIBUTES, 'img': ['src', 'alt']} + +RATING_LABELS = {5: 'отлично', 4: 'хорошо', 3: 'удовлетворительно', + 2: 'неудовлетворительно', 1: 'плохо', 0: 'ужасно'} + + + +def get_db(): + if 'db' not in g: + g.db = sqlite3.connect(app.config['DATABASE']) + g.db.row_factory = sqlite3.Row + g.db.execute('PRAGMA foreign_keys = ON') + return g.db + + +@app.teardown_appcontext +def close_db(exc): + db = g.pop('db', None) + if db is not None: + db.close() + + +def init_db(): + db = get_db() + schema_path = os.path.join(_ROOT, 'db', 'schema.sql') + with open(schema_path, encoding='utf-8') as f: + db.executescript(f.read()) + db.commit() + + + +def load_user(): + user_id = session.get('user_id') + if user_id: + g.user = get_db().execute( + 'SELECT u.*, r.name as role_name FROM users u JOIN roles r ON u.role_id=r.id WHERE u.id=?', + (user_id,) + ).fetchone() + else: + g.user = None + + +app.before_request(load_user) + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if g.user is None: + flash('Для выполнения данного действия необходимо пройти процедуру аутентификации', 'warning') + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated + + +def role_required(*roles): + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + if g.user is None: + flash('Для выполнения данного действия необходимо пройти процедуру аутентификации', 'warning') + return redirect(url_for('login')) + if g.user['role_name'] not in roles: + flash('У вас недостаточно прав для выполнения данного действия', 'danger') + return redirect(url_for('index')) + return f(*args, **kwargs) + return decorated + return decorator + + + +def save_cover(file, book_id): + data = file.read() + md5 = hashlib.md5(data).hexdigest() + mime = file.mimetype + db = get_db() + existing = db.execute('SELECT id, filename FROM covers WHERE md5_hash=?', (md5,)).fetchone() + if existing: + db.execute('UPDATE books SET cover_id=? WHERE id=?', (existing['id'], book_id)) + return existing['id'] + cursor = db.execute( + 'INSERT INTO covers (filename, mime_type, md5_hash) VALUES (?, ?, ?)', + ('', mime, md5) + ) + cover_id = cursor.lastrowid + ext = os.path.splitext(file.filename)[1] or '.jpg' + filename = f'{cover_id}{ext}' + db.execute('UPDATE covers SET filename=? WHERE id=?', (filename, cover_id)) + db.execute('UPDATE books SET cover_id=? WHERE id=?', (cover_id, book_id)) + path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + with open(path, 'wb') as fh: + fh.write(data) + return cover_id + + +def delete_cover_file(cover_id): + db = get_db() + cover = db.execute('SELECT filename FROM covers WHERE id=?', (cover_id,)).fetchone() + if cover: + path = os.path.join(app.config['UPLOAD_FOLDER'], cover['filename']) + if os.path.exists(path): + os.remove(path) + + + +@app.route('/') +def index(): + db = get_db() + page = request.args.get('page', 1, type=int) + + q_title = request.args.get('title', '').strip() + q_genres = request.args.getlist('genres', type=int) + q_years = request.args.getlist('years', type=int) + q_pages_from = request.args.get('pages_from', '').strip() + q_pages_to = request.args.get('pages_to', '').strip() + q_author = request.args.get('author', '').strip() + + conditions = [] + params = [] + + if q_title: + conditions.append('b.title LIKE ?') + params.append(f'%{q_title}%') + if q_author: + conditions.append('b.author LIKE ?') + params.append(f'%{q_author}%') + if q_pages_from: + conditions.append('b.pages >= ?') + params.append(int(q_pages_from)) + if q_pages_to: + conditions.append('b.pages <= ?') + params.append(int(q_pages_to)) + if q_years: + placeholders = ','.join('?' * len(q_years)) + conditions.append(f'b.year IN ({placeholders})') + params.extend(q_years) + if q_genres: + placeholders = ','.join('?' * len(q_genres)) + conditions.append( + f'b.id IN (SELECT book_id FROM book_genres WHERE genre_id IN ({placeholders}))' + ) + params.extend(q_genres) + + where = ('WHERE ' + ' AND '.join(conditions)) if conditions else '' + + total = db.execute( + f'SELECT COUNT(DISTINCT b.id) FROM books b {where}', params + ).fetchone()[0] + + books = db.execute( + f'''SELECT b.*, + c.filename as cover_filename, + (SELECT GROUP_CONCAT(g.name, ", ") FROM genres g + JOIN book_genres bg ON bg.genre_id = g.id WHERE bg.book_id = b.id) as genres, + (SELECT ROUND(AVG(rating), 1) FROM reviews WHERE book_id = b.id) as avg_rating, + (SELECT COUNT(*) FROM reviews WHERE book_id = b.id) as review_count + FROM books b + LEFT JOIN covers c ON b.cover_id = c.id + {where} + ORDER BY b.year DESC + LIMIT ? OFFSET ?''', + params + [app.config['PER_PAGE'], (page - 1) * app.config['PER_PAGE']] + ).fetchall() + + all_genres = db.execute('SELECT * FROM genres ORDER BY name').fetchall() + all_years = db.execute('SELECT DISTINCT year FROM books ORDER BY year DESC').fetchall() + total_pages = (total + app.config['PER_PAGE'] - 1) // app.config['PER_PAGE'] + + return render_template('index.html', + books=books, page=page, total_pages=total_pages, + all_genres=all_genres, all_years=all_years, + q_title=q_title, q_genres=q_genres, q_years=q_years, + q_pages_from=q_pages_from, q_pages_to=q_pages_to, + q_author=q_author) + + +@app.route('/book/') +def book_view(book_id): + db = get_db() + book = db.execute( + '''SELECT b.*, c.filename as cover_filename, + (SELECT GROUP_CONCAT(g.name, ", ") FROM genres g + JOIN book_genres bg ON bg.genre_id = g.id WHERE bg.book_id = b.id) as genres, + (SELECT ROUND(AVG(rating), 1) FROM reviews WHERE book_id = b.id) as avg_rating, + (SELECT COUNT(*) FROM reviews WHERE book_id = b.id) as review_count + FROM books b + LEFT JOIN covers c ON b.cover_id = c.id + WHERE b.id = ?''', + (book_id,) + ).fetchone() + if not book: + flash('Книга не найдена', 'danger') + return redirect(url_for('index')) + + reviews = db.execute( + '''SELECT r.*, u.first_name, u.last_name, u.middle_name + FROM reviews r JOIN users u ON r.user_id = u.id + WHERE r.book_id = ? ORDER BY r.created_at DESC''', + (book_id,) + ).fetchall() + + user_review = None + if g.user: + user_review = db.execute( + 'SELECT * FROM reviews WHERE book_id=? AND user_id=?', + (book_id, g.user['id']) + ).fetchone() + + desc_html = bleach.clean( + markdown.markdown(book['description']), + tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS + ) + + return render_template('book_view.html', book=book, reviews=reviews, + user_review=user_review, desc_html=desc_html, + rating_labels=RATING_LABELS) + + +@app.route('/book/add', methods=['GET', 'POST']) +@role_required('администратор') +def book_add(): + db = get_db() + genres = db.execute('SELECT * FROM genres ORDER BY name').fetchall() + + if request.method == 'POST': + title = request.form.get('title', '').strip() + description = bleach.clean( + markdown.markdown(request.form.get('description', '')), + tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS + ) + year = request.form.get('year', '').strip() + publisher = request.form.get('publisher', '').strip() + author = request.form.get('author', '').strip() + pages = request.form.get('pages', '').strip() + selected_genres = request.form.getlist('genres', type=int) + cover_file = request.files.get('cover') + + if not all([title, year, publisher, author, pages, cover_file and cover_file.filename]): + flash('При сохранении данных возникла ошибка. Проверьте корректность введённых данных.', 'danger') + return render_template('book_form.html', genres=genres, book=request.form, + selected_genres=selected_genres, is_edit=False) + try: + cursor = db.execute( + 'INSERT INTO books (title, description, year, publisher, author, pages) VALUES (?,?,?,?,?,?)', + (title, description, int(year), publisher, author, int(pages)) + ) + book_id = cursor.lastrowid + for gid in selected_genres: + db.execute('INSERT INTO book_genres (book_id, genre_id) VALUES (?,?)', (book_id, gid)) + save_cover(cover_file, book_id) + db.commit() + return redirect(url_for('book_view', book_id=book_id)) + except Exception: + db.rollback() + flash('При сохранении данных возникла ошибка. Проверьте корректность введённых данных.', 'danger') + return render_template('book_form.html', genres=genres, book=request.form, + selected_genres=selected_genres, is_edit=False) + + return render_template('book_form.html', genres=genres, book=None, + selected_genres=[], is_edit=False) + + +@app.route('/book//edit', methods=['GET', 'POST']) +@role_required('администратор', 'модератор') +def book_edit(book_id): + db = get_db() + book = db.execute( + 'SELECT b.*, c.filename as cover_filename FROM books b LEFT JOIN covers c ON b.cover_id=c.id WHERE b.id=?', + (book_id,) + ).fetchone() + if not book: + flash('Книга не найдена', 'danger') + return redirect(url_for('index')) + genres = db.execute('SELECT * FROM genres ORDER BY name').fetchall() + selected_genres = [row['genre_id'] for row in + db.execute('SELECT genre_id FROM book_genres WHERE book_id=?', (book_id,)).fetchall()] + current_cover = book['cover_filename'] + + if request.method == 'POST': + title = request.form.get('title', '').strip() + description = bleach.clean( + markdown.markdown(request.form.get('description', '')), + tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS + ) + year = request.form.get('year', '').strip() + publisher = request.form.get('publisher', '').strip() + author = request.form.get('author', '').strip() + pages = request.form.get('pages', '').strip() + new_genres = request.form.getlist('genres', type=int) + cover_file = request.files.get('cover') + + if not all([title, year, publisher, author, pages]): + flash('При сохранении данных возникла ошибка. Проверьте корректность введённых данных.', 'danger') + return render_template('book_form.html', genres=genres, book=request.form, + selected_genres=new_genres, is_edit=True, book_id=book_id, + current_cover=current_cover) + try: + db.execute( + 'UPDATE books SET title=?, description=?, year=?, publisher=?, author=?, pages=? WHERE id=?', + (title, description, int(year), publisher, author, int(pages), book_id) + ) + db.execute('DELETE FROM book_genres WHERE book_id=?', (book_id,)) + for gid in new_genres: + db.execute('INSERT INTO book_genres (book_id, genre_id) VALUES (?,?)', (book_id, gid)) + if cover_file and cover_file.filename: + save_cover(cover_file, book_id) + db.commit() + return redirect(url_for('book_view', book_id=book_id)) + except Exception: + db.rollback() + flash('При сохранении данных возникла ошибка. Проверьте корректность введённых данных.', 'danger') + return render_template('book_form.html', genres=genres, book=request.form, + selected_genres=new_genres, is_edit=True, book_id=book_id, + current_cover=current_cover) + + return render_template('book_form.html', genres=genres, book=book, + selected_genres=selected_genres, is_edit=True, book_id=book_id, + current_cover=current_cover) + + +@app.route('/book//delete', methods=['POST']) +@role_required('администратор') +def book_delete(book_id): + db = get_db() + book = db.execute('SELECT * FROM books WHERE id=?', (book_id,)).fetchone() + if not book: + flash('Книга не найдена', 'danger') + return redirect(url_for('index')) + cover_id = book['cover_id'] + try: + db.execute('DELETE FROM books WHERE id=?', (book_id,)) + db.commit() + if cover_id: + remaining = db.execute('SELECT COUNT(*) FROM books WHERE cover_id=?', (cover_id,)).fetchone()[0] + if remaining == 0: + delete_cover_file(cover_id) + db.execute('DELETE FROM covers WHERE id=?', (cover_id,)) + db.commit() + flash(f'Книга «{book["title"]}» успешно удалена', 'success') + except Exception: + db.rollback() + flash('Ошибка при удалении книги', 'danger') + return redirect(url_for('index')) + + +@app.route('/book//review', methods=['GET', 'POST']) +@login_required +def review_add(book_id): + db = get_db() + book = db.execute('SELECT * FROM books WHERE id=?', (book_id,)).fetchone() + if not book: + return redirect(url_for('index')) + + existing = db.execute( + 'SELECT id FROM reviews WHERE book_id=? AND user_id=?', + (book_id, g.user['id']) + ).fetchone() + if existing: + return redirect(url_for('book_view', book_id=book_id)) + + if request.method == 'POST': + rating = request.form.get('rating', type=int) + raw_text = request.form.get('text', '').strip() + if rating is None or not raw_text: + flash('Заполните все поля', 'danger') + return render_template('review_form.html', book=book, + rating_labels=RATING_LABELS, form=request.form) + text = bleach.clean( + markdown.markdown(raw_text), + tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS + ) + try: + db.execute( + 'INSERT INTO reviews (book_id, user_id, rating, text) VALUES (?,?,?,?)', + (book_id, g.user['id'], rating, text) + ) + db.commit() + return redirect(url_for('book_view', book_id=book_id)) + except Exception: + db.rollback() + flash('Ошибка при сохранении рецензии', 'danger') + return render_template('review_form.html', book=book, + rating_labels=RATING_LABELS, form=request.form) + + return render_template('review_form.html', book=book, + rating_labels=RATING_LABELS, form={}) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if g.user: + return redirect(url_for('index')) + if request.method == 'POST': + login_val = request.form.get('login', '').strip() + password = request.form.get('password', '') + remember = request.form.get('remember') + db = get_db() + user = db.execute('SELECT * FROM users WHERE login=?', (login_val,)).fetchone() + if user and check_password_hash(user['password_hash'], password): + session.clear() + session['user_id'] = user['id'] + if remember: + session.permanent = True + return redirect(url_for('index')) + flash('Невозможно аутентифицироваться с указанными логином и паролем', 'danger') + return render_template('login.html') + + +@app.route('/logout') +def logout(): + next_url = request.referrer or url_for('index') + session.clear() + return redirect(next_url) + + +@app.route('/uploads/') +def uploaded_file(filename): + return send_from_directory(app.config['UPLOAD_FOLDER'], filename) + + +@app.context_processor +def inject_user(): + return dict(current_user=g.user) diff --git a/ex/app/static/uploads/10.svg b/ex/app/static/uploads/10.svg new file mode 100644 index 0000000..9e3df03 --- /dev/null +++ b/ex/app/static/uploads/10.svg @@ -0,0 +1,5 @@ + + + КР + \ No newline at end of file diff --git a/ex/app/static/uploads/11.svg b/ex/app/static/uploads/11.svg new file mode 100644 index 0000000..2dd3eb0 --- /dev/null +++ b/ex/app/static/uploads/11.svg @@ -0,0 +1,5 @@ + + + ГР + \ No newline at end of file diff --git a/ex/app/static/uploads/12.svg b/ex/app/static/uploads/12.svg new file mode 100644 index 0000000..0ad446c --- /dev/null +++ b/ex/app/static/uploads/12.svg @@ -0,0 +1,5 @@ + + + ВО + \ No newline at end of file diff --git a/ex/app/static/uploads/13.svg b/ex/app/static/uploads/13.svg new file mode 100644 index 0000000..fc4d0c1 --- /dev/null +++ b/ex/app/static/uploads/13.svg @@ -0,0 +1,5 @@ + + + АВ + \ No newline at end of file diff --git a/ex/app/static/uploads/14.jpg b/ex/app/static/uploads/14.jpg new file mode 100644 index 0000000..9a36526 Binary files /dev/null and b/ex/app/static/uploads/14.jpg differ diff --git a/ex/app/static/uploads/15.jpg b/ex/app/static/uploads/15.jpg new file mode 100644 index 0000000..aadfd91 Binary files /dev/null and b/ex/app/static/uploads/15.jpg differ diff --git a/ex/app/static/uploads/16.jpg b/ex/app/static/uploads/16.jpg new file mode 100644 index 0000000..7e23ed0 Binary files /dev/null and b/ex/app/static/uploads/16.jpg differ diff --git a/ex/app/static/uploads/17.jpg b/ex/app/static/uploads/17.jpg new file mode 100644 index 0000000..8e1ea80 Binary files /dev/null and b/ex/app/static/uploads/17.jpg differ diff --git a/ex/app/static/uploads/18.jpg b/ex/app/static/uploads/18.jpg new file mode 100644 index 0000000..d96f52f Binary files /dev/null and b/ex/app/static/uploads/18.jpg differ diff --git a/ex/app/static/uploads/19.jpg b/ex/app/static/uploads/19.jpg new file mode 100644 index 0000000..6c8bb6c Binary files /dev/null and b/ex/app/static/uploads/19.jpg differ diff --git a/ex/app/static/uploads/2.svg b/ex/app/static/uploads/2.svg new file mode 100644 index 0000000..0e669e3 --- /dev/null +++ b/ex/app/static/uploads/2.svg @@ -0,0 +1,5 @@ + + + МА + \ No newline at end of file diff --git a/ex/app/static/uploads/20.jpg b/ex/app/static/uploads/20.jpg new file mode 100644 index 0000000..2eb98b0 Binary files /dev/null and b/ex/app/static/uploads/20.jpg differ diff --git a/ex/app/static/uploads/21.jpg b/ex/app/static/uploads/21.jpg new file mode 100644 index 0000000..4d7b196 Binary files /dev/null and b/ex/app/static/uploads/21.jpg differ diff --git a/ex/app/static/uploads/22.jpg b/ex/app/static/uploads/22.jpg new file mode 100644 index 0000000..d548d3e Binary files /dev/null and b/ex/app/static/uploads/22.jpg differ diff --git a/ex/app/static/uploads/23.jpg b/ex/app/static/uploads/23.jpg new file mode 100644 index 0000000..f22787a Binary files /dev/null and b/ex/app/static/uploads/23.jpg differ diff --git a/ex/app/static/uploads/24.jpg b/ex/app/static/uploads/24.jpg new file mode 100644 index 0000000..5e0a837 Binary files /dev/null and b/ex/app/static/uploads/24.jpg differ diff --git a/ex/app/static/uploads/25.jpg b/ex/app/static/uploads/25.jpg new file mode 100644 index 0000000..6cfe731 Binary files /dev/null and b/ex/app/static/uploads/25.jpg differ diff --git a/ex/app/static/uploads/26.jpg b/ex/app/static/uploads/26.jpg new file mode 100644 index 0000000..fe3b8ab Binary files /dev/null and b/ex/app/static/uploads/26.jpg differ diff --git a/ex/app/static/uploads/27.jpg b/ex/app/static/uploads/27.jpg new file mode 100644 index 0000000..06656c0 Binary files /dev/null and b/ex/app/static/uploads/27.jpg differ diff --git a/ex/app/static/uploads/28.jpg b/ex/app/static/uploads/28.jpg new file mode 100644 index 0000000..03f3f67 Binary files /dev/null and b/ex/app/static/uploads/28.jpg differ diff --git a/ex/app/static/uploads/29.jpg b/ex/app/static/uploads/29.jpg new file mode 100644 index 0000000..19ca6fc Binary files /dev/null and b/ex/app/static/uploads/29.jpg differ diff --git a/ex/app/static/uploads/3.svg b/ex/app/static/uploads/3.svg new file mode 100644 index 0000000..6106c35 --- /dev/null +++ b/ex/app/static/uploads/3.svg @@ -0,0 +1,5 @@ + + + 19 + \ No newline at end of file diff --git a/ex/app/static/uploads/30.jpg b/ex/app/static/uploads/30.jpg new file mode 100644 index 0000000..c5be7d8 Binary files /dev/null and b/ex/app/static/uploads/30.jpg differ diff --git a/ex/app/static/uploads/31.jpg b/ex/app/static/uploads/31.jpg new file mode 100644 index 0000000..cafbc67 Binary files /dev/null and b/ex/app/static/uploads/31.jpg differ diff --git a/ex/app/static/uploads/32.jpg b/ex/app/static/uploads/32.jpg new file mode 100644 index 0000000..a0c8cd2 Binary files /dev/null and b/ex/app/static/uploads/32.jpg differ diff --git a/ex/app/static/uploads/4.svg b/ex/app/static/uploads/4.svg new file mode 100644 index 0000000..4028bf4 --- /dev/null +++ b/ex/app/static/uploads/4.svg @@ -0,0 +1,5 @@ + + + ПР + \ No newline at end of file diff --git a/ex/app/static/uploads/5.svg b/ex/app/static/uploads/5.svg new file mode 100644 index 0000000..436e23e --- /dev/null +++ b/ex/app/static/uploads/5.svg @@ -0,0 +1,5 @@ + + + ДЮ + \ No newline at end of file diff --git a/ex/app/static/uploads/6.svg b/ex/app/static/uploads/6.svg new file mode 100644 index 0000000..c461ad9 --- /dev/null +++ b/ex/app/static/uploads/6.svg @@ -0,0 +1,5 @@ + + + ГА + \ No newline at end of file diff --git a/ex/app/static/uploads/7.svg b/ex/app/static/uploads/7.svg new file mode 100644 index 0000000..05348ab --- /dev/null +++ b/ex/app/static/uploads/7.svg @@ -0,0 +1,5 @@ + + + ТР + \ No newline at end of file diff --git a/ex/app/static/uploads/8.svg b/ex/app/static/uploads/8.svg new file mode 100644 index 0000000..cc4ffbb --- /dev/null +++ b/ex/app/static/uploads/8.svg @@ -0,0 +1,5 @@ + + + ШЕ + \ No newline at end of file diff --git a/ex/app/static/uploads/9.svg b/ex/app/static/uploads/9.svg new file mode 100644 index 0000000..0260be2 --- /dev/null +++ b/ex/app/static/uploads/9.svg @@ -0,0 +1,5 @@ + + + ВЛ + \ No newline at end of file diff --git a/ex/app/templates/base.html b/ex/app/templates/base.html new file mode 100644 index 0000000..c7f9a5c --- /dev/null +++ b/ex/app/templates/base.html @@ -0,0 +1,78 @@ + + + + + + {% block title %}Электронная библиотека{% endblock %} + + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+
+ Группа 241-327 — Деев Егор Викторович +
+
+ + + +{% block scripts %}{% endblock %} + + diff --git a/ex/app/templates/book_form.html b/ex/app/templates/book_form.html new file mode 100644 index 0000000..fb367dc --- /dev/null +++ b/ex/app/templates/book_form.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} +{% from 'macros.html' import book_form_fields %} +{% block title %}{% if is_edit %}Редактирование книги{% else %}Добавление книги{% endif %} — Электронная библиотека{% endblock %} + +{% block content %} +

{% if is_edit %}Редактирование книги{% else %}Добавление книги{% endif %}

+ +
+ {{ book_form_fields(book, genres, selected_genres, is_edit, current_cover) }} + +
+ + Отмена +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/ex/app/templates/book_view.html b/ex/app/templates/book_view.html new file mode 100644 index 0000000..ee3d3bb --- /dev/null +++ b/ex/app/templates/book_view.html @@ -0,0 +1,126 @@ +{% extends 'base.html' %} +{% from 'macros.html' import rating_badge %} +{% block title %}{{ book.title }} — Электронная библиотека{% endblock %} + +{% block content %} +
+
+ {% if book.cover_filename %} + обложка + {% else %} +
+ +
+ {% endif %} +
+ +
+

{{ book.title }}

+ + + + + + + + + + +
Автор{{ book.author }}
Издательство{{ book.publisher }}
Год{{ book.year }}
Жанр(ы){{ book.genres or '—' }}
Объём{{ book.pages }} стр.
Средняя оценка + {% if book.avg_rating is not none %} + {{ rating_badge(book.avg_rating) }} + ({{ book.review_count }} рец.) + {% else %} + Нет оценок + {% endif %} +
+ + {% if current_user and current_user.role_name in ['администратор', 'модератор'] %} + + Редактировать + + {% endif %} + {% if current_user and current_user.role_name == 'администратор' %} + + {% endif %} +
+
+ +
+

Описание

+
{{ desc_html | safe }}
+ +
+

Рецензии

+ +{% if reviews %} + {% for review in reviews %} +
+
+
+ {{ review.last_name }} {{ review.first_name }} + {% if review.middle_name %} {{ review.middle_name }}{% endif %} + + {{ rating_badge(review.rating) }} + {{ rating_labels[review.rating] }} +
+ {{ review.created_at }} +
{{ review.text | safe }}
+
+
+ {% endfor %} +{% else %} +

Рецензий пока нет.

+{% endif %} + +{% if current_user %} + {% if user_review %} +
Вы уже оставили рецензию на эту книгу.
+ {% else %} + + Написать рецензию + + {% endif %} +{% endif %} + +{# ── Модальное окно удаления ──────────────────────────────────────────────── #} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/ex/app/templates/index.html b/ex/app/templates/index.html new file mode 100644 index 0000000..3cbf9b6 --- /dev/null +++ b/ex/app/templates/index.html @@ -0,0 +1,199 @@ +{% extends 'base.html' %} +{% from 'macros.html' import rating_badge %} +{% block title %}Главная — Электронная библиотека{% endblock %} + +{% block content %} +

Каталог книг

+ +{# ── Форма поиска (Вариант 3) ────────────────────────────────────────────── #} +
+
+ Поиск книг +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Ctrl — несколько жанров
+
+
+ + +
Ctrl — несколько лет
+
+
+
+ + Сбросить +
+
+
+
+ +{# ── Список книг ─────────────────────────────────────────────────────────── #} +{% if books %} +
+ + + + + + + + + + + + + + {% for book in books %} + + + + + + + + + + {% endfor %} + +
ОбложкаНазваниеЖанр(ы)ГодСредняя оценкаРецензийДействия
+ {% if book.cover_filename %} + обложка + {% else %} +
+ +
+ {% endif %} +
{{ book.title }}
+ {{ book.author }} +
{{ book.genres or '—' }}{{ book.year }} + {% if book.avg_rating is not none %} + {{ rating_badge(book.avg_rating) }} + {% else %} + + {% endif %} + {{ book.review_count }} + + + + {% if current_user and current_user.role_name in ['администратор', 'модератор'] %} + + + + {% endif %} + {% if current_user and current_user.role_name == 'администратор' %} + + {% endif %} +
+
+ +{# ── Пагинация ────────────────────────────────────────────────────────────── #} +{% if total_pages > 1 %} + +{% endif %} + +{% else %} +
Книги не найдены
+{% endif %} + +{% if current_user and current_user.role_name == 'администратор' %} + +{% endif %} + +{# ── Модальное окно удаления ──────────────────────────────────────────────── #} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/ex/app/templates/login.html b/ex/app/templates/login.html new file mode 100644 index 0000000..c7cae1f --- /dev/null +++ b/ex/app/templates/login.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} +{% block title %}Вход — Электронная библиотека{% endblock %} + +{% block content %} +
+
+

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

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+{% endblock %} diff --git a/ex/app/templates/macros.html b/ex/app/templates/macros.html new file mode 100644 index 0000000..6f42dcb --- /dev/null +++ b/ex/app/templates/macros.html @@ -0,0 +1,78 @@ +{% macro rating_badge(value) %} +{% if value is not none %} + {% set v = value | int %} + {% if v == 5 %}{% set color = 'success' %} + {% elif v == 4 %}{% set color = 'info' %} + {% elif v == 3 %}{% set color = 'warning' %} + {% elif v == 2 %}{% set color = 'orange' %} + {% else %}{% set color = 'danger' %} + {% endif %} + {{ value }} +{% endif %} +{% endmacro %} + +{% macro book_form_fields(book, genres, selected_genres, is_edit, current_cover=none) %} +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
Удерживайте Ctrl для выбора нескольких жанров
+
+
+ + +
+{% if not is_edit %} +
+ + +
+{% else %} +
+ + {% if current_cover %} +
+ текущая обложка +
Текущая обложка. Загрузите новый файл чтобы заменить.
+
+ {% else %} +
Обложка не загружена.
+ {% endif %} + +
+{% endif %} +{% endmacro %} diff --git a/ex/app/templates/review_form.html b/ex/app/templates/review_form.html new file mode 100644 index 0000000..aeaf0ac --- /dev/null +++ b/ex/app/templates/review_form.html @@ -0,0 +1,45 @@ +{% extends 'base.html' %} +{% block title %}Написать рецензию — Электронная библиотека{% endblock %} + +{% block content %} +

Рецензия на книгу «{{ book.title }}»

+ +
+
+ + +
+
+ + +
+
+ + Отмена +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/ex/db/db_fill.py b/ex/db/db_fill.py new file mode 100644 index 0000000..2329c2d --- /dev/null +++ b/ex/db/db_fill.py @@ -0,0 +1,226 @@ +import sqlite3 +import os + +DB = os.path.join(os.path.dirname(__file__), 'library.db') + +BOOKS = [ + { + 'title': 'Мастер и Маргарита', + 'description': 'Роман о визите дьявола в советскую Москву и трагической любви Мастера и Маргариты.', + 'year': 1967, + 'publisher': 'YMCA-Press', + 'author': 'Михаил Булгаков', + 'pages': 480, + 'genres': ['Роман', 'Фантастика'], + }, + { + 'title': '1984', + 'description': 'Антиутопия о тоталитарном обществе, где Большой Брат следит за каждым.', + 'year': 1949, + 'publisher': 'Secker & Warburg', + 'author': 'Джордж Оруэлл', + 'pages': 328, + 'genres': ['Фантастика'], + }, + { + 'title': 'Преступление и наказание', + 'description': 'Психологический роман о студенте Раскольникове, совершившем убийство и его последствиях.', + 'year': 1866, + 'publisher': 'Русский вестник', + 'author': 'Фёдор Достоевский', + 'pages': 592, + 'genres': ['Роман', 'Детектив'], + }, + { + 'title': 'Дюна', + 'description': 'Эпическая сага о пустынной планете Арракис и судьбе юного Пола Атрейдеса.', + 'year': 1965, + 'publisher': 'Chilton Books', + 'author': 'Фрэнк Герберт', + 'pages': 688, + 'genres': ['Фантастика', 'Приключения'], + }, + { + 'title': 'Гарри Поттер и философский камень', + 'description': 'Первая книга о мальчике-волшебнике, который поступает в школу магии Хогвартс.', + 'year': 1997, + 'publisher': 'Bloomsbury', + 'author': 'Джоан Роулинг', + 'pages': 223, + 'genres': ['Фэнтези', 'Приключения'], + }, + { + 'title': 'Три товарища', + 'description': 'История дружбы трёх молодых людей в послевоенной Германии и трагической любви.', + 'year': 1936, + 'publisher': 'Querido Verlag', + 'author': 'Эрих Мария Ремарк', + 'pages': 432, + 'genres': ['Роман'], + }, + { + 'title': 'Шерлок Холмс. Этюд в багровых тонах', + 'description': 'Первое появление великого сыщика Шерлока Холмса и его помощника доктора Ватсона.', + 'year': 1887, + 'publisher': 'Ward Lock & Co', + 'author': 'Артур Конан Дойл', + 'pages': 112, + 'genres': ['Детектив'], + }, + { + 'title': 'Властелин колец', + 'description': 'Эпическое фэнтези о хоббите Фродо, которому предстоит уничтожить Кольцо всевластья.', + 'year': 1954, + 'publisher': 'Allen & Unwin', + 'author': 'Джон Толкин', + 'pages': 1178, + 'genres': ['Фэнтези', 'Приключения'], + }, + { + 'title': 'Краткая история времени', + 'description': 'Научно-популярная книга о происхождении вселенной, чёрных дырах и природе времени.', + 'year': 1988, + 'publisher': 'Bantam Books', + 'author': 'Стивен Хокинг', + 'pages': 212, + 'genres': ['Научная литература'], + }, + { + 'title': 'Граф Монте-Кристо', + 'description': 'Приключенческий роман о моряке Эдмоне Дантесе, несправедливо заключённом в тюрьму.', + 'year': 1846, + 'publisher': 'Pétion', + 'author': 'Александр Дюма', + 'pages': 1276, + 'genres': ['Приключения', 'Исторический'], + }, + { + 'title': 'Война и мир', + 'description': 'Эпопея об эпохе наполеоновских войн глазами нескольких дворянских семей.', + 'year': 1869, + 'publisher': 'Русский вестник', + 'author': 'Лев Толстой', + 'pages': 1274, + 'genres': ['Роман', 'Исторический'], + }, + { + 'title': 'Автостопом по галактике', + 'description': 'Комедийная фантастика о землянине Артуре Денте, путешествующем по космосу.', + 'year': 1979, + 'publisher': 'Pan Books', + 'author': 'Дуглас Адамс', + 'pages': 193, + 'genres': ['Фантастика'], + }, + { + 'title': 'Космос', + 'description': 'Научно-популярная книга об астрономии, эволюции звёзд и месте человека во вселенной.', + 'year': 1980, + 'publisher': 'Random House', + 'author': 'Карл Саган', + 'pages': 365, + 'genres': ['Научная литература'], + }, + { + 'title': 'Стивен Хокинг. Теория всего', + 'description': 'Биография выдающегося физика и обзор его главных научных идей.', + 'year': 2002, + 'publisher': 'New Millennium Press', + 'author': 'Стивен Хокинг', + 'pages': 176, + 'genres': ['Биография', 'Научная литература'], + }, + { + 'title': 'Я, Клавдий', + 'description': 'Автобиографический роман от лица римского императора Клавдия об интригах двора.', + 'year': 1934, + 'publisher': 'Arthur Barker', + 'author': 'Роберт Грейвс', + 'pages': 468, + 'genres': ['Биография', 'Исторический'], + }, + { + 'title': 'Сияние', + 'description': 'Психологический хоррор о писателе Джеке Торрансе и отеле "Оверлук", сводящем с ума.', + 'year': 1977, + 'publisher': 'Doubleday', + 'author': 'Стивен Кинг', + 'pages': 447, + 'genres': ['Ужасы'], + }, + { + 'title': 'Оно', + 'description': 'История о группе детей из Дерри, противостоящих древнему злу в образе клоуна Пеннивайза.', + 'year': 1986, + 'publisher': 'Viking Press', + 'author': 'Стивен Кинг', + 'pages': 1138, + 'genres': ['Ужасы'], + }, + { + 'title': 'Евгений Онегин', + 'description': 'Роман в стихах о судьбе петербургского денди Онегина и его несостоявшейся любви к Татьяне.', + 'year': 1833, + 'publisher': 'Александр Смирдин', + 'author': 'Александр Пушкин', + 'pages': 224, + 'genres': ['Поэзия', 'Роман'], + }, + { + 'title': 'Мёртвые души', + 'description': 'Поэма в прозе о похождениях Чичикова, скупающего «мёртвые души» крепостных.', + 'year': 1842, + 'publisher': 'Университетская типография', + 'author': 'Николай Гоголь', + 'pages': 352, + 'genres': ['Поэзия'], + }, +] + + +def main(): + conn = sqlite3.connect(DB) + conn.row_factory = sqlite3.Row + conn.execute('PRAGMA foreign_keys = ON') + + added = 0 + for book_data in BOOKS: + existing = conn.execute('SELECT id FROM books WHERE title=?', (book_data['title'],)).fetchone() + if existing: + print(f' пропуск (уже есть): {book_data["title"]}') + continue + + cursor = conn.execute( + 'INSERT INTO books (title, description, year, publisher, author, pages) VALUES (?,?,?,?,?,?)', + (book_data['title'], book_data['description'], book_data['year'], + book_data['publisher'], book_data['author'], book_data['pages']) + ) + book_id = cursor.lastrowid + + for genre_name in book_data['genres']: + genre = conn.execute('SELECT id FROM genres WHERE name=?', (genre_name,)).fetchone() + if genre: + conn.execute('INSERT OR IGNORE INTO book_genres (book_id, genre_id) VALUES (?,?)', + (book_id, genre['id'])) + + conn.commit() + added += 1 + print(f' добавлено: {book_data["title"]}') + + print(f'\nГотово. Добавлено новых книг: {added}') + + print('\nКниг по жанрам:') + rows = conn.execute(''' + SELECT g.name, COUNT(bg.book_id) as cnt + FROM genres g + LEFT JOIN book_genres bg ON g.id = bg.genre_id + GROUP BY g.id ORDER BY g.name + ''').fetchall() + for r in rows: + print(f' {r[0]}: {r[1]}') + + conn.close() + + +if __name__ == '__main__': + main() diff --git a/ex/db/db_seed.py b/ex/db/db_seed.py new file mode 100644 index 0000000..95ae1eb --- /dev/null +++ b/ex/db/db_seed.py @@ -0,0 +1,64 @@ +""" +Инициализация БД: создание таблиц, ролей, жанров и тестовых пользователей. +Запускать: python db_seed.py +""" +import sqlite3 +import os +from werkzeug.security import generate_password_hash + +DB = os.path.join(os.path.dirname(__file__), 'library.db') +SCHEMA = os.path.join(os.path.dirname(__file__), 'schema.sql') + + +def init(): + conn = sqlite3.connect(DB) + conn.row_factory = sqlite3.Row + conn.execute('PRAGMA foreign_keys = ON') + + with open(SCHEMA, encoding='utf-8') as f: + conn.executescript(f.read()) + + # Роли + roles = [ + ('администратор', 'Суперпользователь, полный доступ'), + ('модератор', 'Может редактировать книги и модерировать рецензии'), + ('пользователь', 'Может оставлять рецензии'), + ] + for name, desc in roles: + conn.execute( + 'INSERT OR IGNORE INTO roles (name, description) VALUES (?,?)', + (name, desc) + ) + + # Жанры + genres = [ + 'Фантастика', 'Фэнтези', 'Детектив', 'Роман', 'Приключения', + 'Ужасы', 'Исторический', 'Научная литература', 'Биография', 'Поэзия' + ] + for g in genres: + conn.execute('INSERT OR IGNORE INTO genres (name) VALUES (?)', (g,)) + + # Тестовые пользователи + users = [ + ('admin', 'admin123', 'Деев', 'Егор', 'Викторович', 'администратор'), + ('moder', 'moder123', 'Иванов', 'Иван', 'Иванович', 'модератор'), + ('user1', 'user123', 'Петров', 'Пётр', None, 'пользователь'), + ] + for login, pwd, last, first, middle, role_name in users: + role = conn.execute('SELECT id FROM roles WHERE name=?', (role_name,)).fetchone() + if role: + conn.execute( + '''INSERT OR IGNORE INTO users + (login, password_hash, last_name, first_name, middle_name, role_id) + VALUES (?,?,?,?,?,?)''', + (login, generate_password_hash(pwd), last, first, middle, role['id']) + ) + + conn.commit() + conn.close() + print('БД инициализирована.') + print('Логины: admin/admin123, moder/moder123, user1/user123') + + +if __name__ == '__main__': + init() diff --git a/ex/db/library.db b/ex/db/library.db new file mode 100644 index 0000000..f20d5f7 Binary files /dev/null and b/ex/db/library.db differ diff --git a/ex/db/schema.sql b/ex/db/schema.sql new file mode 100644 index 0000000..7045b5f --- /dev/null +++ b/ex/db/schema.sql @@ -0,0 +1,61 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(50) NOT NULL, + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + last_name VARCHAR(100) NOT NULL, + first_name VARCHAR(100) NOT NULL, + middle_name VARCHAR(100), + role_id INTEGER NOT NULL, + FOREIGN KEY (role_id) REFERENCES roles(id) +); + +CREATE TABLE IF NOT EXISTS genres ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS covers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename VARCHAR(255) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + md5_hash VARCHAR(32) NOT NULL +); + +CREATE TABLE IF NOT EXISTS books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + year INTEGER NOT NULL, + publisher VARCHAR(255) NOT NULL, + author VARCHAR(255) NOT NULL, + pages INTEGER NOT NULL, + cover_id INTEGER, + FOREIGN KEY (cover_id) REFERENCES covers(id) +); + +CREATE TABLE IF NOT EXISTS book_genres ( + book_id INTEGER NOT NULL, + genre_id INTEGER NOT NULL, + PRIMARY KEY (book_id, genre_id), + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE, + FOREIGN KEY (genre_id) REFERENCES genres(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + rating INTEGER NOT NULL CHECK(rating BETWEEN 0 AND 5), + text TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/ex/library.db b/ex/library.db new file mode 100644 index 0000000..e69de29 diff --git a/ex/requirements.txt b/ex/requirements.txt new file mode 100644 index 0000000..03ac265 --- /dev/null +++ b/ex/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0 +bleach>=6.1 +markdown>=3.5 +werkzeug>=3.0 diff --git a/ex/run.py b/ex/run.py new file mode 100644 index 0000000..baf4072 --- /dev/null +++ b/ex/run.py @@ -0,0 +1,11 @@ +import os +import sys +sys.dont_write_bytecode = True +from app import app, init_db + +if __name__ == '__main__': + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + with app.app_context(): + if not os.path.exists(app.config['DATABASE']): + init_db() + app.run(debug=True)