diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7da2033 --- /dev/null +++ b/.gitignore @@ -0,0 +1,156 @@ +# ignore custom virtual environments, not just /venv/ mentioned in the template below. +venvLAPTOP/ +venvPC/ +venvCODIO/ + +# ignore databases and images from being committed +db/USER_DB.sqlite3 +db/BOOK_DB.sqlite3 +static/img/ + +# ignore PyCharm folder +.idea/* + +# ignore the folder for included files and documentation +external/ + +# Rest of .gitignore from https://github.com/github/gitignore/blob/master/Python.gitignore + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cda0420 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# bad_bookshop + +## Project 2: Bookshop website +This repository contains the code for my submission of the 5001CEM Project (Bookshop) as part of my coursework + +## Install instructions +These are some instructions to get started with the project. +> Root access to a Python Codio environment is required. + +### Create a Python environment on Codio and open the terminal: +- You can use the 'Open Terminal' button under the Codio project's name, or by clicking Tools > Terminal at the top. + +### Clone the repository and switch into its directory: +- Type `git clone https://github.coventry.ac.uk/5001CEM-2122/bad_bookshop.git` to clone this repository. +- Type `cd bad_bookshop` to switch into the cloned files. +- Type `cd static` to switch into the `static` folder +- Type `mkdir img` to create the folder `img` for saved images. +- Type `cd ..` to return to the `bad_bookshop` folder. + +### Install and activate the virtual environment: +Type the following in order: +1. `sudo apt update` +2. `sudo apt-get install python3-venv` (_Make sure to enter_ `Y` _when prompted_) +3. `python3 -m venv venvCODIO` (`venvCODIO` is the venv name defined in the `.gitignore` file.) +4. `. venvCODIO/bin/activate` + +### Install dependencies from the requirements file: +`pip install -r requirements.txt` + +### Setup and run the flask application using the integrated CLI: +- Type `export FLASK_APP=app` so flask knows where to look to run our code. +- Type `flask run --host=0.0.0.0` to start the flask app. + +### Access the flask app: +1. Click **Project -> Box Info** at the top. +2. Copy the first link under the **WEB: Dynamic Content** section. +3. Paste the link into a browser tab, and change the number at the end to **5000** (It is 80 by default.) +4. Press enter or reload the tab, and you should now be on the index page for the website. diff --git a/app.py b/app.py new file mode 100644 index 0000000..67b886e --- /dev/null +++ b/app.py @@ -0,0 +1,447 @@ +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/") +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/") +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/", 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//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() diff --git a/database.py b/database.py new file mode 100644 index 0000000..9eaeb48 --- /dev/null +++ b/database.py @@ -0,0 +1,365 @@ +import sqlite3 +import os +import datetime + + +def register_user(form_data): + """ + Register the user onto the users database + Should be run after form validation. + + :param form_data: The data values from the registration form + :return: Nothing + """ + + # In the future, the data can be escaped using the escape() function. + + firstname = form_data['firstname'] + middlename = form_data['middlename'] + surname = form_data['surname'] + gender = form_data['gender'] + postcode = form_data['postcode'] + address = form_data['address'] + username = form_data['username'] + email = form_data['email'] + password = form_data['password_set'] + + conn = sqlite3.connect('db/USER_DB.sqlite3') + cur = conn.cursor() + + sql_string = f"INSERT INTO user_info VALUES (NULL,'{username}','{password}','{email}','{firstname}','{middlename}','{surname}','{gender}','{address}','{postcode}',0);" + + cur.execute(sql_string) + cur.close() + conn.commit() + conn.close() + + +def get_access_type(uname, pwd): + """ + Given a username and password, get the associated access level from the database + + :param uname : Username of the user + :param pwd: Password of the user + :return: the access level of the user as a string, or the string 401 if they are not registered + """ + + conn = sqlite3.connect('db/USER_DB.sqlite3') + cur = conn.cursor() + cur.execute("SELECT username FROM user_info WHERE is_admin=1;") + db_admin_usernames = cur.fetchall() # get username values for admin users from db + cur.execute("SELECT username FROM user_info WHERE is_admin=0;") + db_normal_usernames = cur.fetchall() # get username values for normal users from db + + admin_usernames = [] + normal_usernames = [] + + for db_name in db_admin_usernames: # Append the actual values for verification + admin_usernames.append(db_name[0]) + for db_name in db_normal_usernames: + normal_usernames.append(db_name[0]) + + if uname in admin_usernames: # If the username matches an admin one + cur.execute(f"SELECT password FROM user_info WHERE username='{uname}'") + admin_password = cur.fetchone()[0] # select record with same admin username + if pwd == admin_password: # from the selected record, check if the password matches + return 'admin' # if it does, return the admin role + else: # otherwise + cur.close() # close database connections + conn.close() + return '401' # return 401 error + elif uname in normal_usernames: + cur.execute(f"SELECT password FROM user_info WHERE username='{uname}'") + normal_password = cur.fetchone()[0] # select record with same normal username + if pwd == normal_password: # from the selected record, check if the password matches + cur.close() + conn.close() + return 'normal' # if it does, return the normal role + else: # otherwise + cur.close() + conn.close() + return '401' # return 401 error + else: # if the name is not in the database + cur.close() + conn.close() + return '401' # return 401 error + + +def username_unique(username): + """ + Returns a boolean indicating if the username provided is already in the database + + :param username: The username to be checked with the users database + :return: False if the username is in the database, True if the username is not in the database. + """ + conn = sqlite3.connect('db/USER_DB.sqlite3') + cur = conn.cursor() + + cur.execute("SELECT username FROM user_info;") + usernames = cur.fetchall() # get usernames from database + cur.close() + conn.close() + + actual_usernames = [] + for db_username in usernames: + actual_usernames.append(db_username[0]) # get the actual values required for verification + if username in actual_usernames: # if the username is in the usernames field of the database + return False + else: # otherwise the username is unique + return True + + +# noinspection DuplicatedCode +def email_unique(email): + """ + Returns a boolean indicating if the given email is already in the database + + :param email: the email you want to check is already in the database + :return: False if the email is in the database, True if it is not. + """ + + conn = sqlite3.connect('db/USER_DB.sqlite3') + cur = conn.cursor() + cur.execute("SELECT email FROM user_info;") + emails = cur.fetchall() # get emails from database + cur.close() + conn.close() + + actual_emails = [] + for db_email in emails: + actual_emails.append(db_email[0]) + + if email in actual_emails: # if the email is in the email field of the database + return False + else: # otherwise the email is unique + return True + + +def check_stock_from_isbn(code): + """ + Returns a boolean indicating if the book has details for it in the books database + + :param code: The 13 digit ISBN code + :return: True if the book is in the database, False if not. + """ + + conn = sqlite3.connect('db/BOOK_DB.sqlite3') + cur = conn.cursor() + cur.execute("SELECT ISBN FROM book_info;") + db_book_isbns = cur.fetchall() # get list of ISBN13s from database + + book_isbns = [] + for isbn in db_book_isbns: + book_isbns.append(isbn[0]) + + code = int(code) # Convert the code to an integer before checking if it is in the list, as it is currently a string + if code in book_isbns: # if the code is in the list of isbn codes that are in the database + return True + else: # otherwise + return False + + +def get_sl_details(): + """ + Gets stock details for the stock levels screen.\n + These are to display the book's cover, title, ISBN and quantity.\n + The cover's filename will be added for the cover section as these will be in the static folder and we just need the name + + :returns: sl_data which holds tuples in this form - (column_name, value) for the 4 columns. + """ + sl_cols = ["Cover", "Title", "ISBN13", "Quantity Available"] + conn = sqlite3.connect('db/BOOK_DB.sqlite3') + cur = conn.cursor() + + cur.execute("SELECT COUNT(ISBN) FROM book_info;") # First check if there is any data in the database at all + rows = cur.fetchone()[0] + if rows == 0: + return None, sl_cols # If there isn't, don't return any data and only return the column names. + + cur.execute("SELECT FILENAME,TITLE,ISBN,QUANTITY FROM book_info;") + db_data = cur.fetchall() + db_data_list = [] + for record in db_data: # for each record in the database + db_data_list.append(record) # add it to another list which will hold the actual values needed + longlist = [] + for record in db_data_list: # for each list which represents a record + for value in record: # for each value inside of those records + longlist.append(value) # add the value to another list called longlist + sl_data = [] + for i, a in enumerate(longlist): # for the length of the long list of values + sl_data.append((sl_cols[i % 4], a)) # assign the column name to each value as a tuple + return sl_data, sl_cols # return the data and column names as a tuple + + +def get_book_info(isbn13): + """ + Return all details from the database for a given ISBN + + :param isbn13: The 13 digit ISBN code + :return: Book Details and table names zipped into a dict e.g. {ISBN:978...} + """ + + conn = sqlite3.connect('db/BOOK_DB.sqlite3') + cur = conn.cursor() + + cur.execute(f"SELECT * FROM book_info WHERE ISBN={isbn13};") + selected_book = cur.fetchall() # Check the database for the selected book via its isbn13 + column_names = [description[0] for description in + cur.description] # Get column names from the database - https://stackoverflow.com/a/7831685 + cur.close() + conn.close() + + book_info = list( + zip(column_names, selected_book[0])) # Make a list of the column names paired with the actual value needed. + return book_info + + +def get_home_data(): + """ + Gets stock details for the home screen.\n + These are to display the book's cover and title, but ISBN is included for identification. Quantity is also included as it is a useful property to check if the item is in stock\n + The cover's filename will be added for the cover section.\n + The function only returns what is in stock and is related to get_sl_details. + + :returns: hp_data which holds tuples in this form - (column_name, value) + """ + + hp_cols = ["Cover", "Title", "In Stock", "ID"] # Columns needed for the homepage + + conn = sqlite3.connect('db/BOOK_DB.sqlite3') + cur = conn.cursor() + + cur.execute("SELECT COUNT(ISBN) FROM book_info;") + rows = cur.fetchone()[0] + if rows == 0: + return None, hp_cols # If the database is empty, dont return data + + cur.execute("SELECT FILENAME,TITLE,QUANTITY,ISBN FROM book_info WHERE QUANTITY>=1;") + db_data = cur.fetchall() + db_data_list = [] + # SEE get_sl_details(), it is similar in code. + for record in db_data: # for each record in the database + db_data_list.append(record) # convert it into a list and add it to another list which will hold all the values + longlist = [] + for record in db_data_list: # for each list which represents a record + for value in record: # for each value inside of those records + longlist.append(value) # add the value to another list + hp_data = [] + for i, a in enumerate(longlist): # for the length of the long list of values + hp_data.append( + (hp_cols[i % 4], a)) # assign the column name to each value as a tuple, easily found by using modulus + return hp_data, hp_cols # return the column names and the list of data-column pairs + + +def add_book_to_db(form_data, files): + conn = sqlite3.connect('db/BOOK_DB.sqlite3') + cur = conn.cursor() + + # store information from the forms into variables + title = form_data['title'] + author_firstname = form_data['author_firstname'] + author_middlename = form_data['author_middlename'] + author_surname = form_data['author_surname'] + publisher = form_data['publisher'] + pub_date = form_data['pub_date'] + isbn13 = form_data['isbn13'] + description = form_data['description'] + trade_price = form_data['trade_price'] + retail_price = form_data['retail_price'] + quantity = form_data['quantity'] + + # Correct the data types, formats and escape all strings. + FILENAME = str(isbn13) + ".png" + isbn13 = int(isbn13) + title = escape(title) + author_firstname = escape(author_firstname) + author_middlename = escape(author_middlename) + author_surname = escape(author_surname) + publisher = escape(publisher) + description = escape(description) + trade_price = float(trade_price) + retail_price = float(retail_price) + + # Datetime formatting - https://stackoverflow.com/a/35780962 + pub_date = pub_date.strftime("%d-%m-%Y") + + # insert syntax https://stackoverflow.com/a/45575666 + insert_tuple = ( + isbn13, FILENAME, title, author_firstname, author_middlename, author_surname, publisher, pub_date, description, + quantity, retail_price, trade_price) + insert_string = "INSERT INTO book_info VALUES (?,?,?,?,?,?,?,?,?,?,?,?,0);" + update_string = f"UPDATE book_info SET ISBN={isbn13}, FILENAME='{FILENAME}', TITLE='{title}',AUTHOR_FIRSTNAME='{author_firstname}', AUTHOR_MIDDLENAME='{author_middlename}', AUTHOR_SURNAME='{author_surname}', PUBLISHER='{publisher}', PUB_DATE='{pub_date}', FULL_DESC='{description}',QUANTITY={quantity},RETAIL_PRICE={retail_price},TRADE_PRICE={trade_price} WHERE ISBN={isbn13};" + + try: # to insert into db, e.g. record does not exist already. + cur.execute(insert_string, insert_tuple) + print("Inserted record") + except sqlite3.Error as e: # e.g. unique constraint failed - update existing record. + print(e) + cur.execute(update_string) + print("Updated record") + + dbpath = os.path.abspath("static/img/") # absolute path https://stackoverflow.com/a/51523 + dbpath = dbpath.replace("\\", "/") # change direction of slashes to be only one way + savepath = os.path.join(dbpath + '/' + FILENAME) + files.save(savepath) + + cur.close() + conn.commit() + conn.close() + + +def escape(string): + """ + Escape strings for usage in sqlite3 database TEXT fields.\n + The idea is from https://stackoverflow.com/a/58138810 + + :param string: String to be escaped + :return: An escaped string + """ + return string.replace("'", "''").replace('"', '""') # escaping in sqlite3 https://stackoverflow.com/a/58138810 + + +def get_filename(path): + """ + Given a full path, gets the filename.\n + Only for the img folder in static + + :param path: An absolute path to a file in the static/img/ folder + :return: The filename + """ + dbpath = os.path.abspath("static/img/") # absolute path https://stackoverflow.com/a/51523 + dbpath = dbpath.replace("\\", "/") # change direction of slashes to be only one way + new_path = path.remove_suffix(dbpath) + return new_path + + +def get_quantity(isbn): + """ + Get the QUANTITY value given the 13-digit ISBN + + :param isbn: ISBN13, which should be checked beforehand + :return: The quantity value + """ + conn = sqlite3.connect('db/BOOK_DB.sqlite3') + cur = conn.cursor() + cur.execute(f"SELECT QUANTITY FROM book_info WHERE ISBN={isbn}") + quantity = cur.fetchone()[0] + cur.close() + conn.close() + return quantity + + +def get_price(isbn): + """ + Get the PRICE value given the 13-digit ISBN + + :param isbn: ISBN13, which should be checked beforehand + :return: The price value + """ + conn = sqlite3.connect('db/BOOK_DB.sqlite3') + cur = conn.cursor() + cur.execute(f"SELECT RETAIL_PRICE FROM book_info WHERE ISBN={isbn}") + price = cur.fetchone()[0] + cur.close() + conn.close() + return price diff --git a/db/books.sql b/db/books.sql new file mode 100644 index 0000000..442de41 --- /dev/null +++ b/db/books.sql @@ -0,0 +1,18 @@ +BEGIN TRANSACTION; +CREATE TABLE IF NOT EXISTS "book_info" ( + "ISBN" INTEGER NOT NULL UNIQUE, + "FILENAME" TEXT NOT NULL UNIQUE, + "TITLE" TEXT NOT NULL, + "AUTHOR_FIRSTNAME" TEXT NOT NULL, + "AUTHOR_MIDDLENAME" TEXT NOT NULL, + "AUTHOR_SURNAME" TEXT NOT NULL, + "PUBLISHER" TEXT NOT NULL, + "PUB_DATE" TEXT NOT NULL, + "FULL_DESC" TEXT NOT NULL, + "QUANTITY" INTEGER NOT NULL DEFAULT 0, + "RETAIL_PRICE" REAL NOT NULL DEFAULT 0, + "TRADE_PRICE" REAL NOT NULL DEFAULT 0, + "TOTAL_SOLD" INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY("ISBN") +); +COMMIT; diff --git a/db/insert_users.sql b/db/insert_users.sql new file mode 100644 index 0000000..c322460 --- /dev/null +++ b/db/insert_users.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; +INSERT INTO "user_info" VALUES (NULL,'admin','p455w0rd','admin@admin.com','firstname','middlename','surname','','address','postc',1); +INSERT INTO "user_info" VALUES (NULL,'customer1','p455w0rd','customer1@customers.com','firstname','middlename','surname','','address','postc',0); +INSERT INTO "user_info" VALUES (NULL,'customer2','p455w0rd','customer2@customers.com','firstname','middlename','surname','','address','postc',0); +COMMIT; \ No newline at end of file diff --git a/db/users.sql b/db/users.sql new file mode 100644 index 0000000..ca7cd34 --- /dev/null +++ b/db/users.sql @@ -0,0 +1,16 @@ +BEGIN TRANSACTION; +CREATE TABLE IF NOT EXISTS "user_info" ( + "userid" INTEGER NOT NULL UNIQUE, + "username" TEXT NOT NULL UNIQUE, + "password" TEXT NOT NULL, + "email" TEXT NOT NULL UNIQUE, + "firstname" TEXT NOT NULL, + "middlename" TEXT, + "surname" TEXT NOT NULL, + "gender" TEXT, + "address" TEXT NOT NULL, + "postcode" REAL NOT NULL, + "is_admin" INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY("userid" AUTOINCREMENT) +); +COMMIT; diff --git a/isbn13.py b/isbn13.py new file mode 100644 index 0000000..d025043 --- /dev/null +++ b/isbn13.py @@ -0,0 +1,67 @@ +def check_isbn13(isbn, debugging=False): + """ + # Since ISBN13s have 978 at the start, we can check for this and calculate it seperately. + Also, we dont need any hyphens as the order of hyphens does not matter after the first one. + + https://isbn-information.com/the-13-digit-isbn.html + + """ + isbn_str = remove_hyphens(str(isbn)) + if len(isbn_str) != 13: + + if debugging: + print("isbn13 check: Invalid length") + + return False + + if debugging: + print(isbn, isbn_str) + + first_three = isbn_str[:3] + if first_three != "978": + return False + remaining = isbn_str[3:-1] + base, tripled, normal = 38, 0, 0 # 38 is the constant from calculating values from the starting 978 + + if debugging: + print("\nSTATUS", "i", "x", "x*3") + for i in range(0, len(remaining)): + if (i + 1) % 2 == 1: + tripled = tripled + int(remaining[i]) * 3 + + if debugging: + print("TRIPLE", i, remaining[i], int(remaining[i]) * 3) + else: + normal = normal + int(remaining[i]) + + if debugging: + print("NORMAL", i, remaining[i], remaining[i]) + + total = base + tripled + normal + check_digit = int(isbn_str[12]) # last character of the ISBN provided + mod = total % 10 + + if debugging: + print("\n100 mod", total, "=", mod) + + calculated_digit = 10 - mod + + if debugging: + print("calculation:", calculated_digit, "\nlast character:", check_digit) + if calculated_digit == check_digit: + if debugging: + print("\nEQUAL\n==========") + return True + else: + if debugging: + print("\nNOT EQUAL\n==========") + return False + + +def remove_hyphens(text, debugging=False): + out_text = text.replace("-", "") + if debugging: + print(text, ">>>", out_text) + return out_text + + diff --git a/isbn13_test.py b/isbn13_test.py new file mode 100644 index 0000000..bf73dbe --- /dev/null +++ b/isbn13_test.py @@ -0,0 +1,31 @@ +import unittest +from isbn13 import check_isbn13, remove_hyphens + + +class TestISBNs(unittest.TestCase): + def test_good(self): + self.assertTrue(check_isbn13("978-0306406157", True)) + self.assertTrue(check_isbn13("978-1734314502", True)) + self.assertTrue(check_isbn13("978-1788399081", True)) + + def test_bad(self): + self.assertFalse(check_isbn13("978-1734314509", True)) + self.assertFalse(check_isbn13("978-1788399083", True)) + self.assertFalse(check_isbn13("987-1788399083", True)) + + def test_invalid_length(self): + self.assertFalse(check_isbn13("999", True)) + self.assertFalse(check_isbn13("978923857239852983562935", True)) + self.assertFalse(check_isbn13("978-978-978-978", True)) + + +class TestHyphens(unittest.TestCase): + def test_hyphenated(self): + self.assertEqual(remove_hyphens("abcd-", True), "abcd") + self.assertEqual(remove_hyphens("--", True), "") + self.assertEqual(remove_hyphens("abc", True), "abc") + self.assertEqual(remove_hyphens("978-0306406157", True), "9780306406157") + + +if __name__ == '__main__': + unittest.main() diff --git a/make_db.py b/make_db.py new file mode 100644 index 0000000..9c26a15 --- /dev/null +++ b/make_db.py @@ -0,0 +1,71 @@ +import sqlite3 +import os # for absolute paths - https://stackoverflow.com/a/51523 + + +def make_book_db(): + """ + Function to create the book information database if it does not exist already + + :return: Nothing + """ + conn = sqlite3.connect('db/BOOK_DB.sqlite3') + cur = conn.cursor() + book_path = os.path.abspath("db/books.sql") # absolute path https://stackoverflow.com/a/51523 + with open(book_path) as books_db_script: # execute sql script: https://stackoverflow.com/a/32372796 + cur.executescript(books_db_script.read()) + + cur.close() + conn.commit() + conn.close() + + +def make_user_db(): + """ + Function to create the user information database if it does not exist already + + :return: Nothing + """ + + conn = sqlite3.connect('db/USER_DB.sqlite3') + cur = conn.cursor() + user_path = os.path.abspath("db/users.sql") # absolute path https://stackoverflow.com/a/51523 + with open(user_path) as users_db_script: # execute sql script: https://stackoverflow.com/a/32372796 + cur.executescript(users_db_script.read()) + + cur.close() + conn.commit() + conn.close() + + +def init_db(): + """ + Creates all databases + + :return: Nothing + """ + make_book_db() + make_user_db() + add_default_users() + + +def add_default_users(): + """ + Add the default users for testing purposes + + :return: Nothing + """ + conn = sqlite3.connect("db/USER_DB.sqlite3") + cur = conn.cursor() + + user_insert_path = os.path.abspath("db/insert_users.sql") # absolute path https://stackoverflow.com/a/51523 + try: + with open(user_insert_path) as users_insert_script: # execute sql script: https://stackoverflow.com/a/32372796 + cur.executescript(users_insert_script.read()) + except sqlite3.Error: + pass + # If the default users are already in the system because of a server restart + # just skip inserting the base users as otherwise this will cause a unique constraint to fail + + cur.close() + conn.commit() + conn.close() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4407ece --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask~=2.0.2 +WTForms~=3.0.0 +Flask-WTF~=1.0.0 \ No newline at end of file diff --git a/sessions.py b/sessions.py new file mode 100644 index 0000000..872c5c0 --- /dev/null +++ b/sessions.py @@ -0,0 +1,28 @@ +def set_session_type(session, usertype): + """ + Sets the access type for the session + + :param session: The session cookie for the user + :param usertype: The type of access level of the user to be set + :return: Nothing + """ + if usertype == 'admin': # if the usertype parameter is admin + session['usertype'] = 'admin' # set admin session cookies + elif usertype == 'normal': # if the usertype parameter is normal + session['usertype'] = 'normal' # set normal session cookies + + +def is_admin(session): + """ + Returns a boolean indicating if the user's session is an admin one.\n + Performed by checking the 'usertype' key of the session + + :param session: The user's session cookie + :return: True if the user's session is an admin one, false if not. + """ + if 'usertype' not in session: # checks if the session cookie even exists + return False + if session['usertype'] != 'admin': # if the user's type is not admin + return False # user is not an admin + else: # otherwise + return True # user is an admin \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..9a7b5fe Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/icon readme.txt b/static/icon readme.txt new file mode 100644 index 0000000..ecc6c03 --- /dev/null +++ b/static/icon readme.txt @@ -0,0 +1,128 @@ +small-n-flat +============ + +svg icons on a 24px grid +http://paomedia.github.io/small-n-flat/ + +![small-n-flat normal size](preview-24.png) + + +License: +============ + +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + diff --git a/static/round.js b/static/round.js new file mode 100644 index 0000000..c0df995 --- /dev/null +++ b/static/round.js @@ -0,0 +1,4 @@ +function round_number(old){ + const unrounded = Number(old); //convert to int - https://stackoverflow.com/a/1133814 + return (unrounded).toFixed(2); //round number in JS - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed +} diff --git a/static/stylesheet.css b/static/stylesheet.css new file mode 100644 index 0000000..e9fbe4f --- /dev/null +++ b/static/stylesheet.css @@ -0,0 +1,179 @@ +.table_label{ + text-align: right; +} + +.reg_table{ + align-self: center; +} + +.reg_table_div{ + margin: 0 auto; + display: flex; + justify-content: center; +} + +.login_table_div{ + margin: 0 auto; + display: flex; + justify-content: center; +} + +.login_table{ + align-self: center; +} + +#login_btn{ + margin-top: 10px; +} + +.error_flash_message{ + margin: 0 auto; + display: flex; + justify-content: center; + color: red; +} + +.check_isbn_button{ + margin: 0 auto; + display: flex; + justify-content: center; +} + +.isbn_entry_div{ + margin: 0 auto; + display: flex; + justify-content: center; +} + +.add_book_table{ + align-self: center; +} + +.add_book_table_div{ + margin: 0 auto; + display: flex; + justify-content: center; +} + +.book_found_div{ + text-align: center; +} + +.book_detail_table{ + border: 1px solid black; + width: 100%; +} + +.book_detail_table th{ + border: 1px solid black +} + +.book_detail_table td{ + border: 1px solid black +} + +.SL_div{ + text-align: center; +} + +.SL_table_outer{ + border: 1px solid black; + display: flex; + justify-content: center; +} + +.SL_table{ + margin: 4px auto; + border: 1px solid black; + width: 50%; +} + +.SL_row{ + border: 1px solid black; +} + +.SL_row td{ + border: 1px solid black; +} + + +.SL_thead{ + display: table-header-group +} + + +.center_div{ + display: flex; + justify-content: center; +} + + +.low_quantity{ + background-color: lightcoral; +} + +.medium_quantity{ + background-color: orange; +} + +.high_quantity{ + background-color: lightgreen; +} + +#quantity{ + width: 10%; +} + +#isbn{ + width: 15%; +} + +.HP_div{ + text-align: center; +} + +.HP_table_outer{ + border: 1px solid black; + display: flex; + justify-content: center; +} + +.HP_table{ + margin: 4px auto; + border: 1px solid black; + width: 50%; +} + +.HP_th{ + background-color: darkgray; +} + + +.HP_row{ + border: 1px solid black; +} + +.HP_row td{ + border: 1px solid black; +} + + +.HP_thead{ + display: table-header-group +} + +#pic_col{ + width: 60%; +} + +.tbl_pic{ + text-align: center; + vertical-align: middle; +} + +.tbl_img{ + display: block; + height: auto; + width: 25%; + margin: auto +} \ No newline at end of file diff --git a/templates/401_error.html b/templates/401_error.html new file mode 100644 index 0000000..520a04b --- /dev/null +++ b/templates/401_error.html @@ -0,0 +1,11 @@ + + + + + 401 Unauthorized + + +

