diff --git a/README.md b/README.md index 8a2861bddbef65eb4f10cd2bcdc9369bfcd5f44b..36ac4fbbffba796dcd5155416999331ec509f6f3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This terminal must be left open for the server to run and for it to be accessibl Ensure all dependencies are installed by running the command ``pip install -r requirements.txt`` in the root project directory. It is recommended to do this in a virtual environment, https://docs.python.org/3/library/venv.html in a subfolder named venv so that it can be correctly git ignored. For Windows: 1. Create a virtual environment, ``python -m venv venv``. -2. Activate the virtual environment, ``.\venv\Scripts\activate ``. +2. Activate the virtual environment, ``.\venv\Scripts\activate``. 3. Install the depedendencies, ``pip install -r requirements.txt``. # Project Organisattion @@ -18,6 +18,9 @@ To ensure a consistent code style Python [black](https://pypi.org/project/black/ 1. Go to VS Code's settings. 2. Search for "python formatting provider", and set it to "black." 3. (Optional) Search for "format on save", and enable. This will change the settings globally, not just the project. + +Python black follows pep8, that means snake_case for variables and function names, PascalCase for classes. + ## Static Static content, e.g. images, css, are stored in the static folder. diff --git a/seed_database.py b/seed_database.py index a1e3e03147afe051383b2adfbee2f6078c0f5447..295d1ee24faff6bc22412399af7c01543a77d14c 100644 --- a/seed_database.py +++ b/seed_database.py @@ -80,7 +80,7 @@ with app.app_context(): for item_set in item_sets: # Items will be added to the sets later. flask_item_set = ItemSet( - id=item_set[0], description=item_set[1], price=item_set[3] + id=item_set[0], description=item_set[1], quantity=item_set[3] ) flask_item_sets.append(flask_item_set) diff --git a/store/forms.py b/store/forms.py index 61ef12b75fdd177854ac3b1a4e87cb5b118c2911..ec125c50b01db198e8520e6836d8a0bf19e113d9 100644 --- a/store/forms.py +++ b/store/forms.py @@ -32,3 +32,8 @@ class LoginForm(FlaskForm): "Enter your Favourite Colour", [validators.Length(min=3, max=35)] ) submit = SubmitField("Login", render_kw={"class": "button"}) + + +class SearchForm(FlaskForm): + query = StringField("Query", [validators.DataRequired()]) + submit = SubmitField("Submit") diff --git a/store/models.py b/store/models.py index 6cb85f73c7b753d7c7b1505ec7be6c6318d5d4ba..06b869cb95456d1241b9cdf471fa9a21a0ea3be1 100644 --- a/store/models.py +++ b/store/models.py @@ -58,9 +58,7 @@ class ItemSet(db.Model): id = db.Column(db.Integer, primary_key=True) description = db.Column(db.String(256), nullable=False) - price = db.Column( - db.Integer, nullable=False - ) # This is actually the quantity not price... TODO: Rename + quantity = db.Column(db.Integer, nullable=False) items = db.relationship( "Item", secondary=itemSets, @@ -68,11 +66,12 @@ class ItemSet(db.Model): backref=db.backref("ItemSets", lazy=True), ) - def calculate_price(self): + @property + def price(self): return sum(int(item.price) for item in self.items) def __repr__(self): - return f"id: {self.id}, description: {self.description}, items: {self.items}, price: {self.calculate_price()}" + return f"id: {self.id}, description: {self.description}, items: {self.items}, quantity: {self.price}, price: { self.calculate_price() }" # TODO: Rename price to quantity class User(db.Model, UserMixin): diff --git a/store/routes.py b/store/routes.py index a7e6463be4ffbbb2d608a600e99521f94e123804..e63b9cc3fd83d4fe3871c7446eac7ef1bccb2779 100644 --- a/store/routes.py +++ b/store/routes.py @@ -3,6 +3,8 @@ from flask import render_template, request, flash, redirect, url_for, Flask, ses import json from store.utility import * from store.forms import * +import string +import random # Official flask-login doc free liecense @@ -81,14 +83,14 @@ def items(): return render_template("items.html", title="Items", items=unsold_items) -@app.route("/itemSets") -def itemSets(): +@app.route("/item_sets") +def item_sets(): items = get_item_sets() - return render_template("itemSets.html", title="Item Sets", items=items) + return render_template("item_sets.html", title="Item Sets", items=items) -@app.route("/ItemSetPage/<int:item_id>") -def ItemSetPage(item_id): +@app.route("/item_set_page/<int:item_id>") +def item_set_page(item_id): item = get_item_set_by_id(item_id) if not item: return "Item set not found", 404 @@ -98,7 +100,7 @@ def ItemSetPage(item_id): contained_items = item.items return render_template( - "ItemSetPage.html", + "item_set_page.html", item_price=item_price, item_description=item_description, item_id=item_id, @@ -405,7 +407,7 @@ def add_to_basket(): session["basket"][item_id] = item_dict flash("Item added to basket: " + item_obj.description) print(session["basket"]) - return redirect(url_for("ItemPage", item_id=item_id)) + return redirect(url_for("item_page", item_id=item_id)) @app.route("/add_to_basket_set", methods=["POST"]) @@ -424,11 +426,12 @@ def add_to_basket_set(): if item_id in session["basket"]: flash("Item already in basket") + else: session["basket"][item_id] = item_dict flash("Item added to basket: " + item_obj.description) print(session["basket"]) - return redirect(url_for("ItemSetPage", item_id=item_id)) + return redirect(url_for("item_set_page", item_id=item_id)) @app.route("/remove_item", methods=["POST"]) @@ -444,16 +447,75 @@ def remove_item(): return redirect(url_for("basket")) -@app.route("/ItemPage/<int:item_id>") -def ItemPage(item_id): +@app.route("/item_page/<int:item_id>") +def item_page(item_id): item = get_item_by_id(item_id) if not item: return "Item not found", 404 item_price = item.price item_description = item.description return render_template( - "ItemPage.html", + "item_page.html", item_price=item_price, item_description=item_description, item_id=item_id, ) + + +@app.route("/addAdress", methods=["GET", "POST"]) +@login_required +def addAdress(): + if request.method == "POST": + user_id = current_user.user_id + country = request.form["country"] + homeNumber = request.form["homeNumber"] + streetName = request.form["streetName"] + city = request.form["city"] + phoneNumber = request.form["phoneNumber"] + contactEmail = request.form["contactEmail"] + + # user = User.query.get(user_id) + + newAdress = Adresses.create_Adresses( + user_id=user_id, + country=country, + homeNumber=homeNumber, + streetName=streetName, + city=city, + phoneNumber=phoneNumber, + contactEmail=contactEmail, + ) + flash("Your address has been added!") + return redirect(url_for("addAdress")) + return render_template("userContent/address.html") + + +@app.route("/view_address", methods=["GET"]) +@login_required +def view_address(): + user_id = current_user.user_id + addresses = Adresses.query.filter_by(user_id=user_id).all() + + if not addresses: + flash("You don't have any saved addresses. you can add here") + return redirect(url_for("addAdress", user_id=user_id)) + else: + return render_template("userContent/view_address.html", addresses=addresses) + + +@app.context_processor +def base(): + """So that search works on every page, and so that the search form does not need to be passed + to every template a context processor is used to inject a variable (in this case search_form) + into every template. + """ + form = SearchForm() + return dict(search_form=form) + + +@app.route("/search", methods=["POST"]) +def search(): + form = SearchForm() + if form.validate_on_submit(): + items = get_by_keyword(form.query.data) + return render_template("search.html", form=form, items=items) diff --git a/store/templates/base.html b/store/templates/base.html index e91b02e822abc86e5e0841fe898ad17850c0bf9b..36c73cd05b7585f0d667353706521fe1fd419f19 100644 --- a/store/templates/base.html +++ b/store/templates/base.html @@ -1,3 +1,4 @@ +{% from "_formhelpers.html" import render_field %} <!DOCTYPE html> <html lang="en"> @@ -8,15 +9,15 @@ </head> <body> - <header class="header"> - <a href="{{ url_for('index')}}">ANTIQUES ONLINE</a> - </header> + <header class="header"> + <a href="{{ url_for('index')}}">ANTIQUES ONLINE</a> + </header> <nav class="navbar"> <ul class="navbar-list"> <li><a href="{{ url_for('basket')}}">Basket</a></li> <li><a href="{{ url_for('items')}}">Items</a></li> - <li><a href="{{ url_for('itemSets')}}">Item Sets</a></li> + <li><a href="{{ url_for('item_sets')}}">Item Sets</a></li> {% if current_user.is_authenticated %} <li><a href="{{ url_for('account')}}">Account</a></li> @@ -27,9 +28,10 @@ <li><a href="{{ url_for('register')}}">Register</a></li> {% endif %} - <form class="search"> - <button type="submit">Search</button> - <input type="text" placeholder="Search..."> + <form method="POST" action="{{url_for('search')}}" class="search"> + {{search_form.hidden_tag()}} + {{render_field(search_form.query) }} + {{search_form.submit()}} </form> </ul> </nav> @@ -48,12 +50,12 @@ {% endblock %} <!-- Where HTML will go when extended. --> - <footer class="footer"> - <br> - <p>Contact Info: mail@mail.com</p> - <p>Phone Number 0000 000 0000</p> - <br> - </footer> + <footer class="footer"> + <br> + <p>Contact Info: mail@mail.com</p> + <p>Phone Number 0000 000 0000</p> + <br> + </footer> </body> </html> \ No newline at end of file diff --git a/store/templates/ItemPage.html b/store/templates/item_page.html similarity index 100% rename from store/templates/ItemPage.html rename to store/templates/item_page.html diff --git a/store/templates/ItemSetPage.html b/store/templates/item_set_page.html similarity index 100% rename from store/templates/ItemSetPage.html rename to store/templates/item_set_page.html diff --git a/store/templates/itemSets.html b/store/templates/item_sets.html similarity index 93% rename from store/templates/itemSets.html rename to store/templates/item_sets.html index a98b764c13d54a984849ebc356f782278f2827ec..27bae077dd6b90b93e0b67dac8fd13e4ec478f16 100644 --- a/store/templates/itemSets.html +++ b/store/templates/item_sets.html @@ -43,7 +43,7 @@ <img src="static\image_placeholder.png" alt="{{ item.description }}"> <h2>{{ item.description }}</h2> <p>£{{ item.price }}</p> - <a href="{{url_for('ItemSetPage', item_id = item.id)}}">View + <a href="{{url_for('item_set_page', item_id = item.id)}}">View Details</a> </div> diff --git a/store/templates/Items.html b/store/templates/items.html similarity index 91% rename from store/templates/Items.html rename to store/templates/items.html index 15b866e14a6ff699f7bbfb9b080ade43ecdc9eee..7f7c46477394a56bd2b9967ae50fd9b506ac8d9d 100644 --- a/store/templates/Items.html +++ b/store/templates/items.html @@ -43,7 +43,7 @@ <img src="static\image_placeholder.png" alt="{{ item.description }}"> <h2>{{ item.description }}</h2> <p>£{{ item.price }}</p> - <a href="{{url_for('ItemPage', item_id = item.id)}}">View + <a href="{{url_for('item_page', item_id = item.id)}}">View Details</a> </div> @@ -52,4 +52,4 @@ <p>No items available</p> {% endif %} </div> -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/store/templates/search.html b/store/templates/search.html new file mode 100644 index 0000000000000000000000000000000000000000..d14da500827b637a974b5028863f9c92c05d9bf3 --- /dev/null +++ b/store/templates/search.html @@ -0,0 +1,53 @@ +{%extends 'base.html' %} +{% block title %} Search | Antiques Online {% endblock %} +{% block content %} +Searching for {{form.query.data}} +<style> + .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + grid-gap: 10px; + grid-auto-rows: minmax(100px, auto); + } + + .grid-item { + grid-row: span 1; + grid-column: span 1; + padding: 10px; + } + + .grid-item img { + max-width: 100%; + height: auto; + } + + .grid-item h2 { + margin: 10px 0; + font-size: 18px; + + } + + .grid-item p { + margin: 5px 0; + font-size: 14px; + } +</style> + +<div class="grid"> + {% if items %} + {% for item in items %} + <div class="grid-item"> + + <img src="static\image_placeholder.png" alt="{{ item.description }}"> + <h2>{{ item.description }}</h2> + <p>£{{ item.price }}</p> + <a href="{{url_for('item_page', item_id = item.id)}}">View + Details</a> + + </div> + {% endfor %} + {% else %} + <p>No items available</p> + {% endif %} +</div> +{% endblock %} \ No newline at end of file diff --git a/store/templates/userContent/user.html b/store/templates/userContent/user.html index eff6f6a4e4f1f2b5560892c22b8d9dfa49a26855..eb4a4c7d2de7c1339c6df657806125dd1e3a672c 100644 --- a/store/templates/userContent/user.html +++ b/store/templates/userContent/user.html @@ -5,5 +5,6 @@ <ul> <li><a href="{{ url_for('accountDetails', user_id=current_user.id)}}">View my Account details</a></li> <li><a href="{{ url_for('addAdress', user_id=current_user.id)}}">Add shipping address</a></li> + <li><a href="{{ url_for('view_address', user_id=current_user.id)}}">View your shipping address</a></li> </ul> {% endblock %} \ No newline at end of file diff --git a/store/utility.py b/store/utility.py index 5774f2b704f000bdf32f9810676a3a86f11a9ae3..bdd1bda891e0f8541fc6bd7325750d01c04e126e 100644 --- a/store/utility.py +++ b/store/utility.py @@ -2,12 +2,10 @@ from difflib import SequenceMatcher from store.models import * -from sqlalchemy import select +from sqlalchemy import select, func import datetime from store import * import re -import random -import string # for autegenarating letters # Difference between filter and filter_by: https://stackoverflow.com/a/31560897 @@ -46,11 +44,15 @@ def get_item_set_by_id(itemset_id): pass -# Search the description of all items to find similar. -# This is intended for use in replacing items in an items, -# and works best when description is close to the target. -# As a result it does not return sold items. def get_similar_items(description): + """Search the description of all items to find similar. + This is intended for use in replacing items in an items, + and works best when description is close to the target. + As a result it does not return sold items. + TODO: Do the regex using SQL alchemy so that as much as + possible is handled by the database rather than in memory + for maximum performance. + """ to_search = get_unsold_items() found = [] for descriptable in to_search: @@ -80,31 +82,17 @@ def remove_item(item): db.session.commit() -# def remove_item_by_id(item_id): -# get_similar_items() # TODO: Need to get Item object to pass to add similar item to set -# db.session.Item.query() - - -# Search desciption for matching substring. -# This is most useful for a user keyword search. def get_by_keyword(substring): - to_search = get_items() + get_item_sets() - found = [] - for descriptable in to_search: - if substring.lower() in descriptable.description.lower(): - found.append(descriptable) - return found - - -# Search desciption for matching substring. -# This is most useful for a user keyword search. -def get_by_keyword(substring): - to_search = get_items() + get_item_sets() - found = [] - for descriptable in to_search: - if substring.lower() in descriptable.description.lower(): - found.append(descriptable) - return found + """Search desciption for matching substring. Case insensitive. + This is most useful for a user keyword search. + """ + substring = substring.lower() + return ( + Item.query.filter(func.lower(Item.description.contains(substring))).all() + + ItemSet.query.filter( + func.lower(ItemSet.description.contains(substring)) + ).all() + ) def get_itemsets_by_item_id(item_id): @@ -135,6 +123,8 @@ def add_item(description, price, date_sold=None): def update_set(reference_item): + """Update the set that the passed item belongs to by removing it from the set and adding a similar item.""" + # We need the owning item set to avoid adding duplicates. owning_itemsets = get_itemsets_by_item_id(reference_item.id) # Sort by closest match. Could also do it based of the description of the set. diff --git a/unit_tests.py b/unit_tests.py index 8345b4a4fdebb22c5a7ff401246ac5121070d933..eb6d6f744bf0e50757e89984f336737202f7d64f 100644 --- a/unit_tests.py +++ b/unit_tests.py @@ -1,9 +1,11 @@ from store import app -# An instance of the database needs to exist to unit test, -# as the database is seeded from file this can be used as a test database. -# It would probably be best to create a variant of it here, but for now -# just use the main database to test against. +""" An instance of the database needs to exist to unit test, +as the database is seeded from file this can be used as a test database. +Really there should be an inmemory database for testing purposes, but +further research into how flask works with such a thing needs to be done. +""" + from store.utility import * import unittest