Introduction
Congratulations on completing Module 17 on Flask! This weekend project is designed to help you consolidate everything you've learned by building a complete web application with database integration and a RESTful API. As you work through this project, you'll practice all the key concepts from the module, from template rendering to database operations to creating a structured API.
For this project, we'll be using George Polya's 4-step problem-solving procedure:
- Understand the problem - Clarify what we're trying to build
- Devise a plan - Design the solution step-by-step
- Carry out the plan - Implement the solution
- Look back - Evaluate, test, and refine the solution
This approach will help ensure that you don't get overwhelmed by the complexity of building a full application and will give you a systematic way to tackle the challenge.
1. Understand the Problem
Let's define what we're building: a personal library management system. This application will allow users to keep track of their books, manage reading lists, rate and review books, and search for new titles.
Project Requirements
- User Authentication: Users can register, log in, and manage their accounts
- Book Management: Users can add, view, edit, and delete books in their library
- Reading Lists: Users can create and manage reading lists (e.g., "Currently Reading", "Want to Read")
- Reviews and Ratings: Users can rate books and write reviews
- Search and Filter: Users can search for books by title, author, genre, etc.
- RESTful API: The application will have a RESTful API for accessing book data
Clarifying Questions
Following George Polya's method, let's clarify the problem:
-
What data do we need to store?
We need to store information about users, books, reading lists, reviews, and relationships between them.
-
What functionality does the web interface need?
Users need to be able to browse, search, and manage their books and reading lists through a user-friendly interface.
-
What should the API provide?
The API should provide access to book data, including CRUD operations for books, reading lists, and reviews.
-
What are our technical constraints?
We'll use Flask, SQLAlchemy, Flask-RESTful/Marshmallow, and other tools we've learned in the module.
Technologies We'll Use
- Flask: Web framework
- Flask-SQLAlchemy: ORM for database interactions
- Flask-Migrate: Database migrations
- Flask-Login: User authentication
- Flask-WTF: Form handling and validation
- Flask-RESTful and Flask-Marshmallow: API development
- Bootstrap: Front-end styling
- SQLite: Development database
2. Devise a Plan
Now that we understand what we're building, let's break down the project into manageable steps:
Step 1: Set Up Project Structure
- Create project directory and virtual environment
- Install required packages
- Set up a basic Flask application with blueprints
- Configure database connection
Step 2: Design Database Models
- User model for authentication
- Book model for storing book information
- ReadingList model for organizing books
- Review model for ratings and reviews
- Define relationships between models
Step 3: Implement Authentication
- Set up Flask-Login
- Create registration and login forms
- Implement registration, login, and logout routes
- Add user profile pages
Step 4: Develop Book Management
- Create forms for adding and editing books
- Implement CRUD operations for books
- Add book detail pages
- Implement search and filtering
Step 5: Implement Reading Lists
- Create forms for managing reading lists
- Implement CRUD operations for reading lists
- Allow adding/removing books from lists
- Add reading list views
Step 6: Add Reviews and Ratings
- Create forms for reviews and ratings
- Implement review creation and editing
- Display reviews on book pages
- Add aggregate ratings
Step 7: Design and Implement API
- Define API endpoints
- Create resource classes using Flask-RESTful
- Implement serialization with Marshmallow
- Add API authentication
Step 8: Styling and UI Improvements
- Apply Bootstrap styling
- Create a responsive layout
- Add client-side validation and enhancements
- Improve UX with feedback messages, pagination, etc.
Step 9: Testing and Debugging
- Write tests for critical functionality
- Test the application manually
- Fix bugs and issues
- Optimize performance
Step 10: Deployment (Optional)
- Prepare the application for production
- Set up a production database
- Deploy to a hosting platform (e.g., Heroku, PythonAnywhere)
- Configure domain and HTTPS
Project Timeline
This is a substantial project, so let's allocate time realistically:
- Day 1 (Saturday): Steps 1-4
- Day 2 (Sunday): Steps 5-9
- Additional Time (Optional): Step 10
3. Carry Out the Plan
Now let's implement our plan step by step, providing code and explanations for each part:
Step 1: Set Up Project Structure
First, let's create our project directory and set up the basic structure:
# Create project directory
$ mkdir bookshelf
$ cd bookshelf
# Create virtual environment
$ python -m venv venv
$ source venv/bin/activate # On Windows: venv\Scripts\activate
# Install required packages
$ pip install flask flask-sqlalchemy flask-migrate flask-login flask-wtf flask-restful flask-marshmallow marshmallow-sqlalchemy python-dotenv email-validator
Now, let's create our basic project structure:
bookshelf/
├── app/
│ ├── __init__.py
│ ├── config.py
│ ├── extensions.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── book.py
│ │ ├── reading_list.py
│ │ └── review.py
│ ├── blueprints/
│ │ ├── __init__.py
│ │ ├── auth/
│ │ │ ├── __init__.py
│ │ │ ├── routes.py
│ │ │ └── forms.py
│ │ ├── main/
│ │ │ ├── __init__.py
│ │ │ └── routes.py
│ │ ├── books/
│ │ │ ├── __init__.py
│ │ │ ├── routes.py
│ │ │ └── forms.py
│ │ ├── reading_lists/
│ │ │ ├── __init__.py
│ │ │ ├── routes.py
│ │ │ └── forms.py
│ │ └── api/
│ │ ├── __init__.py
│ │ ├── resources.py
│ │ └── schemas.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── auth/
│ │ ├── main/
│ │ ├── books/
│ │ └── reading_lists/
│ └── static/
│ ├── css/
│ └── js/
├── migrations/
├── .env
└── run.py
Now, let's implement the basic Flask application:
# app/config.py
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///bookshelf.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_marshmallow import Marshmallow
db = SQLAlchemy()
migrate = Migrate()
login = LoginManager()
login.login_view = 'auth.login'
login.login_message = 'Please log in to access this page.'
ma = Marshmallow()
# app/__init__.py
from flask import Flask
from app.config import Config
from app.extensions import db, migrate, login, ma
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
login.init_app(app)
ma.init_app(app)
# Register blueprints
from app.blueprints.auth import auth
from app.blueprints.main import main
from app.blueprints.books import books
from app.blueprints.reading_lists import reading_lists
from app.blueprints.api import api_bp
app.register_blueprint(auth)
app.register_blueprint(main)
app.register_blueprint(books)
app.register_blueprint(reading_lists)
app.register_blueprint(api_bp, url_prefix='/api')
return app
# app/models/__init__.py
from app.models.user import User
from app.models.book import Book
from app.models.reading_list import ReadingList, reading_list_books
from app.models.review import Review
# run.py
from app import create_app, db
from app.models import User, Book, ReadingList, Review
app = create_app()
@app.shell_context_processor
def make_shell_context():
return {
'db': db,
'User': User,
'Book': Book,
'ReadingList': ReadingList,
'Review': Review
}
if __name__ == '__main__':
app.run(debug=True)
Step 2: Design Database Models
Now let's define our database models:
# app/models/user.py
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app.extensions import db, login
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(128), nullable=False)
joined_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
books = db.relationship('Book', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
reading_lists = db.relationship('ReadingList', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
reviews = db.relationship('Review', backref='author', lazy='dynamic', cascade='all, delete-orphan')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f''
@login.user_loader
def load_user(id):
return User.query.get(int(id))
# app/models/book.py
from datetime import datetime
from app.extensions import db
class Book(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False, index=True)
author = db.Column(db.String(100), nullable=False, index=True)
isbn = db.Column(db.String(20), index=True)
publisher = db.Column(db.String(100))
publication_year = db.Column(db.Integer)
description = db.Column(db.Text)
genre = db.Column(db.String(50), index=True)
cover_image = db.Column(db.String(200))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
reviews = db.relationship('Review', backref='book', lazy='dynamic', cascade='all, delete-orphan')
def __repr__(self):
return f''
@property
def average_rating(self):
reviews = self.reviews.all()
if not reviews:
return 0
return sum(review.rating for review in reviews) / len(reviews)
# app/models/reading_list.py
from datetime import datetime
from app.extensions import db
# Association table for the many-to-many relationship
reading_list_books = db.Table('reading_list_books',
db.Column('reading_list_id', db.Integer, db.ForeignKey('reading_list.id'), primary_key=True),
db.Column('book_id', db.Integer, db.ForeignKey('book.id'), primary_key=True),
db.Column('added_at', db.DateTime, default=datetime.utcnow)
)
class ReadingList(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
books = db.relationship('Book', secondary=reading_list_books, lazy='dynamic',
backref=db.backref('reading_lists', lazy='dynamic'))
def __repr__(self):
return f''
# app/models/review.py
from datetime import datetime
from app.extensions import db
class Review(db.Model):
id = db.Column(db.Integer, primary_key=True)
rating = db.Column(db.Integer, nullable=False) # 1-5 stars
content = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
book_id = db.Column(db.Integer, db.ForeignKey('book.id'), nullable=False)
# Ensure a user can only review a book once
__table_args__ = (
db.UniqueConstraint('user_id', 'book_id', name='uix_user_book_review'),
)
def __repr__(self):
return f''
Step 3: Implement Authentication
Let's implement user authentication:
# app/blueprints/auth/__init__.py
from flask import Blueprint
auth = Blueprint('auth', __name__, url_prefix='/auth')
from app.blueprints.auth import routes
# app/blueprints/auth/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
from app.models import User
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=64)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
password2 = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user is not None:
raise ValidationError('Please use a different username.')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('Please use a different email address.')
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Remember Me')
submit = SubmitField('Sign In')
# app/blueprints/auth/routes.py
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, current_user, login_required
from app.extensions import db
from app.blueprints.auth import auth
from app.blueprints.auth.forms import LoginForm, RegistrationForm
from app.models import User
@auth.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('Congratulations, you are now a registered user!', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', title='Register', form=form)
@auth.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password', 'danger')
return redirect(url_for('auth.login'))
login_user(user, remember=form.remember_me.data)
# Redirect to requested page or default to home page
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('main.index')
return redirect(next_page)
return render_template('auth/login.html', title='Sign In', form=form)
@auth.route('/logout')
def logout():
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('main.index'))
@auth.route('/profile')
@login_required
def profile():
return render_template('auth/profile.html', title='Profile')
Step 4-6: Implement Main Features
Now let's implement the main features of our application, including book management, reading lists, and reviews:
# app/blueprints/main/__init__.py
from flask import Blueprint
main = Blueprint('main', __name__)
from app.blueprints.main import routes
# app/blueprints/main/routes.py
from flask import render_template, redirect, url_for
from app.blueprints.main import main
from app.models import Book, ReadingList, Review
@main.route('/')
def index():
recent_books = Book.query.order_by(Book.created_at.desc()).limit(5).all()
top_rated_books = Book.query.join(Review).group_by(Book.id).order_by(db.func.avg(Review.rating).desc()).limit(5).all()
return render_template('main/index.html', title='Home',
recent_books=recent_books, top_rated_books=top_rated_books)
@main.route('/about')
def about():
return render_template('main/about.html', title='About')
# app/blueprints/books/__init__.py
from flask import Blueprint
books = Blueprint('books', __name__, url_prefix='/books')
from app.blueprints.books import routes
# app/blueprints/books/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, IntegerField, SelectField, SubmitField
from wtforms.validators import DataRequired, Length, Optional, NumberRange, URL
class BookForm(FlaskForm):
title = StringField('Title', validators=[DataRequired(), Length(max=200)])
author = StringField('Author', validators=[DataRequired(), Length(max=100)])
isbn = StringField('ISBN', validators=[Optional(), Length(max=20)])
publisher = StringField('Publisher', validators=[Optional(), Length(max=100)])
publication_year = IntegerField('Publication Year', validators=[Optional(), NumberRange(min=1000, max=2100)])
description = TextAreaField('Description', validators=[Optional()])
genre = StringField('Genre', validators=[Optional(), Length(max=50)])
cover_image = StringField('Cover Image URL', validators=[Optional(), URL()])
submit = SubmitField('Save Book')
class SearchForm(FlaskForm):
query = StringField('Search', validators=[DataRequired()])
category = SelectField('Category', choices=[
('title', 'Title'),
('author', 'Author'),
('genre', 'Genre'),
('isbn', 'ISBN')
])
submit = SubmitField('Search')
class ReviewForm(FlaskForm):
rating = SelectField('Rating', choices=[(1, '1 Star'), (2, '2 Stars'), (3, '3 Stars'),
(4, '4 Stars'), (5, '5 Stars')], coerce=int)
content = TextAreaField('Review', validators=[Optional(), Length(max=1000)])
submit = SubmitField('Submit Review')
# app/blueprints/books/routes.py
from flask import render_template, redirect, url_for, flash, request, abort
from flask_login import current_user, login_required
from app.extensions import db
from app.blueprints.books import books
from app.blueprints.books.forms import BookForm, SearchForm, ReviewForm
from app.models import Book, Review
@books.route('/')
def index():
page = request.args.get('page', 1, type=int)
books_list = Book.query.order_by(Book.title).paginate(page=page, per_page=12)
return render_template('books/index.html', title='All Books', books=books_list)
@books.route('/create', methods=['GET', 'POST'])
@login_required
def create():
form = BookForm()
if form.validate_on_submit():
book = Book(
title=form.title.data,
author=form.author.data,
isbn=form.isbn.data,
publisher=form.publisher.data,
publication_year=form.publication_year.data,
description=form.description.data,
genre=form.genre.data,
cover_image=form.cover_image.data,
user_id=current_user.id
)
db.session.add(book)
db.session.commit()
flash('Your book has been added!', 'success')
return redirect(url_for('books.detail', id=book.id))
return render_template('books/create.html', title='Add Book', form=form)
@books.route('/')
def detail(id):
book = Book.query.get_or_404(id)
review_form = ReviewForm()
user_review = None
if current_user.is_authenticated:
user_review = Review.query.filter_by(user_id=current_user.id, book_id=book.id).first()
return render_template('books/detail.html', title=book.title, book=book,
review_form=review_form, user_review=user_review)
@books.route('//edit', methods=['GET', 'POST'])
@login_required
def edit(id):
book = Book.query.get_or_404(id)
# Check if the current user is the owner of the book
if book.user_id != current_user.id:
abort(403)
form = BookForm()
if form.validate_on_submit():
book.title = form.title.data
book.author = form.author.data
book.isbn = form.isbn.data
book.publisher = form.publisher.data
book.publication_year = form.publication_year.data
book.description = form.description.data
book.genre = form.genre.data
book.cover_image = form.cover_image.data
db.session.commit()
flash('Your book has been updated!', 'success')
return redirect(url_for('books.detail', id=book.id))
elif request.method == 'GET':
form.title.data = book.title
form.author.data = book.author
form.isbn.data = book.isbn
form.publisher.data = book.publisher
form.publication_year.data = book.publication_year
form.description.data = book.description
form.genre.data = book.genre
form.cover_image.data = book.cover_image
return render_template('books/edit.html', title='Edit Book', form=form, book=book)
@books.route('//delete', methods=['POST'])
@login_required
def delete(id):
book = Book.query.get_or_404(id)
# Check if the current user is the owner of the book
if book.user_id != current_user.id:
abort(403)
db.session.delete(book)
db.session.commit()
flash('Your book has been deleted!', 'success')
return redirect(url_for('books.index'))
@books.route('/search', methods=['GET', 'POST'])
def search():
form = SearchForm()
results = []
if form.validate_on_submit() or request.args.get('query'):
query = form.query.data if form.validate_on_submit() else request.args.get('query')
category = form.category.data if form.validate_on_submit() else request.args.get('category', 'title')
if category == 'title':
results = Book.query.filter(Book.title.ilike(f'%{query}%')).all()
elif category == 'author':
results = Book.query.filter(Book.author.ilike(f'%{query}%')).all()
elif category == 'genre':
results = Book.query.filter(Book.genre.ilike(f'%{query}%')).all()
elif category == 'isbn':
results = Book.query.filter(Book.isbn.ilike(f'%{query}%')).all()
return render_template('books/search.html', title='Search Books', form=form, results=results)
@books.route('//review', methods=['POST'])
@login_required
def add_review(id):
book = Book.query.get_or_404(id)
form = ReviewForm()
if form.validate_on_submit():
# Check if user already has a review for this book
existing_review = Review.query.filter_by(user_id=current_user.id, book_id=book.id).first()
if existing_review:
# Update existing review
existing_review.rating = form.rating.data
existing_review.content = form.content.data
flash('Your review has been updated!', 'success')
else:
# Create new review
review = Review(
rating=form.rating.data,
content=form.content.data,
user_id=current_user.id,
book_id=book.id
)
db.session.add(review)
flash('Your review has been added!', 'success')
db.session.commit()
return redirect(url_for('books.detail', id=book.id))
# app/blueprints/reading_lists/__init__.py
from flask import Blueprint
reading_lists = Blueprint('reading_lists', __name__, url_prefix='/reading-lists')
from app.blueprints.reading_lists import routes
# app/blueprints/reading_lists/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length, Optional
class ReadingListForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=100)])
description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
submit = SubmitField('Save')
# app/blueprints/reading_lists/routes.py
from flask import render_template, redirect, url_for, flash, request, abort
from flask_login import current_user, login_required
from app.extensions import db
from app.blueprints.reading_lists import reading_lists
from app.blueprints.reading_lists.forms import ReadingListForm
from app.models import ReadingList, Book
@reading_lists.route('/')
@login_required
def index():
lists = ReadingList.query.filter_by(user_id=current_user.id).all()
return render_template('reading_lists/index.html', title='My Reading Lists', reading_lists=lists)
@reading_lists.route('/create', methods=['GET', 'POST'])
@login_required
def create():
form = ReadingListForm()
if form.validate_on_submit():
reading_list = ReadingList(
name=form.name.data,
description=form.description.data,
user_id=current_user.id
)
db.session.add(reading_list)
db.session.commit()
flash('Your reading list has been created!', 'success')
return redirect(url_for('reading_lists.detail', id=reading_list.id))
return render_template('reading_lists/create.html', title='Create Reading List', form=form)
@reading_lists.route('/')
@login_required
def detail(id):
reading_list = ReadingList.query.get_or_404(id)
# Ensure the current user is the owner of the reading list
if reading_list.user_id != current_user.id:
abort(403)
return render_template('reading_lists/detail.html', title=reading_list.name, reading_list=reading_list)
@reading_lists.route('//edit', methods=['GET', 'POST'])
@login_required
def edit(id):
reading_list = ReadingList.query.get_or_404(id)
# Ensure the current user is the owner of the reading list
if reading_list.user_id != current_user.id:
abort(403)
form = ReadingListForm()
if form.validate_on_submit():
reading_list.name = form.name.data
reading_list.description = form.description.data
db.session.commit()
flash('Your reading list has been updated!', 'success')
return redirect(url_for('reading_lists.detail', id=reading_list.id))
elif request.method == 'GET':
form.name.data = reading_list.name
form.description.data = reading_list.description
return render_template('reading_lists/edit.html', title='Edit Reading List', form=form, reading_list=reading_list)
@reading_lists.route('//delete', methods=['POST'])
@login_required
def delete(id):
reading_list = ReadingList.query.get_or_404(id)
# Ensure the current user is the owner of the reading list
if reading_list.user_id != current_user.id:
abort(403)
db.session.delete(reading_list)
db.session.commit()
flash('Your reading list has been deleted!', 'success')
return redirect(url_for('reading_lists.index'))
@reading_lists.route('//add/', methods=['POST'])
@login_required
def add_book(list_id, book_id):
reading_list = ReadingList.query.get_or_404(list_id)
book = Book.query.get_or_404(book_id)
# Ensure the current user is the owner of the reading list
if reading_list.user_id != current_user.id:
abort(403)
# Check if the book is already in the reading list
if book in reading_list.books:
flash('This book is already in the reading list.', 'info')
else:
reading_list.books.append(book)
db.session.commit()
flash('Book added to the reading list!', 'success')
return redirect(url_for('reading_lists.detail', id=reading_list.id))
@reading_lists.route('//remove/', methods=['POST'])
@login_required
def remove_book(list_id, book_id):
reading_list = ReadingList.query.get_or_404(list_id)
book = Book.query.get_or_404(book_id)
# Ensure the current user is the owner of the reading list
if reading_list.user_id != current_user.id:
abort(403)
# Check if the book is in the reading list
if book in reading_list.books:
reading_list.books.remove(book)
db.session.commit()
flash('Book removed from the reading list.', 'success')
else:
flash('This book is not in the reading list.', 'info')
return redirect(url_for('reading_lists.detail', id=reading_list.id))
Step 7: Design and Implement API
Now let's implement our RESTful API:
# app/blueprints/api/__init__.py
from flask import Blueprint
api_bp = Blueprint('api', __name__)
from app.blueprints.api import resources
# app/blueprints/api/schemas.py
from app.extensions import ma
from app.models import User, Book, ReadingList, Review
from marshmallow import fields, validates, ValidationError
class UserSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = User
exclude = ('password_hash',)
load_instance = True
class ReviewSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Review
include_fk = True
load_instance = True
author = fields.Nested('UserSchema', only=('id', 'username'))
class BookSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Book
include_fk = True
load_instance = True
reviews = fields.Nested(ReviewSchema, many=True, exclude=('book',))
owner = fields.Nested('UserSchema', only=('id', 'username'))
average_rating = fields.Float()
@validates('publication_year')
def validate_year(self, value):
if value and (value < 1000 or value > 2100):
raise ValidationError('Publication year must be between 1000 and 2100')
class ReadingListSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = ReadingList
include_fk = True
load_instance = True
books = fields.Nested(BookSchema, many=True, exclude=('reviews', 'reading_lists'))
owner = fields.Nested('UserSchema', only=('id', 'username'))
# Create schema instances
user_schema = UserSchema()
users_schema = UserSchema(many=True)
book_schema = BookSchema()
books_schema = BookSchema(many=True)
review_schema = ReviewSchema()
reviews_schema = ReviewSchema(many=True)
reading_list_schema = ReadingListSchema()
reading_lists_schema = ReadingListSchema(many=True)
# app/blueprints/api/resources.py
from flask import request, jsonify, g
from flask_restful import Api, Resource, abort
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
from app.extensions import db
from app.blueprints.api import api_bp
from app.blueprints.api.schemas import (
user_schema, users_schema, book_schema, books_schema,
review_schema, reviews_schema, reading_list_schema, reading_lists_schema
)
from app.models import User, Book, ReadingList, Review
from marshmallow import ValidationError
# Set up authentication
basic_auth = HTTPBasicAuth()
token_auth = HTTPTokenAuth()
@basic_auth.verify_password
def verify_password(username, password):
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
g.user = user
return user
@token_auth.verify_token
def verify_token(token):
# In a real app, you would implement token verification here
# For simplicity, we'll use basic auth for this example
return None
# Create the API
api = Api(api_bp)
# Resource classes
class BookListAPI(Resource):
def get(self):
# Get query parameters for filtering
author = request.args.get('author')
genre = request.args.get('genre')
# Start with base query
query = Book.query
# Apply filters
if author:
query = query.filter(Book.author.ilike(f'%{author}%'))
if genre:
query = query.filter(Book.genre.ilike(f'%{genre}%'))
# Execute query
books = query.all()
return books_schema.dump(books)
@basic_auth.login_required
def post(self):
try:
# Get JSON data
json_data = request.get_json()
# Ensure user_id is set to current user
json_data['user_id'] = g.user.id
# Validate and deserialize input
book = book_schema.load(json_data)
# Save the book
db.session.add(book)
db.session.commit()
return book_schema.dump(book), 201
except ValidationError as err:
return {'errors': err.messages}, 400
class BookAPI(Resource):
def get(self, id):
book = Book.query.get_or_404(id)
return book_schema.dump(book)
@basic_auth.login_required
def put(self, id):
book = Book.query.get_or_404(id)
# Check if the current user is the owner
if book.user_id != g.user.id:
abort(403, message="Permission denied")
try:
# Get JSON data
json_data = request.get_json()
# Validate and deserialize input
book = book_schema.load(json_data, instance=book, partial=True)
# Save the book
db.session.commit()
return book_schema.dump(book)
except ValidationError as err:
return {'errors': err.messages}, 400
@basic_auth.login_required
def delete(self, id):
book = Book.query.get_or_404(id)
# Check if the current user is the owner
if book.user_id != g.user.id:
abort(403, message="Permission denied")
db.session.delete(book)
db.session.commit()
return '', 204
class ReadingListListAPI(Resource):
@basic_auth.login_required
def get(self):
reading_lists = ReadingList.query.filter_by(user_id=g.user.id).all()
return reading_lists_schema.dump(reading_lists)
@basic_auth.login_required
def post(self):
try:
# Get JSON data
json_data = request.get_json()
# Ensure user_id is set to current user
json_data['user_id'] = g.user.id
# Validate and deserialize input
reading_list = reading_list_schema.load(json_data)
# Save the reading list
db.session.add(reading_list)
db.session.commit()
return reading_list_schema.dump(reading_list), 201
except ValidationError as err:
return {'errors': err.messages}, 400
class ReadingListAPI(Resource):
@basic_auth.login_required
def get(self, id):
reading_list = ReadingList.query.get_or_404(id)
# Check if the current user is the owner
if reading_list.user_id != g.user.id:
abort(403, message="Permission denied")
return reading_list_schema.dump(reading_list)
@basic_auth.login_required
def put(self, id):
reading_list = ReadingList.query.get_or_404(id)
# Check if the current user is the owner
if reading_list.user_id != g.user.id:
abort(403, message="Permission denied")
try:
# Get JSON data
json_data = request.get_json()
# Validate and deserialize input
reading_list = reading_list_schema.load(json_data, instance=reading_list, partial=True)
# Save the reading list
db.session.commit()
return reading_list_schema.dump(reading_list)
except ValidationError as err:
return {'errors': err.messages}, 400
@basic_auth.login_required
def delete(self, id):
reading_list = ReadingList.query.get_or_404(id)
# Check if the current user is the owner
if reading_list.user_id != g.user.id:
abort(403, message="Permission denied")
db.session.delete(reading_list)
db.session.commit()
return '', 204
class ReadingListBookAPI(Resource):
@basic_auth.login_required
def post(self, list_id, book_id):
reading_list = ReadingList.query.get_or_404(list_id)
book = Book.query.get_or_404(book_id)
# Check if the current user is the owner of the reading list
if reading_list.user_id != g.user.id:
abort(403, message="Permission denied")
# Check if the book is already in the reading list
if book in reading_list.books:
return {'message': 'Book is already in this reading list'}, 400
# Add book to reading list
reading_list.books.append(book)
db.session.commit()
return {'message': 'Book added to reading list'}, 201
@basic_auth.login_required
def delete(self, list_id, book_id):
reading_list = ReadingList.query.get_or_404(list_id)
book = Book.query.get_or_404(book_id)
# Check if the current user is the owner of the reading list
if reading_list.user_id != g.user.id:
abort(403, message="Permission denied")
# Check if the book is in the reading list
if book not in reading_list.books:
return {'message': 'Book is not in this reading list'}, 400
# Remove book from reading list
reading_list.books.remove(book)
db.session.commit()
return '', 204
class ReviewListAPI(Resource):
def get(self, book_id):
book = Book.query.get_or_404(book_id)
reviews = book.reviews.all()
return reviews_schema.dump(reviews)
@basic_auth.login_required
def post(self, book_id):
book = Book.query.get_or_404(book_id)
try:
# Get JSON data
json_data = request.get_json()
# Ensure user_id and book_id are set
json_data['user_id'] = g.user.id
json_data['book_id'] = book_id
# Check if the user already has a review for this book
existing_review = Review.query.filter_by(user_id=g.user.id, book_id=book_id).first()
if existing_review:
# Update existing review
review = review_schema.load(json_data, instance=existing_review, partial=True)
message = 'Review updated'
else:
# Create new review
review = review_schema.load(json_data)
db.session.add(review)
message = 'Review created'
db.session.commit()
return {'message': message, 'review': review_schema.dump(review)}, 201
except ValidationError as err:
return {'errors': err.messages}, 400
class ReviewAPI(Resource):
def get(self, id):
review = Review.query.get_or_404(id)
return review_schema.dump(review)
@basic_auth.login_required
def put(self, id):
review = Review.query.get_or_404(id)
# Check if the current user is the author
if review.user_id != g.user.id:
abort(403, message="Permission denied")
try:
# Get JSON data
json_data = request.get_json()
# Validate and deserialize input
review = review_schema.load(json_data, instance=review, partial=True)
# Save the review
db.session.commit()
return review_schema.dump(review)
except ValidationError as err:
return {'errors': err.messages}, 400
@basic_auth.login_required
def delete(self, id):
review = Review.query.get_or_404(id)
# Check if the current user is the author
if review.user_id != g.user.id:
abort(403, message="Permission denied")
db.session.delete(review)
db.session.commit()
return '', 204
# Register resources
api.add_resource(BookListAPI, '/books')
api.add_resource(BookAPI, '/books/')
api.add_resource(ReadingListListAPI, '/reading-lists')
api.add_resource(ReadingListAPI, '/reading-lists/')
api.add_resource(ReadingListBookAPI, '/reading-lists//books/')
api.add_resource(ReviewListAPI, '/books//reviews')
api.add_resource(ReviewAPI, '/reviews/')
Step 8: Templates and Styling
Let's create some basic templates for our application:
{% block title %}Bookshelf{% endblock %}
{% block styles %}{% endblock %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
{{ message }}
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
{% extends 'base.html' %}
{% block content %}
Welcome to Bookshelf
Your personal library management system
{% if not current_user.is_authenticated %}
Join today to track your books, create reading lists, and more!
Sign Up Now
{% endif %}
Recently Added Books
{% for book in recent_books %}
{% if book.cover_image %}
{% else %}
{% endif %}
{% endfor %}
Top Rated Books
{% for book in top_rated_books %}
{% if book.cover_image %}
{% else %}
{% endif %}
{{ book.title }}
{{ book.author }}
{% for i in range(5) %}
{% if i < book.average_rating|int %}
{% elif i < book.average_rating|round(0, 'ceil')|int and (book.average_rating % 1) > 0 %}
{% else %}
{% endif %}
{% endfor %}
({{ book.average_rating|round(1) }})
View Details
{% endfor %}
{% endblock %}
{% extends 'base.html' %}
{% block content %}
Sign In
New User? Register here
{% endblock %}
{% extends 'base.html' %}
{% block content %}
Register
Already have an account? Sign in
{% endblock %}
{% extends 'base.html' %}
{% block content %}
{% if book.cover_image %}
{% else %}
{% endif %}
{{ book.title }}
by {{ book.author }}
{% if book.publication_year %}
Publication Year: {{ book.publication_year }}
{% endif %}
{% if book.publisher %}
Publisher: {{ book.publisher }}
{% endif %}
{% if book.isbn %}
ISBN: {{ book.isbn }}
{% endif %}
{% if book.genre %}
Genre: {{ book.genre }}
{% endif %}
Rating:
{% for i in range(5) %}
{% if i < book.average_rating|int %}
{% elif i < book.average_rating|round(0, 'ceil')|int and (book.average_rating % 1) > 0 %}
{% else %}
{% endif %}
{% endfor %}
({{ book.average_rating|round(1) }} / 5)
{% if book.description %}
Description
{{ book.description }}
{% endif %}
{% if current_user.is_authenticated %}
{% if book.user_id == current_user.id %}
Edit
{% endif %}
{% endif %}
Reviews
{% if current_user.is_authenticated %}
{% if user_review %}Edit Your Review{% else %}Write a Review{% endif %}
{% endif %}
{% if book.reviews.all() %}
{% for review in book.reviews %}
{% for i in range(5) %}
{% if i < review.rating %}
{% else %}
{% endif %}
{% endfor %}
By {{ review.author.username }} on {{ review.created_at.strftime('%Y-%m-%d') }}
{{ review.content }}
{% endfor %}
{% else %}
No reviews yet. Be the first to review this book!
{% endif %}
{% endblock %}
{% extends 'base.html' %}
{% block content %}
Add New Book
{% endblock %}
{% extends 'base.html' %}
{% block content %}
Edit Book
{% endblock %}
{% extends 'base.html' %}
{% block content %}
Books Library
{% if current_user.is_authenticated %}
Add New Book
{% endif %}
{% if books.items %}
{% for book in books.items %}
{% if book.cover_image %}
{% else %}
{% endif %}
{{ book.title }}
{{ book.author }}
{% for i in range(5) %}
{% if i < book.average_rating|int %}
{% elif i < book.average_rating|round(0, 'ceil')|int and (book.average_rating % 1) > 0 %}
{% else %}
{% endif %}
{% endfor %}
({{ book.average_rating|round(1) }})
{% if book.genre %}
{{ book.genre }}
{% endif %}
{{ book.description|truncate(100) }}
View Details
{% endfor %}
{% else %}
No books found. {% if current_user.is_authenticated %}Add a book{% endif %}
{% endif %}
{% endblock %}
{% extends 'base.html' %}
{% block content %}
Search Books
{% if results %}
Search Results
{% for book in results %}
{% if book.cover_image %}
{% else %}
{% endif %}
{{ book.title }}
{{ book.author }}
{% for i in range(5) %}
{% if i < book.average_rating|int %}
{% elif i < book.average_rating|round(0, 'ceil')|int and (book.average_rating % 1) > 0 %}
{% else %}
{% endif %}
{% endfor %}
{% if book.genre %}
{{ book.genre }}
{% endif %}
View Details
{% endfor %}
{% elif request.method == 'POST' or request.args.get('query') %}
No books found matching your search criteria.
{% endif %}
{% endblock %}
{% extends 'base.html' %}
{% block content %}
Create Reading List
{% endblock %}
{% extends 'base.html' %}
{% block content %}
{{ reading_list.name }}
{% if reading_list.description %}
{{ reading_list.description }}
{% endif %}
Books in this List
{% if reading_list.books.all() %}
{% for book in reading_list.books %}
{% if book.cover_image %}
{% else %}
{% endif %}
{{ book.title }}
{{ book.author }}
{% for i in range(5) %}
{% if i < book.average_rating|int %}
{% elif i < book.average_rating|round(0, 'ceil')|int and (book.average_rating % 1) > 0 %}
{% else %}
{% endif %}
{% endfor %}
{% endfor %}
{% else %}
No books in this reading list yet. Browse books to add some!
{% endif %}
{% endblock %}
{% extends 'base.html' %}
{% block content %}
Edit Reading List
{% endblock %}
{% extends 'base.html' %}
{% block content %}
My Reading Lists
Create New List
{% if reading_lists %}
{% for reading_list in reading_lists %}
{{ reading_list.name }}
{% if reading_list.description %}
{{ reading_list.description|truncate(100) }}
{% endif %}
{{ reading_list.books.count() }} books
View List
{% endfor %}
{% else %}
You don't have any reading lists yet. Create one to start organizing your books!
{% endif %}
{% endblock %}
{% extends 'base.html' %}
{% block content %}
Profile Information
Username: {{ current_user.username }}
Email: {{ current_user.email }}
Joined: {{ current_user.joined_at.strftime('%Y-%m-%d') }}
Activity Summary
{{ current_user.books.count() }}
Books Added
{{ current_user.reading_lists.count() }}
Reading Lists
{{ current_user.reviews.count() }}
Reviews Written
Recently Added Books
{% set recent_books = current_user.books.order_by(Book.created_at.desc()).limit(3).all() %}
{% if recent_books %}
{% for book in recent_books %}
{% endfor %}
{% else %}
You haven't added any books yet. Add your first book!
{% endif %}
{% endblock %}
{% extends 'base.html' %}
{% block content %}
About Bookshelf
Project Overview
Bookshelf is a personal library management system built with Flask. This application allows users to track their books,
manage reading lists, rate and review books, and search for new titles.
Features
- User Accounts: Register, log in, and manage your personal library
- Book Management: Add, edit, and organize your book collection
- Reading Lists: Create custom lists for different categories or reading goals
- Reviews & Ratings: Rate books and write reviews
- Search: Find books by title, author, genre, or ISBN
- RESTful API: Access your book data programmatically
Technologies Used
- Flask: Web framework
- SQLAlchemy: Database ORM
- Flask-Login: User authentication
- Flask-WTF: Form handling
- Flask-RESTful & Marshmallow: API development
- Bootstrap: Frontend styling
About the Developer
Bookshelf was developed as a weekend project for Module 17 of the Comprehensive Full Stack Web Development course.
The goal was to apply Flask development concepts in a practical, feature-rich application.
{% endblock %}
/* Custom styles */
.jumbotron {
padding: 2rem;
background-color: #f7f7f9;
border-radius: 0.3rem;
margin-bottom: 2rem;
}
.book-cover {
height: 200px;
object-fit: cover;
}
.card-img-top {
height: 200px;
object-fit: cover;
}
/* Star Rating */
.star-rating {
color: #ffc107;
}
/* Footer */
footer {
margin-top: 3rem;
padding: 1.5rem 0;
background-color: #f8f9fa;
}
/* app/static/js/main.js */
// Custom JavaScript
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap components
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
// Add event listeners for forms if needed
});
3. Carry Out the Plan
Now let's implement our plan step by step, providing code and explanations for each part:
Step 1: Set Up Project Structure
First, let's create our project directory and set up the basic structure:
# Create project directory
$ mkdir bookshelf
$ cd bookshelf
# Create virtual environment
$ python -m venv venv
$ source venv/bin/activate # On Windows: venv\Scripts\activate
# Install required packages
$ pip install flask flask-sqlalchemy flask-migrate flask-login flask-wtf flask-restful flask-marshmallow marshmallow-sqlalchemy python-dotenv email-validator
Now, let's create our basic project structure:
bookshelf/
├── app/
│ ├── __init__.py
│ ├── config.py
│ ├── extensions.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── book.py
│ │ ├── reading_list.py
│ │ └── review.py
│ ├── blueprints/
│ │ ├── __init__.py
│ │ ├── auth/
│ │ │ ├── __init__.py
│ │ │ ├── routes.py
│ │ │ └── forms.py
│ │ ├── main/
│ │ │ ├── __init__.py
│ │ │ └── routes.py
│ │ ├── books/
│ │ │ ├── __init__.py
│ │ │ ├── routes.py
│ │ │ └── forms.py
│ │ ├── reading_lists/
│ │ │ ├── __init__.py
│ │ │ ├── routes.py
│ │ │ └── forms.py
│ │ └── api/
│ │ ├── __init__.py
│ │ ├── resources.py
│ │ └── schemas.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── auth/
│ │ ├── main/
│ │ ├── books/
│ │ └── reading_lists/
│ └── static/
│ ├── css/
│ └── js/
├── migrations/
├── .env
└── run.py
Now, let's implement the basic Flask application:
# app/config.py
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///bookshelf.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_marshmallow import Marshmallow
db = SQLAlchemy()
migrate = Migrate()
login = LoginManager()
login.login_view = 'auth.login'
login.login_message = 'Please log in to access this page.'
ma = Marshmallow()
# app/__init__.py
from flask import Flask
from app.config import Config
from app.extensions import db, migrate, login, ma
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
login.init_app(app)
ma.init_app(app)
# Register blueprints
from app.blueprints.auth import auth
from app.blueprints.main import main
from app.blueprints.books import books
from app.blueprints.reading_lists import reading_lists
from app.blueprints.api import api_bp
app.register_blueprint(auth)
app.register_blueprint(main)
app.register_blueprint(books)
app.register_blueprint(reading_lists)
app.register_blueprint(api_bp, url_prefix='/api')
return app
# app/models/__init__.py
from app.models.user import User
from app.models.book import Book
from app.models.reading_list import ReadingList, reading_list_books
from app.models.review import Review
# run.py
from app import create_app, db
from app.models import User, Book, ReadingList, Review
app = create_app()
@app.shell_context_processor
def make_shell_context():
return {
'db': db,
'User': User,
'Book': Book,
'ReadingList': ReadingList,
'Review': Review
}
if __name__ == '__main__':
app.run(debug=True)
Step 2: Design Database Models
Now let's define our database models:
# app/models/user.py
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app.extensions import db, login
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(128), nullable=False)
joined_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
books = db.relationship('Book', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
reading_lists = db.relationship('ReadingList', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
reviews = db.relationship('Review', backref='author', lazy='dynamic', cascade='all, delete-orphan')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f''
@login.user_loader
def load_user(id):
return User.query.get(int(id))
# app/models/book.py
from datetime import datetime
from app.extensions import db
class Book(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False, index=True)
author = db.Column(db.String(100), nullable=False, index=True)
isbn = db.Column(db.String(20), index=True)
publisher = db.Column(db.String(100))
publication_year = db.Column(db.Integer)
description = db.Column(db.Text)
genre = db.Column(db.String(50), index=True)
cover_image = db.Column(db.String(200))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
reviews = db.relationship('Review', backref='book', lazy='dynamic', cascade='all, delete-orphan')
def __repr__(self):
return f''
@property
def average_rating(self):
reviews = self.reviews.all()
if not reviews:
return 0
return sum(review.rating for review in reviews) / len(reviews)
# app/models/reading_list.py
from datetime import datetime
from app.extensions import db
# Association table for the many-to-many relationship
reading_list_books = db.Table('reading_list_books',
db.Column('reading_list_id', db.Integer, db.ForeignKey('reading_list.id'), primary_key=True),
db.Column('book_id', db.Integer, db.ForeignKey('book.id'), primary_key=True),
db.Column('added_at', db.DateTime, default=datetime.utcnow)
)
class ReadingList(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
books = db.relationship('Book', secondary=reading_list_books, lazy='dynamic',
backref=db.backref('reading_lists', lazy='dynamic'))
def __repr__(self):
return f''
# app/models/review.py
from datetime import datetime
from app.extensions import db
class Review(db.Model):
id = db.Column(db.Integer, primary_key=True)
rating = db.Column(db.Integer, nullable=False) # 1-5 stars
content = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
book_id = db.Column(db.Integer, db.ForeignKey('book.id'), nullable=False)
# Ensure a user can only review a book once
__table_args__ = (
db.UniqueConstraint('user_id', 'book_id', name='uix_user_book_review'),
)
def __repr__(self):
return f''
Step 3: Implement Authentication
Let's implement user authentication:
# app/blueprints/auth/__init__.py
from flask import Blueprint
auth = Blueprint('auth', __name__, url_prefix='/auth')
from app.blueprints.auth import routes
# app/blueprints/auth/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
from app.models import User
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=64)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
password2 = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user is not None:
raise ValidationError('Please use a different username.')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('Please use a different email address.')
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Remember Me')
submit = SubmitField('Sign In')
# app/blueprints/auth/routes.py
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, current_user, login_required
from app.extensions import db
from app.blueprints.auth import auth
from app.blueprints.auth.forms import LoginForm, RegistrationForm
from app.models import User
@auth.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('Congratulations, you are now a registered user!', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', title='Register', form=form)
@auth.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password', 'danger')
return redirect(url_for('auth.login'))
login_user(user, remember=form.remember_me.data)
# Redirect to requested page or default to home page
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('main.index')
return redirect(next_page)
return render_template('auth/login.html', title='Sign In', form=form)
@auth.route('/logout')
def logout():
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('main.index'))
@auth.route('/profile')
@login_required
def profile():
return render_template('auth/profile.html', title='Profile')
Step 4-6: Implement Main Features
Now let's implement the main features of our application, including book management, reading lists, and reviews:
# app/blueprints/main/__init__.py
from flask import Blueprint
main = Blueprint('main', __name__)
from app.blueprints.main import routes
# app/blueprints/main/routes.py
from flask import render_template, redirect, url_for
from app.blueprints.main import main
from app.models import Book, ReadingList, Review
@main.route('/')
def index():
recent_books = Book.query.order_by(Book.created_at.desc()).limit(5).all()
top_rated_books = Book.query.join(Review).group_by(Book.id).order_by(db.func.avg(Review.rating).desc()).limit(5).all()
return render_template('main/index.html', title='Home',
recent_books=recent_books, top_rated_books=top_rated_books)
@main.route('/about')
def about():
return render_template('main/about.html', title='About')
# app/blueprints/books/__init__.py
from flask import Blueprint
books = Blueprint('books', __name__, url_prefix='/books')
from app.blueprints.books import routes
# app/blueprints/books/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, IntegerField, SelectField, SubmitField
from wtforms.validators import DataRequired, Length, Optional, NumberRange, URL
class BookForm(FlaskForm):
title = StringField('Title', validators=[DataRequired(), Length(max=200)])
author = StringField('Author', validators=[DataRequired(), Length(max=100)])
isbn = StringField('ISBN', validators=[Optional(), Length(max=20)])
publisher = StringField('Publisher', validators=[Optional(), Length(max=100)])
publication_year = IntegerField('Publication Year', validators=[Optional(), NumberRange(min=1000, max=2100)])
description = TextAreaField('Description', validators=[Optional()])
genre = StringField('Genre', validators=[Optional(), Length(max=50)])
cover_image = StringField('Cover Image URL', validators=[Optional(), URL()])
submit = SubmitField('Save Book')
class SearchForm(FlaskForm):
query = StringField('Search', validators=[DataRequired()])
category = SelectField('Category', choices=[
('title', 'Title'),
('author', 'Author'),
('genre', 'Genre'),
('isbn', 'ISBN')
])
submit = SubmitField('Search')
class ReviewForm(FlaskForm):
rating = SelectField('Rating', choices=[(1, '1 Star'), (2, '2 Stars'), (3, '3 Stars'),
(4, '4 Stars'), (5, '5 Stars')], coerce=int)
content = TextAreaField('Review', validators=[Optional(), Length(max=1000)])
submit = SubmitField('Submit Review')
# app/blueprints/books/routes.py
from flask import render_template, redirect, url_for, flash, request, abort
from flask_login import current_user, login_required
from app.extensions import db
from app.blueprints.books import books
from app.blueprints.books.forms import BookForm, SearchForm, ReviewForm
from app.models import Book, Review
@books.route('/')
def index():
page = request.args.get('page', 1, type=int)
books_list = Book.query.order_by(Book.title).paginate(page=page, per_page=12)
return render_template('books/index.html', title='All Books', books=books_list)
@books.route('/create', methods=['GET', 'POST'])
@login_required
def create():
form = BookForm()
if form.validate_on_submit():
book = Book(
title=form.title.data,
author=form.author.data,
isbn=form.isbn.data,
publisher=form.publisher.data,
publication_year=form.publication_year.data,
description=form.description.data,
genre=form.genre.data,
cover_image=form.cover_image.data,
user_id=current_user.id
)
db.session.add(book)
db.session.commit()
flash('Your book has been added!', 'success')
return redirect(url_for('books.detail', id=book.id))
return render_template('books/create.html', title='Add Book', form=form)
@books.route('/')
def detail(id):
book = Book.query.get_or_404(id)
review_form = ReviewForm()
user_review = None
if current_user.is_authenticated:
user_review = Review.query.filter_by(user_id=current_user.id, book_id=book.id).first()
return render_template('books/detail.html', title=book.title, book=book,
review_form=review_form, user_review=user_review)
@books.route('//edit', methods=['GET', 'POST'])
@login_required
def edit(id):
book = Book.query.get_or_404(id)
# Check if the current user is the owner of the book
if book.user_id != current_user.id:
abort(403)
form = BookForm()
if form.validate_on_submit():
book.title = form.title.data
book.author = form.author.data
book.isbn = form.isbn.data
book.publisher = form.publisher.data
book.publication_year = form.publication_year.data
book.description = form.description.data
book.genre = form.genre.data
book.cover_image = form.cover_image.data
db.session.commit()
flash('Your book has been updated!', 'success')
return redirect(url_for('books.detail', id=book.id))
elif request.method == 'GET':
form.title.data = book.title
form.author.data = book.author
form.isbn.data = book.isbn
form.publisher.data = book.publisher
form.publication_year.data = book.publication_year
form.description.data = book.description
form.genre.data = book.genre
form.cover_image.data = book.cover_image
return render_template('books/edit.html', title='Edit Book', form=form, book=book)
@books.route('//delete', methods=['POST'])
@login_required
def delete(id):
book = Book.query.get_or_404(id)
# Check if the current user is the owner of the book
if book.user_id != current_user.id:
abort(403)
db.session.delete(book)
db.session.commit()
flash('Your book has been deleted!', 'success')
return redirect(url_for('books.index'))
@books.route('/search', methods=['GET', 'POST'])
def search():
form = SearchForm()
results = []
if form.validate_on_submit() or request.args.get('query'):
query = form.query.data if form.validate_on_submit() else request.args.get('query')
category = form.category.data if form.validate_on_submit() else request.args.get('category', 'title')
if category == 'title':
results = Book.query.filter(Book.title.ilike(f'%{query}%')).all()
elif category == 'author':
results = Book.query.filter(Book.author.ilike(f'%{query}%')).all()
elif category == 'genre':
results = Book.query.filter(Book.genre.ilike(f'%{query}%')).all()
elif category == 'isbn':
results = Book.query.filter(Book.isbn.ilike(f'%{query}%')).all()
return render_template('books/search.html', title='Search Books', form=form, results=results)
@books.route('//review', methods=['POST'])
@login_required
def add_review(id):
book = Book.query.get_or_404(id)
form = ReviewForm()
if form.validate_on_submit():
# Check if user already has a review for this book
existing_review = Review.query.filter_by(user_id=current_user.id, book_id=book.id).first()
if existing_review:
# Update existing review
existing_review.rating = form.rating.data
existing_review.content = form.content.data
flash('Your review has been updated!', 'success')
else:
# Create new review
review = Review(
rating=form.rating.data,
content=form.content.data,
user_id=current_user.id,
book_id=book.id
)
db.session.add(review)
flash('Your review has been added!', 'success')
db.session.commit()
return redirect(url_for('books.detail', id=book.id))
# app/blueprints/reading_lists/__init__.py
from flask import Blueprint
reading_lists = Blueprint('reading_lists', __name__, url_prefix='/reading-lists')
from app.blueprints.reading_lists import routes
# app/blueprints/reading_lists/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length, Optional
class ReadingListForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=100)])
description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
submit = SubmitField('Save')
# app/blueprints/reading_lists/routes.py
from flask import render_template, redirect, url_for, flash, request, abort
from flask_login import current_user, login_required
from app.extensions import db
from app.blueprints.reading_lists import reading_lists
from app.blueprints.reading_lists.forms import ReadingListForm
from app.models import ReadingList, Book
@reading_lists.route('/')
@login_required
def index():
lists = ReadingList.query.filter_by(user_id=current_user.id).all()
return render_template('reading_lists/index.html', title='My Reading Lists', reading_lists=lists)
@reading_lists.route('/create', methods=['GET', 'POST'])
@login_required
def create():
form = ReadingListForm()
if form.validate_on_submit():
reading_list = ReadingList(
name=form.name.data,
description=form.description.data,
user_id=current_user.id
)
db.session.add(reading_list)
db.session.commit()
flash('Your reading list has been created!', 'success')
return redirect(url_for('reading_lists.detail', id=reading_list.id))
return render_template('reading_lists/create.html', title='Create Reading List', form=form)
@reading_lists.route('/')
@login_required
def detail(id):
reading_list = ReadingList.query.get_or_404(id)
# Ensure the current user is the owner of the reading list
if reading_list.user_id != current_user.id:
abort(403)
return render_template('reading_lists/detail.html', title=reading_list.name, reading_list=reading_list)
@reading_lists.route('//edit', methods=['GET', 'POST'])
@login_required
def edit(id):
reading_list = ReadingList.query.get_or_404(id)
# Ensure the current user is the owner of the reading list
if reading_list.user_id != current_user.id:
abort(403)
form = ReadingListForm()
if form.validate_on_submit():
reading_list.name = form.name.data
reading_list.description = form.description.data
db.session.commit()
flash('Your reading list has been updated!', 'success')
return redirect(url_for('reading_lists.detail', id=reading_list.id))
elif request.method == 'GET':
form.name.data = reading_list.name
form.description.data = reading_list.description
return render_template('reading_lists/edit.html', title='Edit Reading List', form=form, reading_list=reading_list)
@reading_lists.route('//delete', methods=['POST'])
@login_required
def delete(id):
reading_list = ReadingList.query.get_or_404(id)
# Ensure the current user is the owner of the reading list
if reading_list.user_id != current_user.id:
abort(403)
db.session.delete(reading_list)
db.session.commit()
flash('Your reading list has been deleted!', 'success')
return redirect(url_for('reading_lists.index'))
@reading_lists.route('//add/', methods=['POST'])
@login_required
def add_book(list_id, book_id):
reading_list = ReadingList.query.get_or_404(list_id)
book = Book.query.get_or_404(book_id)
# Ensure the current user is the owner of the reading list
if reading_list.user_id != current_user.id:
abort(403)
# Check if the book is already in the reading list
if book in reading_list.books:
flash('This book is already in the reading list.', 'info')
else:
reading_list.books.append(book)
db.session.commit()
flash('Book added to the reading list!', 'success')
return redirect(url_for('reading_lists.detail', id=reading_list.id))
@reading_lists.route('//remove/', methods=['POST'])
@login_required
def remove_book(list_id, book_id):
reading_list = ReadingList.query.get_or_404(list_id)
book = Book.query.get_or_404(book_id)
# Ensure the current user is the owner of the reading list
if reading_list.user_id != current_user.id:
abort(403)
# Check if the book is in the reading list
if book in reading_list.books:
reading_list.books.remove(book)
db.session.commit()
flash('Book removed from the reading list.', 'success')
else:
flash('This book is not in the reading list.', 'info')
return redirect(url_for('reading_lists.detail', id=reading_list.id))
Step 7: Design and Implement API
Now let's implement our RESTful API:
# app/blueprints/api/__init__.py
from flask import Blueprint
api_bp = Blueprint('api', __name__)
from app.blueprints.api import resources
# app/blueprints/api/schemas.py
from app.extensions import ma
from app.models import User, Book, ReadingList, Review
from marshmallow import fields, validates, ValidationError
class UserSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = User
exclude = ('password_hash',)
load_instance = True
class ReviewSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Review
include_fk = True
load_instance = True
author = fields.Nested('UserSchema', only=('id', 'username'))
class BookSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Book
include_fk = True
load_instance = True
reviews = fields.Nested(ReviewSchema, many=True, exclude=('book',))
owner = fields.Nested('UserSchema', only=('id', 'username'))
average_rating = fields.Float()
@validates('publication_year')
def validate_year(self, value):
if value and (value < 1000 or value > 2100):
raise ValidationError('Publication year must be between 1000 and 2100')
class ReadingListSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = ReadingList
include_fk = True
load_instance = True
books = fields.Nested(BookSchema, many=True, exclude=('reviews', 'reading_lists'))
owner = fields.Nested('UserSchema', only=('id', 'username'))
# Create schema instances
user_schema = UserSchema()
users_schema = UserSchema(many=True)
book_schema = BookSchema()
books_schema = BookSchema(many=True)
review_schema = ReviewSchema()
reviews_schema = ReviewSchema(many=True)
reading_list_schema = ReadingListSchema()
reading_lists_schema = ReadingListSchema(many=True)
# app/blueprints/api/resources.py
from flask import request, jsonify, g
from flask_restful import Api, Resource, abort
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
from app.extensions import db
from app.blueprints.api import api_bp
from app.blueprints.api.schemas import (
user_schema, users_schema, book_schema, books_schema,
review_schema, reviews_schema, reading_list_schema, reading_lists_schema
)
from app.models import User, Book, ReadingList, Review
from marshmallow import ValidationError
# Set up authentication
basic_auth = HTTPBasicAuth()
token_auth = HTTPTokenAuth()
@basic_auth.verify_password
def verify_password(username, password):
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
g.user = user
return user
@token_auth.verify_token
def verify_token(token):
# In a real app, you would implement token verification here
# For simplicity, we'll use basic auth for this example
return None
# Create the API
api = Api(api_bp)
# Resource classes
class BookListAPI(Resource):
def get(self):
# Get query parameters for filtering
author = request.args.get('author')
genre = request.args.get('genre')
# Start with base query
query = Book.query
# Apply filters
if author:
query = query.filter(Book.author.ilike(f'%{author}%'))
if genre:
query = query.filter(Book.genre.ilike(f'%{genre}%'))
# Execute query
books = query.all()
return books_schema.dump(books)
@basic_auth.login_required
def post(self):
try:
# Get JSON data
json_data = request.get_json()
# Ensure user_id is set to current user
json_data['user_id'] = g.user.id
# Validate and deserialize input
book = book_schema.load(json_data)
# Save the book
db.session.add(book)
db.session.commit()
return book_schema.dump(book), 201
except ValidationError as err:
return {'errors': err.messages}, 400
class BookAPI(Resource):
def get(self, id):
book = Book.query.get_or_404(id)
return book_schema.dump(book)
@basic_auth.login_required
def put(self, id):
book = Book.query.get_or_404(id)
# Check if the current user is the owner
if book.user_id != g.user.id:
abort(403, message="Permission denied")
try:
# Get JSON data
json_data = request.get_json()
# Validate and deserialize input
book = book_schema.load(json_data, instance=book, partial=True)
# Save the book
db.session.commit()
return book_schema.dump(book)
except ValidationError as err:
return {'errors': err.messages}, 400
@basic_auth.login_required
def delete(self, id):
book = Book.query.get_or_404(id)
# Check if the current user is the owner
if book.user_id != g.user.id:
abort(403, message="Permission denied")
db.session.delete(book)
db.session.commit()
return '', 204
class ReadingListListAPI(Resource):
@basic_auth.login_required
def get(self):
reading_lists = ReadingList.query.filter_by(user_id=g.user.id).all()
return reading_lists_schema.dump(reading_lists)
@basic_auth.login_required
def post(self):
try:
# Get JSON data
json_data = request.get_json()
# Ensure user_id is set to current user
json_data['user_id'] = g.user.id
# Validate and deserialize input
reading_list = reading_list_schema.load(json_data)
# Save the reading list
db.session.add(reading_list)
db.session.commit()
return reading_list_schema.dump(reading_list), 201
except ValidationError as err:
return {'errors': err.messages}, 400
class ReadingListAPI(Resource):
@basic_auth.login_required
def get(self, id):
reading_list = ReadingList.query.get_or_404(id)
# Check if the current user is the owner
if reading_list.user_id != g.user.id:
abort(403, message="Permission denied")
return reading_list_schema.dump(reading_list)
@basic_auth.login_required
def put(self, id):
reading_list = ReadingList.query.get_or_404(id)
# Check if the current user is the owner
if reading_list.user_id != g.user.id:
abort(403, message="Permission denied")
try:
# Get JSON data
json_data = request.get_json()
# Validate and deserialize input
reading_list = reading_list_schema.load(json_data, instance=reading_list, partial=True)
# Save the reading list
db.session.commit()
return reading_list_schema.dump(reading_list)
except ValidationError as err:
return {'errors': err.messages}, 400
@basic_auth.login_required
def delete(self, id):
reading_list = ReadingList.query.get_or_404(id)
# Check if the current user is the owner
if reading_list.user_id != g.user.id:
abort(403, message="Permission denied")
db.session.delete(reading_list)
db.session.commit()
return '', 204
class ReadingListBookAPI(Resource):
@basic_auth.login_required
def post(self, list_id, book_id):
reading_list = ReadingList.query.get_or_404(list_id)
book = Book.query.get_or_404(book_id)
# Check if the current user is the owner of the reading list
if reading_list.user_id != g.user.id:
abort(403, message="Permission denied")
# Check if the book is already in the reading list
if book in reading_list.books:
return {'message': 'Book is already in this reading list'}, 400
# Add book to reading list
reading_list.books.append(book)
db.session.commit()
return {'message': 'Book added to reading list'}, 201
@basic_auth.login_required
def delete(self, list_id, book_id):
reading_list = ReadingList.query.get_or_404(list_id)
book = Book.query.get_or_404(book_id)
# Check if the current user is the owner of the reading list
if reading_list.user_id != g.user.id:
abort(403, message="Permission denied")
# Check if the book is in the reading list
if book not in reading_list.books:
return {'message': 'Book is not in this reading list'}, 400
# Remove book from reading list
reading_list.books.remove(book)
db.session.commit()
return '', 204
class ReviewListAPI(Resource):
def get(self, book_id):
book = Book.query.get_or_404(book_id)
reviews = book.reviews.all()
return reviews_schema.dump(reviews)
@basic_auth.login_required
def post(self, book_id):
book = Book.query.get_or_404(book_id)
try:
# Get JSON data
json_data = request.get_json()
# Ensure user_id and book_id are set
json_data['user_id'] = g.user.id
json_data['book_id'] = book_id
# Check if the user already has a review for this book
existing_review = Review.query.filter_by(user_id=g.user.id, book_id=book_id).first()
if existing_review:
# Update existing review
review = review_schema.load(json_data, instance=existing_review, partial=True)
message = 'Review updated'
else:
# Create new review
review = review_schema.load(json_data)
db.session.add(review)
message = 'Review created'
db.session.commit()
return {'message': message, 'review': review_schema.dump(review)}, 201
except ValidationError as err:
return {'errors': err.messages}, 400
class ReviewAPI(Resource):
def get(self, id):
review = Review.query.get_or_404(id)
return review_schema.dump(review)
@basic_auth.login_required
def put(self, id):
review = Review.query.get_or_404(id)
# Check if the current user is the author
if review.user_id != g.user.id:
abort(403, message="Permission denied")
try:
# Get JSON data
json_data = request.get_json()
# Validate and deserialize input
review = review_schema.load(json_data, instance=review, partial=True)
# Save the review
db.session.commit()
return review_schema.dump(review)
except ValidationError as err:
return {'errors': err.messages}, 400
@basic_auth.login_required
def delete(self, id):
review = Review.query.get_or_404(id)
# Check if the current user is the author
if review.user_id != g.user.id:
abort(403, message="Permission denied")
db.session.delete(review)
db.session.commit()
return '', 204
# Register resources
api.add_resource(BookListAPI, '/books')
api.add_resource(BookAPI, '/books/')
api.add_resource(ReadingListListAPI, '/reading-lists')
api.add_resource(ReadingListAPI, '/reading-lists/')
api.add_resource(ReadingListBookAPI, '/reading-lists//books/')
api.add_resource(ReviewListAPI, '/books//reviews')
api.add_resource(ReviewAPI, '/reviews/')
Step 8: Templates and Styling
We need to create templates for our application. For the sake of brevity, we'll only show a few key templates:
{% block title %}Bookshelf{% endblock %}
{% block styles %}{% endblock %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
{{ message }}
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
{% extends 'base.html' %}
{% block content %}
Welcome to Bookshelf
Your personal library management system
{% if not current_user.is_authenticated %}
Join today to track your books, create reading lists, and more!
Sign Up Now
{% endif %}
Recently Added Books
{% for book in recent_books %}
{% if book.cover_image %}
{% else %}
{% endif %}
{% endfor %}
Top Rated Books
{% for book in top_rated_books %}
{% if book.cover_image %}
{% else %}
{% endif %}
{{ book.title }}
{{ book.author }}
{% for i in range(5) %}
{% if i < book.average_rating|int %}
{% elif i < book.average_rating|round(0, 'ceil')|int and (book.average_rating % 1) > 0 %}
{% else %}
{% endif %}
{% endfor %}
({{ book.average_rating|round(1) }})
View Details
{% endfor %}
{% endblock %}
Step 9: Testing and Debugging
For testing our application, we can use Flask's built-in test client along with pytest. Here's a sample test for the authentication system:
# tests/test_auth.py
import pytest
from app import create_app, db
from app.models import User
from app.config import Config
class TestConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite://' # Use in-memory database for testing
@pytest.fixture
def client():
app = create_app(TestConfig)
with app.test_client() as client:
with app.app_context():
db.create_all()
yield client
db.session.remove()
db.drop_all()
def test_register(client):
response = client.post('/auth/register', data={
'username': 'testuser',
'email': 'test@example.com',
'password': 'testpassword',
'password2': 'testpassword'
}, follow_redirects=True)
assert response.status_code == 200
assert b'Congratulations, you are now a registered user!' in response.data
# Check if user was created in database
with client.application.app_context():
user = User.query.filter_by(username='testuser').first()
assert user is not None
assert user.email == 'test@example.com'
assert user.check_password('testpassword')
def test_login_logout(client):
# Create a user
with client.application.app_context():
user = User(username='testuser', email='test@example.com')
user.set_password('testpassword')
db.session.add(user)
db.session.commit()
# Test login
response = client.post('/auth/login', data={
'username': 'testuser',
'password': 'testpassword',
'remember_me': False
}, follow_redirects=True)
assert response.status_code == 200
# Test protected page access
response = client.get('/reading-lists/')
assert response.status_code == 200
# Test logout
response = client.get('/auth/logout', follow_redirects=True)
assert response.status_code == 200
assert b'You have been logged out.' in response.data
# Test protected page access after logout
response = client.get('/reading-lists/')
assert response.status_code == 302 # Redirect to login
To run the tests:
$ python -m pytest -v
4. Look Back
Now that we've implemented our application, let's evaluate our solution and think about improvements
and lessons learned.
What We've Accomplished
-
Created a complete Flask web application with a modular structure using blueprints
-
Implemented a database model with relationships between users, books, reading lists, and reviews
-
Added user authentication with registration, login, and profile management
-
Built CRUD functionality for books and reading lists
-
Created a review and rating system
-
Implemented search functionality
-
Developed a RESTful API with proper authentication and validation
-
Created responsive UI templates with Bootstrap
-
Added tests for key functionality
Areas for Improvement
-
Security Enhancements: Implement CSRF protection for all forms, add rate limiting to the API,
use HTTPS in production
-
User Experience: Add pagination for large collections, improve mobile responsiveness,
add client-side validation, implement AJAX for a smoother experience
-
Features: Add book recommendations, social features (sharing, following users),
import/export functionality, book categories and tags
-
Architecture: Improve error handling, add logging, enhance API authentication with tokens,
optimize database queries
-
Testing: Increase test coverage, add integration and UI tests, implement CI/CD pipeline
Lessons Learned
-
Flask's Flexibility: Flask's modular design allows for structuring applications in a way
that makes sense for your specific project
-
SQLAlchemy Power: The ORM makes it easy to define complex relationships between models
and work with the database
-
Blueprint Organization: Using blueprints helps keep the code organized and maintainable
as the application grows
-
REST API Design: Well-structured APIs with proper validation and error handling improve
the developer experience
-
Authentication Complexity: User authentication requires careful implementation to ensure
security and a good user experience
Final Thoughts
This project showcases how Flask can be used to create a feature-rich web application with both
a user-friendly web interface and a robust API. By following George Polya's problem-solving approach,
we were able to break down a complex task into manageable steps and systematically implement a solution.
The project incorporates many of the Flask concepts covered in Module 17, including routing, templates,
database operations, forms, blueprints, and APIs. The modular structure makes it easy to add new features
or make improvements in the future.
As a weekend project, this implementation focuses on core functionality while leaving room for
enhancements and optimizations. In a real-world scenario, you would likely spend more time on
testing, security, performance, and user experience before deploying to production.
Conclusion
Congratulations on completing the Flask Weekend Project! You've built a complete web application
that demonstrates your understanding of Flask and related technologies. This project has given
you hands-on experience with:
- Structuring a Flask application with blueprints
- Working with databases using SQLAlchemy
- Implementing user authentication
- Creating and validating forms
- Building RESTful APIs
- Templating with Jinja2
- Testing Flask applications
As you continue your journey in web development, remember that this project can serve as a
foundation for more complex applications. The skills you've developed here are directly
applicable to real-world web projects.
Happy coding!