ex
446
ex/app/__init__.py
Normal 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)
|
||||
5
ex/app/static/uploads/10.svg
Normal 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 |
5
ex/app/static/uploads/11.svg
Normal 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 |
5
ex/app/static/uploads/12.svg
Normal 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 |
5
ex/app/static/uploads/13.svg
Normal 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 |
BIN
ex/app/static/uploads/14.jpg
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
ex/app/static/uploads/15.jpg
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
ex/app/static/uploads/16.jpg
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
ex/app/static/uploads/17.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
ex/app/static/uploads/18.jpg
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
ex/app/static/uploads/19.jpg
Normal file
|
After Width: | Height: | Size: 648 KiB |
5
ex/app/static/uploads/2.svg
Normal 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 |
BIN
ex/app/static/uploads/20.jpg
Normal file
|
After Width: | Height: | Size: 863 KiB |
BIN
ex/app/static/uploads/21.jpg
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
ex/app/static/uploads/22.jpg
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
ex/app/static/uploads/23.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
ex/app/static/uploads/24.jpg
Normal file
|
After Width: | Height: | Size: 297 KiB |
BIN
ex/app/static/uploads/25.jpg
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
ex/app/static/uploads/26.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
ex/app/static/uploads/27.jpg
Normal file
|
After Width: | Height: | Size: 434 KiB |
BIN
ex/app/static/uploads/28.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
ex/app/static/uploads/29.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
5
ex/app/static/uploads/3.svg
Normal 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 |
BIN
ex/app/static/uploads/30.jpg
Normal file
|
After Width: | Height: | Size: 442 KiB |
BIN
ex/app/static/uploads/31.jpg
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
ex/app/static/uploads/32.jpg
Normal file
|
After Width: | Height: | Size: 243 KiB |
5
ex/app/static/uploads/4.svg
Normal 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 |
5
ex/app/static/uploads/5.svg
Normal 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 |
5
ex/app/static/uploads/6.svg
Normal 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 |
5
ex/app/static/uploads/7.svg
Normal 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 |
5
ex/app/static/uploads/8.svg
Normal 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 |
5
ex/app/static/uploads/9.svg
Normal 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 |
78
ex/app/templates/base.html
Normal 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 — Деев Егор Викторович
|
||||
</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>
|
||||
33
ex/app/templates/book_form.html
Normal 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 %}
|
||||
126
ex/app/templates/book_view.html
Normal 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
|
|
@ -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 %}
|
||||
25
ex/app/templates/login.html
Normal 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 %}
|
||||
78
ex/app/templates/macros.html
Normal 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 %}
|
||||
45
ex/app/templates/review_form.html
Normal 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
|
|
@ -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
|
|
@ -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
61
ex/db/schema.sql
Normal 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
4
ex/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
flask>=3.0
|
||||
bleach>=6.1
|
||||
markdown>=3.5
|
||||
werkzeug>=3.0
|
||||
11
ex/run.py
Normal 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)
|
||||