Your details seem incorrect.

+

Click here to return to the login page.

+ + \ No newline at end of file diff --git a/templates/403_error.html b/templates/403_error.html new file mode 100644 index 0000000..d361f42 --- /dev/null +++ b/templates/403_error.html @@ -0,0 +1,11 @@ + + + + + 403 Forbidden + + +

You do not have access to this page.

+

Click here to return to the homepage.

+ + \ No newline at end of file diff --git a/templates/book_add.html b/templates/book_add.html new file mode 100644 index 0000000..730096a --- /dev/null +++ b/templates/book_add.html @@ -0,0 +1,79 @@ + + + + + Add Book + + + + +
+ {{ form.hidden_tag() }} +
+ + + {% for field in form %} + {% if field.name != "csrf_token" %} + + + {% if field.name == "isbn13" %} + + {% set isbn_value = field.data | int %} + {% if isbn_value > 1 %} + + + {% endif %} + {% endif %} + {% if field.id not in ["quantity_slider", "trade_slider", "retail_slider", "ISBN"] %} + + {% endif %} + + {% if field.id == "quantity_slider" %} + + {% endif %} + + {% if field.id == "trade_slider" %} + + {% endif %} + + {% if field.id == "retail_slider" %} + + {% endif %} + + {% endif %} + {% endfor %} +
Add Book Form
{{ field.label }}:{{ field(readonly="readonly") }}{{ field }} + {{ field(oninput="this.nextElementSibling.value = this.value") }} + {{ field.data.value }} + {{ field.data }} + + + {{ field(oninput="this.nextElementSibling.value = round_number(this.value)") }} + {{ field.data }} + + {{ field(oninput="this.nextElementSibling.value = round_number(this.value)") }} + {{ field.data }} +
+
+
+ {{ submitbtn.button }} +
+
+
+

