Skip to content
Permalink
master
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Go to file
 
 
Cannot retrieve contributors at this time
import os.path
import secrets
from flask import Flask, render_template, url_for, redirect, flash, abort, session, request
from flask_wtf import FlaskForm
from flask_wtf.csrf import CSRFProtect
from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms import validators, StringField, SelectField, EmailField, PasswordField, SubmitField, DateField, \
TextAreaField, IntegerRangeField, DecimalRangeField, HiddenField
from database import check_stock_from_isbn, register_user, get_access_type, get_book_info, username_unique, \
email_unique, add_book_to_db, get_sl_details, get_home_data, get_quantity, get_price
from isbn13 import check_isbn13, remove_hyphens
from make_db import init_db
from sessions import set_session_type, is_admin
init_db() # Create user and book databases on site startup
# Ideally this would be hosted on an online service but that is beyond the scope of this project
app = Flask(__name__)
app.config['DEBUG'] = True # Allow flask to automatically restart on changes, saving us from restarting it manually
app.secret_key = secrets.token_hex() # This is Flask's preferred method - https://flask.palletsprojects.com/en/2.0.x/quickstart/#sessions
# Alternative could be secrets.token_urlsafe(32) from the lab 2 worksheet on AULA.
csrf = CSRFProtect(app) # Protect the app against CSRF - https://flask-wtf.readthedocs.io/en/1.0.x/csrf/
"""
WTForms: Documentation for Forms and Fields
Forms - https://wtforms.readthedocs.io/en/3.0.x/forms/#the-form-class
Fields - https://wtforms.readthedocs.io/en/3.0.x/fields/#module-wtforms.fields
"""
# The Form for our custom Forms are FlaskForm - https://flask-wtf.readthedocs.io/en/0.15.x/api/#flask_wtf.FlaskForm
# This offers some CSRF protection and handles data such as file uploads better.
# Each Field is provided by the base WTForms, which has validators which is useful for client-side validation.
class RegistrationForm(FlaskForm):
# Defining forms - https://wtforms.readthedocs.io/en/3.0.x/forms/#defining-forms
# Validators - https://wtforms.readthedocs.io/en/3.0.x/validators/
firstname = StringField('First Name', [validators.input_required(),
validators.Length(min=1, max=35)])
middlename = StringField('Middle Name', [validators.optional()])
surname = StringField('Surname', [validators.input_required(),
validators.Length(min=1, max=35)])
gender = SelectField('Gender', choices=['', 'Male', 'Female', 'Other'],
validators=[validators.optional()])
postcode = StringField('Postcode', [validators.input_required(),
validators.length(min=5, max=7)])
address = StringField('Address', [validators.input_required()])
username = StringField('Username', [validators.input_required(),
validators.length(min=3, max=14)])
email = EmailField('Email Address', [validators.input_required(),
validators.Length(min=6, max=254)])
# The 254 is from https://stackoverflow.com/a/574698 which says emails cannot be bigger than 254 characters.
password_set = PasswordField('Set Password',
[validators.input_required(),
validators.Length(min=6, max=32,
message="Password must be between 10 and 32 characters"),
validators.equal_to('password_repeat',
message="Passwords need to match.")])
password_repeat = PasswordField('Repeat Password', [validators.input_required()])
class SubmitButton(FlaskForm):
button = SubmitField("Submit")
class LoginForm(FlaskForm):
username = StringField('Username', [validators.input_required(),
validators.length(min=3, max=14)])
password = PasswordField("Password", [validators.input_required()])
class ISBNForm(FlaskForm):
isbn13_input = StringField("ISBN13",
[validators.input_required(message="Please enter an ISBN."),
validators.length(min=13, max=17, message="ISBN entered is not a valid length.")])
button = SubmitField("Submit")
class BookForm(FlaskForm):
title = StringField("Book Title",
[validators.input_required(),
validators.length(min=1, max=200)])
author_firstname = StringField("Author First Name",
[validators.input_required(),
validators.length(min=1, max=35)])
author_middlename = StringField("Author Middle Name",
[validators.optional()])
author_surname = StringField("Author Surname",
[validators.input_required(),
validators.length(min=1, max=35)])
publisher = StringField("Publisher",
[validators.optional()])
pub_date = DateField("Publication Date",
[validators.input_required()])
isbn13 = StringField("ISBN13",
[validators.input_required(message="Please enter an ISBN."),
validators.length(min=13, max=17, message="ISBN entered is not a valid length.")],
render_kw={'readonly': False}, # Read Only field https://stackoverflow.com/a/43215676
id="ISBN")
description = TextAreaField("Multiline Description",
[validators.input_required()]) # Multi-Line field https://stackoverflow.com/a/60021262
picture = FileField("Front Cover Image", [FileRequired(), FileAllowed(['jpg', 'png'], "Only png and jpg allowed")])
trade_price = DecimalRangeField('Trade Price',
[validators.NumberRange(min=0, max=100),
validators.input_required()], default=1, id="trade_slider",
render_kw={'step': 0.01}) # limit decimal https://stackoverflow.com/a/22641142
retail_price = DecimalRangeField('Retail Price',
[validators.NumberRange(min=0, max=100),
validators.input_required()], default=1, id="retail_slider",
render_kw={'step': 0.01})
quantity = IntegerRangeField('Quantity',
[validators.NumberRange(min=0, max=20),
validators.input_required()], default=1, id="quantity_slider")
class HomeForm(FlaskForm):
ID = HiddenField('', validators=[validators.data_required()])
number = StringField('Enter Amount:', [validators.input_required()], default=0)
cart_btn = SubmitField("ADD TO CART")
@app.route("/", methods=['GET', 'POST'])
def homepage():
"""
Renders the homepage, which lies at the index of the site.
Uses the HomeForm for CSRF protection only.\n
Input is instead validated serverside by replacing blank forms with the integer 0 and by checking if the value submitted is numeric.\n
The data for the product grid retrieved from the function get_home_data()\n
:return: The template homepage.html with appropriate error messages once data has been entered.
"""
form = HomeForm() # HomeForm for CSRF protection
if request.method == "POST":
data = request.form.to_dict() # to_dict from https://stackoverflow.com/a/45713753
data.pop('csrf_token') # remove csrf token as it is not needed for cart functionality
data.pop('ID') # Remove ID part of the form too.
for key, value in data.items(): # replace blank items with 0 whilst still preserving the associated ISBNs
if value == '': # If the value is blank
data[key] = 0 # replace it with 0
else: # if it is not blank
if not value.isnumeric(): # Check if it contains a letter
flash("Please only enter numbers.") # if it does, notify the user
hp_data, hp_cols = get_home_data() # reload the webpage
return render_template("homepage.html", form=form, data=hp_data, cols=hp_cols)
else: # otherwise
data[key] = int(data[key]) # convert the value to an integer
add_item_to_cart(data) # Add the item to the cart if the values are ok
flash("Items added to cart") # Notify the user and continue to reload the homescreen.
hp_data, hp_cols = get_home_data() # Get data needed to construct the product grid
if hp_data is None: # If there is no data
return render_template("homepage.html", cols=hp_cols) # Do not pass the data, the template can state the database is empty
return render_template("homepage.html", form=form, data=hp_data, cols=hp_cols)
@app.route("/cart")
def cart():
"""
Show the shopping cart and get more related information.
"""
cartdata = session['cart']
quantities = {}
for isbn, num_in_cart in cartdata.items():
quantities[isbn] = get_quantity(int(isbn)) # Get the number of books in stock for each item in the cart
uncombined = [cartdata, quantities]
combined = {}
# Combine dictionaries - https://stackoverflow.com/a/5946359
for x in cartdata.keys():
combined[x] = tuple([combined[x] for combined in uncombined])
# Combine the data in the shopping cart with the data from the database.
return render_template("cart.html")
@app.route("/empty_cart")
def empty_cart():
"""
Empty the shopping cart session and redirect back to the shopping cart page.
"""
session.pop('cart')
redirect(url_for("cart"))
def add_item_to_cart(data):
"""
Add the data from the homepage's product grid to a shopping cart session.
"""
session['cart'] = data
@app.route("/register", methods=['GET', 'POST'])
def registration():
"""
Handles user registration for the website.
Uses the RegistrationForm and SubmitButton forms.
If the request is a POST request:\n
- All forms are first validated client-side and checked using .validate_on_submit() provided by Flask-WTF
- The username and email are then checked using the username_unique and email_unique functions, with validation stopping and an error message appearing on the website accordingly.
- If the registration goes through successfully, the user is redirected to the registration_success endpoint.
:return: A template with the RegistrationForm and a separate submit button.
"""
reg_form = RegistrationForm() # Set up required forms
submit = SubmitButton()
if reg_form.validate_on_submit(): # If a submit has been recieved, validate the forms.
username = reg_form['username'] # Get the username field
if username_unique(username.data): # Check the username in the database to see if it is unique
email = reg_form['email'] # If the username is unique, check the email
if email_unique(email.data):
register_user(reg_form.data) # Register only if both are unique
return redirect(url_for("registration_success", reg=True))
else:
flash("Email is already in use.") # Notify the user if the email is already in use
else:
flash("Username is already in use.") # Notify the user if the username is already in use
return render_template('register.html', form=reg_form, submitbtn=submit)
else:
return render_template('register.html', form=reg_form, submitbtn=submit)
@app.route("/successful_registration/<reg>")
def registration_success(reg):
"""
Renders the successful registration webpage, and is only run after a successful registration.
If this endpoint was not loaded after a registration, the user is redirected to the registration page.
:return: The registered.html template on successful registration, or a redirect back to the registration page if not called after a registration.
"""
if reg == False:
return redirect(url_for("registration"))
return render_template("registered.html")
@app.route("/login", methods=['GET', 'POST'])
def login():
"""
Handles log-in for the website.\n
Uses the LoginForm and SubmitButton forms.\n
- On a POST request, username and password is retrieved and stored.
- The combination is then checked with the set_cookies function
- If this returns True, then the user will be redirected to the successful_login endpoint
- If this returns False, the user will be notified by a message on the website, and have to re-enter log-in information.
:return:
"""
login_form = LoginForm()
submit = SubmitButton()
submit.button.id = "#login_btn"
submit.button.label.text = "Log In"
if login_form.validate_on_submit():
uname = login_form.data['username']
pwd = login_form.data['password']
if set_cookies(uname, pwd): # If this returns true, everything is valid so the user can be logged in.
return redirect(url_for("login_success", loggedin=True))
else:
flash("Incorrect username or password!")
return render_template("login.html", form=login_form, submitbtn=submit)
return render_template("login.html", form=login_form, submitbtn=submit)
def set_cookies(uname, pwd):
"""
Takes a username and password pair, and sets the appropriate session variables for the user.\n
Runs the get_access_type function to query the database on the backend.\n
If this returns 401, then the function will return False.
:param uname: Username from the login form
:param pwd: Password from the login form
:return: True if session cookies have been set, False if the details are wrong.
"""
usertype = get_access_type(uname, pwd)
if usertype == '401':
return False
if usertype == 'admin':
session['username'] = uname
set_session_type(session, 'admin')
return True
elif usertype == 'normal':
session['username'] = uname
set_session_type(session, 'normal')
return True
else:
return False
@app.route("/login_success/<loggedin>")
def login_success(loggedin):
"""
Renders the successful login webpage, and is only run after a successful log-in.
If this endpoint was not loaded immediately after a log-in, the user is redirected to the homepage.
:return: The login_success.html template after a successful log-in, or a redirect back to the homepage.
"""
if not loggedin:
return redirect(url_for("homepage"))
return render_template("login_success.html")
@app.route("/logout")
def logout():
"""
Clears all session cookies that have been set by the set_cookies function.
:return: A redirect to the homepage.
"""
session.pop('username', None) # delete session data - https://overiq.com/flask-101/sessions-in-flask/
session.pop('usertype', None)
return redirect(url_for("homepage"))
@app.route("/stock")
def stock_index():
"""
Redirects to the stock_view page.
:return: A redirect to the view_stock endpoint
"""
return redirect(url_for("view_stock"))
@app.route("/stock/view")
def view_stock():
"""
Renders the stock_report.html webpage. This is for the Stock Levels screen.\n
An admin account is required to view this page, and is performed by calling the function is_admin.
:return: Template for stock levels, 403 error if the user is not an admin
"""
if is_admin(session): # If the user is an admin
data, cols = get_sl_details() # Get details for the Stock Levels screen
if data is None: # If the database is empty
return render_template("stock_report.html") # do not pass anything to the webpage
return render_template("stock_report.html", data=data, cols=cols) # otherwise it is ok to show the information
else:
return abort(403) # 403 forbidden if the user is not an admin
@app.route("/stock/update", methods=['GET', 'POST'])
def update_stock():
"""
Handles adding books to the website.
Uses the ISBNForm which accepts a 13-17 digit ISBN (includes hyphens).\n
On a POST request, the ISBN13 entered is validated using the check_isbn function.\n
- If the ISBN13 is valid, the user is redirected to book update page where additional information can be viewed. The ISBN is removed of hyphens using the remove_hyphens function.
- If the ISBN13 is invalid, the template is reloaded, and a message appears on-screen to notify the user.
If the user is not an admin, a 403 error is displayed.
:return: 403 if the user is not an admin. redirect to update_book on valid ISBN, same webpage on invalid ISBN and GET request.
"""
if not is_admin(session):
return abort(403)
isbnform = ISBNForm()
if isbnform.validate_on_submit():
if check_isbn13(isbnform.data['isbn13_input']): # Runs the script to check if the ISBN is valid
return redirect(url_for("update_book", isbn13=remove_hyphens(isbnform.data['isbn13_input'])))
else:
flash("Invalid ISBN entered", category="error")
return render_template("stock_add.html", form=isbnform)
return render_template("stock_add.html", form=isbnform)
@app.route("/stock/update/<isbn13>", methods=['GET', 'POST'])
def update_book(isbn13):
"""
Handles viewing specific information for a book, given its ISBN13\n
- If the ISBN is in the database, the details for the book are displayed using the book_details.html page.\n
- If it is not in the database, the details not displayed.\n
On both cases, a link is added which redirects to the add_book endpoint where they can add information.\n
:param isbn13: The ISBN13 entered by the admin in update_stock()
:return: Template for book_details with values for if the book in the database or not. 403 If accessed by a non-admin user.
"""
if not is_admin(session):
return abort(403)
if check_stock_from_isbn(remove_hyphens(isbn13)) == True: # Checks the database for stock matching the ISBN
book_info = get_book_info(isbn13) # Since there is a matching ISBN, we can get it's details.
return render_template("book_details.html", bookID=isbn13, book_found=True, book_info=book_info)
else:
return render_template("book_details.html", bookID=isbn13, book_found=False)
@app.route("/stock/update/<isbn13>/add", methods=["GET", "POST"])
def add_book(isbn13):
"""
UI form for adding book details.\n
Uses BookForm for storing user input and validating information client-side.\n
On a POST request, data entered is added to the database by the add_book_to_db function.\n
Admin access is required.
:param isbn13: The ISBN13 passed through update_book as the parameter bookID.
:return:
"""
if not is_admin(session):
return abort(403)
book_form = BookForm()
book_form.isbn13.data = isbn13 # Autofills the isbn13 field, and makes it read-only in the template.
submitbtn = SubmitButton()
if book_form.validate_on_submit():
cover = book_form.picture.data # Flask-WTF offers this method to make it easier to obtain uploaded files.
add_book_to_db(book_form.data, cover) # Add the book to the database.
print("BOOK_ADDED", isbn13)
return render_template("book_add.html", isbn13=isbn13, form=book_form, submitbtn=submitbtn, book_added=True)
return render_template("book_add.html", isbn13=isbn13, form=book_form, submitbtn=submitbtn, book_added=False)
@app.errorhandler(401)
def unauthorised(error):
"""
Error handler for 401 Unauthorised errors.\n
Called by abort(401)
:param error:
:return:
"""
return render_template('401_error.html'), 401
@app.errorhandler(403)
def wrong_details(error):
"""
Error handler for 403 Forbidden errors.\n
Called by abort(403)
:param error:
:return:
"""
return render_template('403_error.html'), 403
if __name__ == "__main__":
app.run()