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 b5dec8b1e0ae7881ad1cb9178be20e8ebd646813..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 - ) # In pounds, as we're not dividing or multiplying this will not matter in calculations. Do we neccesarily need this? It could be calculated dynamically from the items held. + quantity = db.Column(db.Integer, nullable=False) items = db.relationship( "Item", secondary=itemSets, @@ -68,8 +66,12 @@ class ItemSet(db.Model): backref=db.backref("ItemSets", lazy=True), ) + @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}" + 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 08e28a982daf4217e072e455e622aa05619df668..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, @@ -388,6 +390,7 @@ def ChangePhNumber(): @app.route("/add_to_basket", methods=["POST"]) def add_to_basket(): item_id = request.form["item_id"] + item_obj = get_item_by_id(item_id) item_dict = { @@ -404,7 +407,31 @@ 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"]) +def add_to_basket_set(): + item_id = request.form["item_id"] + + item_obj = get_item_set_by_id(item_id) + + item_dict = { + "id": item_obj.id, + "description": item_obj.description, + "price": item_obj.price, + } + if "basket" not in session: + session["basket"] = {} + + 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("item_set_page", item_id=item_id)) @app.route("/remove_item", methods=["POST"]) @@ -420,15 +447,15 @@ 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, @@ -474,3 +501,21 @@ def view_address(): 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 82% rename from store/templates/ItemPage.html rename to store/templates/item_page.html index 7fec52b9d84591cf8be4bebeb254eb5d67a6d85c..3f4434c44257d019a7b5c24a018913cdd65df523 100644 --- a/store/templates/ItemPage.html +++ b/store/templates/item_page.html @@ -3,7 +3,7 @@ {% block content %} {% block title %} Item Page | Antiques Online {% endblock %} <div> - <img src='static\image_placeholder.png' alt="Image Placeholder" width="200" height="170"> + <img src='..\static\image_placeholder.png' alt="Image Placeholder" width="200" height="170"> <br /> Item price: £{{item_price}} <br /> diff --git a/store/templates/ItemSetPage.html b/store/templates/item_set_page.html similarity index 94% rename from store/templates/ItemSetPage.html rename to store/templates/item_set_page.html index ca0be6ae501f66ac540cd0a5139d64a7e1d8ddc2..d2b0b0136412c49e5838ce3d769bfe1306b28aee 100644 --- a/store/templates/ItemSetPage.html +++ b/store/templates/item_set_page.html @@ -22,7 +22,7 @@ <br> Item description: {{item_description}} <br> - <form method="POST" action="{{url_for ('add_to_basket') }}"> + <form method="POST" action="{{url_for ('add_to_basket_set') }}"> <input type="hidden" name="item_id" value="{{ item_id }}"> <input class="button" type="submit" value="Add to basket"> </form> diff --git a/store/templates/itemSets.html b/store/templates/item_sets.html similarity index 90% rename from store/templates/itemSets.html rename to store/templates/item_sets.html index 039728a9a640a670441f7e328fe5437a3cd51538..27bae077dd6b90b93e0b67dac8fd13e4ec478f16 100644 --- a/store/templates/itemSets.html +++ b/store/templates/item_sets.html @@ -42,8 +42,8 @@ <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 + <p>£{{ item.price }}</p> + <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 88% rename from store/templates/Items.html rename to store/templates/items.html index 961714a620187cdd82d5f39e086a588203518fc7..7f7c46477394a56bd2b9967ae50fd9b506ac8d9d 100644 --- a/store/templates/Items.html +++ b/store/templates/items.html @@ -42,8 +42,8 @@ <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 + <p>£{{ item.price }}</p> + <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/utility.py b/store/utility.py index 89384d83fd3558b49bf753a282a7f3d90ac21c9b..d39525a62041479e2f77a071043ceade5ae53081 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 @@ -45,11 +43,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: @@ -79,31 +81,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): @@ -134,6 +122,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