Use arrow keys on sliders to get more exact values

+
+ {% if book_added %} +
+

Book details updated. Click here to add or update another book.

+
+
+

Or click here to return to the view stock screen.

+
+ {% else %} +
+

Click here to return to the view stock screen.

+
+ {% endif %} + + + \ No newline at end of file diff --git a/templates/book_details.html b/templates/book_details.html new file mode 100644 index 0000000..752dbf0 --- /dev/null +++ b/templates/book_details.html @@ -0,0 +1,57 @@ + + + + + Book Details + + + + +
+

+ Information for ISBN13:
{{ bookID }} +

+
+
+ {% if book_found %} + +

+ BOOK INFO [READ ONLY] +

+ {% if book_info %} +
+ + + {% for info in book_info %} + + {% endfor %} + + + + + {% for info in book_info %} + + {% endfor %} + + +
{{ info[0] }}
{{ info[1] }}
+ {% endif %} +

Book found. Click here to update information for + the book

+ + {% else %} +
+

Book not found. Click here to add information for + the book

+
+ {% endif %} +
+
+

Return to homepage


+
+
+

Or add a different ISBN here

+
+ + + \ No newline at end of file diff --git a/templates/cart.html b/templates/cart.html new file mode 100644 index 0000000..b419497 --- /dev/null +++ b/templates/cart.html @@ -0,0 +1,10 @@ + + + + + cart + + +

