This commit is contained in:
DeevEV 2026-06-16 14:14:59 +03:00
parent 12ce86bc41
commit cf8acbff40
46 changed files with 1456 additions and 0 deletions

446
ex/app/__init__.py Normal file
View file

@ -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/<int:book_id>')
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/<int:book_id>/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/<int:book_id>/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/<int:book_id>/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/<filename>')
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)

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#1cc88a"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">КР</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#858796"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">ГР</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#e74a3b"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">ВО</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#f6c23e"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">АВ</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#e74a3b"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">МА</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#f6c23e"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">19</text>
</svg>

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#4e73df"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">ПР</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#1cc88a"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">ДЮ</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#f6c23e"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">ГА</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#e74a3b"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">ТР</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#e74a3b"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">ШЕ</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280">
<rect width="200" height="280" fill="#4e73df"/>
<text x="100" y="155" font-family="Arial" font-size="48" font-weight="bold"
fill="white" text-anchor="middle">ВЛ</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Электронная библиотека{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='14' font-size='14'>📚</text></svg>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<style>
body { padding-bottom: 60px; }
.cover-thumb { width: 60px; height: 80px; object-fit: cover; }
.cover-main { max-width: 220px; max-height: 320px; object-fit: cover; }
footer { position: fixed; bottom: 0; width: 100%; background: #f8f9fa; border-top: 1px solid #dee2e6; padding: 6px 0; font-size: .85rem; }
.bg-orange { background-color: #fd7e14 !important; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container">
<a class="navbar-brand" href="{{ url_for('index') }}">
<i class="bi bi-book"></i> Электронная библиотека
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navMain">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('index') }}">Главная</a>
</li>
</ul>
<ul class="navbar-nav">
{% if current_user %}
<li class="nav-item">
<span class="nav-link text-light">
{{ current_user['last_name'] }} {{ current_user['first_name'] }}
{% if current_user['middle_name'] %} {{ current_user['middle_name'] }}{% endif %}
</span>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('logout') }}">Выйти</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('login') }}">Войти</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<footer>
<div class="container text-center text-muted">
Группа 241-327 &mdash; Деев Егор Викторович
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% from 'macros.html' import book_form_fields %}
{% block title %}{% if is_edit %}Редактирование книги{% else %}Добавление книги{% endif %} — Электронная библиотека{% endblock %}
{% block content %}
<h2 class="mb-4">{% if is_edit %}Редактирование книги{% else %}Добавление книги{% endif %}</h2>
<form method="post" enctype="multipart/form-data">
{{ book_form_fields(book, genres, selected_genres, is_edit, current_cover) }}
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Сохранить
</button>
<a href="{{ url_for('book_view', book_id=book_id) if is_edit else url_for('index') }}"
class="btn btn-outline-secondary">Отмена</a>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
const easyMDE = new EasyMDE({
element: document.getElementById('description-editor'),
spellChecker: false,
autosave: { enabled: false }
});
document.querySelector('form').addEventListener('submit', function() {
easyMDE.codemirror.save();
});
</script>
{% endblock %}

View file