This is the cart.

+ + \ No newline at end of file diff --git a/templates/homepage.html b/templates/homepage.html new file mode 100644 index 0000000..fb23660 --- /dev/null +++ b/templates/homepage.html @@ -0,0 +1,89 @@ + + + + + Homepage + + + + +
+

+ Bookstore +

+
+
+ {% if session['username'] %} +

Logged in as: {{ session['username'] }}!

+

Click here to log out: LOG OUT

+ {% else %} +

Please register here.

+

Already registered? LOG IN

+ {% endif %} + {% if session['usertype'] == 'admin' %} +

Click to access admin stuff: Stock Levels

+ {% endif %} +
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+ +

+ PRODUCT LIST +

+ {% if data %} +
+ + {{ form.hidden_tag() }} + + + + + + + + + + {% for tuple in data %} + + {% if tuple[0] == "Cover" %} + + + {% elif tuple[0] == "Title" %} + + {% elif tuple[0] == "In Stock" %} + + {% elif tuple[0] == "ID" %} + + + {% endif %} + {% endfor %} + + + +
{{ cols[0] }}{{ cols[1] }}{{ cols[2] }}Total Required
{{ tuple[1] }}{{ tuple[1] }} + +
+ + + +
+
+ {% else %} +

Everything is out of stock.