@ -0,0 +1,126 @@
{% extends 'base.html' %}
{% from 'macros.html' import rating_badge %}
{% block title %}{{ book.title }} — Электронная библиотека{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-3 mb-4">
{% if book.cover_filename %}
<img src="{{ url_for('uploaded_file', filename=book.cover_filename) }}"
class="img-fluid cover-main rounded shadow" alt="обложка">
{% else %}
<div class="bg-secondary text-white rounded d-flex align-items-center justify-content-center"
style="width:220px;height:320px;font-size:3rem;">
<i class="bi bi-book"></i>
</div>
{% endif %}
</div>
<div class="col-md-9">
<h2>{{ book.title }}</h2>
<table class="table table-borderless w-auto mb-3">
<tr><th class="pe-4">Автор</th><td>{{ book.author }}</td></tr>
<tr><th>Издательство</th><td>{{ book.publisher }}</td></tr>
<tr><th>Год</th><td>{{ book.year }}</td></tr>
<tr><th>Жанр(ы)</th><td>{{ book.genres or '—' }}</td></tr>
<tr><th>Объём</th><td>{{ book.pages }} стр.</td></tr>
<tr>
<th>Средняя оценка</th>
<td>
{% if book.avg_rating is not none %}
{{ rating_badge(book.avg_rating) }}
<small class="text-muted">({{ book.review_count }} рец.)</small>
{% else %}
<span class="text-muted">Нет оценок</span>
{% endif %}
</td>
</tr>
</table>
{% if current_user and current_user.role_name in ['администратор', 'модератор'] %}
<a href="{{ url_for('book_edit', book_id=book.id) }}" class="btn btn-outline-secondary me-2">
<i class="bi bi-pencil"></i> Редактировать
</a>
{% endif %}
{% if current_user and current_user.role_name == 'администратор' %}
<button class="btn btn-outline-danger"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-book-id="{{ book.id }}" data-book-title="{{ book.title }}">
<i class="bi bi-trash"></i> Удалить
</button>
{% endif %}
</div>
</div>
<hr>
<h4>Описание</h4>
<div class="mb-4">{{ desc_html | safe }}</div>
<hr>
<h4>Рецензии</h4>
{% if reviews %}
{% for review in reviews %}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between mb-1">
<strong>{{ review.last_name }} {{ review.first_name }}
{% if review.middle_name %} {{ review.middle_name }}{% endif %}
</strong>
{{ rating_badge(review.rating) }}
<small class="text-muted">{{ rating_labels[review.rating] }}</small>
</div>
<small class="text-muted">{{ review.created_at }}</small>
<div class="mt-2">{{ review.text | safe }}</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted">Рецензий пока нет.</p>
{% endif %}
{% if current_user %}
{% if user_review %}
<div class="alert alert-info">Вы уже оставили рецензию на эту книгу.</div>
{% else %}
<a href="{{ url_for('review_add', book_id=book.id) }}" class="btn btn-primary mt-2">
<i class="bi bi-chat-left-text"></i> Написать рецензию
</a>
{% endif %}
{% endif %}
{# ── Модальное окно удаления ──────────────────────────────────────────────── #}
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Удаление книги</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p id="deleteMessage"></p>
</div>
<div class="modal-footer">
<form id="deleteForm" method="post">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Нет</button>
<button type="submit" class="btn btn-danger">Да</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const deleteModal = document.getElementById('deleteModal');
if (deleteModal) {
deleteModal.addEventListener('show.bs.modal', function(event) {
const btn = event.relatedTarget;
document.getElementById('deleteMessage').textContent =
'Вы уверены, что хотите удалить книгу «' + btn.dataset.bookTitle + '»?';
document.getElementById('deleteForm').action = '/book/' + btn.dataset.bookId + '/delete';
});
}
</script>
{% endblock %}

199
ex/app/templates/index.html Normal file
View file

@ -0,0 +1,199 @@
{% extends 'base.html' %}
{% from 'macros.html' import rating_badge %}
{% block title %}Главная — Электронная библиотека{% endblock %}
{% block content %}
<h2 class="mb-3">Каталог книг</h2>
{# ── Форма поиска (Вариант 3) ────────────────────────────────────────────── #}
<div class="card mb-4">
<div class="card-header">
<strong>Поиск книг</strong>
</div>
<div class="card-body">
<form method="get" action="{{ url_for('index') }}">
<div class="row g-2">
<div class="col-md-4">
<label class="form-label">Название</label>
<input type="text" class="form-control" name="title" value="{{ q_title }}">
</div>
<div class="col-md-4">
<label class="form-label">Автор</label>
<input type="text" class="form-control" name="author" value="{{ q_author }}">
</div>
<div class="col-md-2">
<label class="form-label">Страниц от</label>
<input type="number" class="form-control" name="pages_from" value="{{ q_pages_from }}" min="1">
</div>
<div class="col-md-2">
<label class="form-label">Страниц до</label>
<input type="number" class="form-control" name="pages_to" value="{{ q_pages_to }}" min="1">
</div>
<div class="col-md-6">
<label class="form-label">Жанр</label>
<select class="form-select" name="genres" multiple size="4">
{% for genre in all_genres %}
<option value="{{ genre.id }}"
{% if genre.id in q_genres %}selected{% endif %}>
{{ genre.name }}
</option>
{% endfor %}
</select>
<div class="form-text">Ctrl — несколько жанров</div>
</div>
<div class="col-md-6">
<label class="form-label">Год</label>
<select class="form-select" name="years" multiple size="4">
{% for row in all_years %}
<option value="{{ row.year }}"
{% if row.year in q_years %}selected{% endif %}>
{{ row.year }}
</option>
{% endfor %}
</select>
<div class="form-text">Ctrl — несколько лет</div>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i> Найти
</button>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">Сбросить</a>
</div>
</form>
</div>
</div>
{# ── Список книг ─────────────────────────────────────────────────────────── #}
{% if books %}
<div class="table-responsive">
<table class="table table-striped align-middle">
<thead class="table-dark">
<tr>
<th>Обложка</th>
<th>Название</th>
<th>Жанр(ы)</th>
<th>Год</th>
<th>Средняя оценка</th>
<th>Рецензий</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for book in books %}
<tr>
<td>
{% if book.cover_filename %}
<img src="{{ url_for('uploaded_file', filename=book.cover_filename) }}"
class="cover-thumb rounded" alt="обложка">
{% else %}
<div class="cover-thumb bg-secondary rounded d-flex align-items-center justify-content-center text-white">
<i class="bi bi-book"></i>
</div>
{% endif %}
</td>
<td><strong>{{ book.title }}</strong><br>
<small class="text-muted">{{ book.author }}</small>
</td>
<td><small>{{ book.genres or '—' }}</small></td>
<td>{{ book.year }}</td>
<td>
{% if book.avg_rating is not none %}
{{ rating_badge(book.avg_rating) }}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>{{ book.review_count }}</td>
<td>
<a href="{{ url_for('book_view', book_id=book.id) }}"
class="btn btn-sm btn-outline-primary" title="Просмотр">
<i class="bi bi-eye"></i>
</a>
{% if current_user and current_user.role_name in ['администратор', 'модератор'] %}
<a href="{{ url_for('book_edit', book_id=book.id) }}"
class="btn btn-sm btn-outline-secondary" title="Редактировать">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
{% if current_user and current_user.role_name == 'администратор' %}
<button type="button" class="btn btn-sm btn-outline-danger"
title="Удалить"
data-bs-toggle="modal"
data-bs-target="#deleteModal"
data-book-id="{{ book.id }}"
data-book-title="{{ book.title }}">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# ── Пагинация ────────────────────────────────────────────────────────────── #}
{% if total_pages > 1 %}
<nav>
<ul class="pagination justify-content-center">
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('index', page=p,
title=q_title, author=q_author,
pages_from=q_pages_from, pages_to=q_pages_to,
genres=q_genres, years=q_years) }}">{{ p }}</a>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-info">Книги не найдены</div>
{% endif %}
{% if current_user and current_user.role_name == 'администратор' %}
<div class="mt-3">
<a href="{{ url_for('book_add') }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> Добавить книгу
</a>
</div>
{% endif %}
{# ── Модальное окно удаления ──────────────────────────────────────────────── #}
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Удаление книги</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p id="deleteMessage"></p>
</div>
<div class="modal-footer">
<form id="deleteForm" method="post">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Нет</button>
<button type="submit" class="btn btn-danger">Да</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const deleteModal = document.getElementById('deleteModal');
deleteModal.addEventListener('show.bs.modal', function(event) {
const btn = event.relatedTarget;
const bookId = btn.dataset.bookId;
const bookTitle = btn.dataset.bookTitle;
document.getElementById('deleteMessage').textContent =
'Вы уверены, что хотите удалить книгу «' + bookTitle + '»?';
document.getElementById('deleteForm').action = '/book/' + bookId + '/delete';
});
</script>
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% block title %}Вход — Электронная библиотека{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<h2 class="mb-4">Вход в систему</h2>
<form method="post">
<div class="mb-3">
<label class="form-label">Логин</label>
<input type="text" class="form-control" name="login" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Пароль</label>
<input type="password" class="form-control" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="remember" id="remember">
<label class="form-check-label" for="remember">Запомнить меня</label>
</div>
<button type="submit" class="btn btn-primary w-100">Войти</button>
</form>
</div>
</div>
{% endblock %}

View file

@ -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 %}
<span class="badge bg-{{ color }} text-white">{{ value }}</span>
{% endif %}
{% endmacro %}
{% macro book_form_fields(book, genres, selected_genres, is_edit, current_cover=none) %}
<div class="mb-3">
<label class="form-label">Название <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="title"
value="{{ book.title if book else '' }}" required>
</div>
<div class="mb-3">
<label class="form-label">Автор <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="author"
value="{{ book.author if book else '' }}" required>
</div>
<div class="mb-3">
<label class="form-label">Издательство <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="publisher"
value="{{ book.publisher if book else '' }}" required>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Год <span class="text-danger">*</span></label>
<input type="number" class="form-control" name="year" min="1000" max="2100"
value="{{ book.year if book else '' }}" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Объём (страниц) <span class="text-danger">*</span></label>
<input type="number" class="form-control" name="pages" min="1"
value="{{ book.pages if book else '' }}" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Жанры <span class="text-danger">*</span></label>
<select class="form-select" name="genres" multiple size="5">
{% for genre in genres %}
<option value="{{ genre.id }}"
{% if genre.id in selected_genres %}selected{% endif %}>
{{ genre.name }}
</option>
{% endfor %}
</select>
<div class="form-text">Удерживайте Ctrl для выбора нескольких жанров</div>
</div>
<div class="mb-3">
<label class="form-label">Краткое описание <span class="text-danger">*</span></label>
<textarea class="form-control" name="description" id="description-editor" rows="8">{{ book.description if book else '' }}</textarea>
</div>
{% if not is_edit %}
<div class="mb-3">
<label class="form-label">Обложка <span class="text-danger">*</span></label>
<input type="file" class="form-control" name="cover" accept="image/*" required>
</div>
{% else %}
<div class="mb-3">
<label class="form-label">Обложка</label>
{% if current_cover %}
<div class="mb-2">
<img src="{{ url_for('uploaded_file', filename=current_cover) }}"
class="cover-thumb rounded" alt="текущая обложка">
<div class="form-text">Текущая обложка. Загрузите новый файл чтобы заменить.</div>
</div>
{% else %}
<div class="form-text text-muted mb-2">Обложка не загружена.</div>
{% endif %}
<input type="file" class="form-control" name="cover" accept="image/*">
</div>
{% endif %}
{% endmacro %}

View file

@ -0,0 +1,45 @@
{% extends 'base.html' %}
{% block title %}Написать рецензию — Электронная библиотека{% endblock %}
{% block content %}
<h2 class="mb-4">Рецензия на книгу «{{ book.title }}»</h2>
<form method="post">
<div class="mb-3">
<label class="form-label">Оценка <span class="text-danger">*</span></label>
<select class="form-select" name="rating" style="max-width:260px;" required>
{% set options = [(5,'отлично'),(4,'хорошо'),(3,'удовлетворительно'),(2,'неудовлетворительно'),(1,'плохо'),(0,'ужасно')] %}
{% for val, label in options %}
<option value="{{ val }}"
{% if (form.get('rating', 5) | int) == val %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Текст рецензии <span class="text-danger">*</span></label>
<textarea class="form-control" name="text" id="review-text" rows="8">{{ form.get('text','') }}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Сохранить
</button>
<a href="{{ url_for('book_view', book_id=book.id) }}" class="btn btn-outline-secondary">Отмена</a>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
const easyMDE = new EasyMDE({
element: document.getElementById('review-text'),
spellChecker: false,
autosave: { enabled: false }
});
document.querySelector('form').addEventListener('submit', function() {
easyMDE.codemirror.save();
});
</script>
{% endblock %}

226
ex/db/db_fill.py Normal file
View file

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

64
ex/db/db_seed.py Normal file
View file

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

BIN
ex/db/library.db Normal file

Binary file not shown.

61
ex/db/schema.sql Normal file
View file

@ -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
);

0
ex/library.db Normal file
View file

4
ex/requirements.txt Normal file
View file

@ -0,0 +1,4 @@
flask>=3.0
bleach>=6.1
markdown>=3.5
werkzeug>=3.0

11
ex/run.py Normal file
View file

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