+ {% endif %} + +
+ + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..31de115 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,52 @@ + + + + + Login Page + + + + + +{% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} +{% endwith %} + +
+ {{ form.csrf_token }} +

+ Login Page +

+ + +
+
+

Haven't registered? REGISTER

+
+
+

Return to homepage

+
+ + \ No newline at end of file diff --git a/templates/login_success.html b/templates/login_success.html new file mode 100644 index 0000000..3e6c1a7 --- /dev/null +++ b/templates/login_success.html @@ -0,0 +1,20 @@ + + + + + Successful Login + + + + +

+ Login Successful! +

+ + + + \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..1b13914 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,49 @@ + + + + + register + + + + +
+ {{ form.csrf_token }} +
+ + + {% for field in form %} + {% if field.name != "csrf_token" %} + + + + + {% endif %} + {% endfor %} +
Registration Form
{{ field.label }}:{{ field }}
+
+
+ {{ submitbtn.button }} +
+
+
+

Already registered? LOG IN

+
+
+

Return to homepage

+
+
+ + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + \ No newline at end of file diff --git a/templates/registered.html b/templates/registered.html new file mode 100644 index 0000000..09df518 --- /dev/null +++ b/templates/registered.html @@ -0,0 +1,20 @@ + + + + + Successful Registration + + + + +

+ Registration Successful! +

+ + + + \ No newline at end of file diff --git a/templates/stock_add.html b/templates/stock_add.html new file mode 100644 index 0000000..f43d06a --- /dev/null +++ b/templates/stock_add.html @@ -0,0 +1,40 @@ + + + + + + + Enter ISBN13 + + + +{% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} +{% endwith %} + +
+ {{ form.csrf_token }} +
+ + + + + + +
ISBN13 Entry Form
Enter {{ form.isbn13_input.label }}:{{ form.isbn13_input }}
+
+
+ {{ form.button }} +
+
+
+

Return to homepage

+
+ + \ No newline at end of file diff --git a/templates/stock_report.html b/templates/stock_report.html new file mode 100644 index 0000000..4044dac --- /dev/null +++ b/templates/stock_report.html @@ -0,0 +1,65 @@ + + + + + Admin Stock Panel + + + + +
+

A button which says Add Stock goes here.

+
+
+ {% if cols %} +
+ +

+ STOCK LEVELS INFO [READ ONLY] +

+ {% if data %} +
+ + + + + + + + + + {% for tuple in data %} + + {% if tuple[0] == "Cover" %} + + + {% elif tuple[0] == "Title" %} + + {% elif tuple[0] == "ISBN13" %} + + {% elif tuple[0] == "Quantity Available" %} + {% if tuple[1] <= 4 %} + + {% elif tuple[1] <= 10 %} + + {% else %} + + {% endif %} + + {% endif %} + {% endfor %} + + +
{{ cols[0] }}{{ cols[1] }}{{ cols[2] }}{{ cols[3] }}
{{ tuple[1] }}{{ tuple[1] }}{{ tuple[1] }}{{ tuple[1] }}{{ tuple[1] }}
+ {% endif %} + + {% else %} +

Database empty

+ {% endif %} +
+
+

Return to homepage

+
+
+ + \ No newline at end of file