diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..665cc78ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,165 @@ +# 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 + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__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/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + + +# var +cache/ +*.db \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/README.md b/README.md index 2994dc5b8..8b46b3710 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,50 @@ # PWP SPRING 2024 -# PROJECT NAME +# Inventory Sytstem for Foodmarket Chain # Group information -* Student 1. Name and email -* Student 2. Name and email -* Student 3. Name and email -* Student 4. Name and email +* Student 1. Alexis Chambers Alexis.Chambers@oulu.fi +* Student 2. Zeeshan Talha Talha.Zeeshan@student.oulu.fi +* Student 3. Reed Connor Connor.Reed@student.oulu.fi +* Student 4. Chakal Khalil Khalil.Chakal@oulu.fi + + +## Project Setup + +Clone the repository to your local machine using git clone +``` +git clone https://github.com/khacha329/PWP_CrustyCrabs.git +``` +To ensure your python packages are protected, set up a virtual environment to keep a clean system + +``` +python -m venv /path/to/myEnv +``` +Activate the virtual environment + +``` +source /path/to/myEnv/bin/activate +``` +Install the required python packages in the virtual environment using the requirements.txt file located in the root directory of this project + +``` +pip install requirements.txt -r +``` + +Intall inventory manager package in developer mode: +Navigate to /PWP_CrustyCrabs/ + +Run: + +``` +pip install -e . +``` + + +## Initialize and Populate DB + +To intialize the database and populate it with dummy data follow the [README file](https://github.com/khacha329/PWP_CrustyCrabs/blob/main/inventorymanager/README.md) under the inventorymanager folder + + + __Remember to include all required documentation and HOWTOs, including how to create and populate the database, how to run and test the API, the url to the entrypoint and instructions on how to setup and run the client__ diff --git a/inventorymanager/README.md b/inventorymanager/README.md new file mode 100644 index 000000000..738a5067a --- /dev/null +++ b/inventorymanager/README.md @@ -0,0 +1,21 @@ +To initialize the database for this application, run the following commands from the root directory of the project + +``` +flask --app inventorymanager init-db +flask --app inventorymanager populate-db +``` +To check the contents of the database, run the flask shell using the following command from the root directory of the project + +``` +flask --app inventorymanager shell +``` + +This will open an interactive flask-shell in which we can query the database. For a quick overview of each model, run any of the following commands in the shell: + +``` +Location.query.all() +Warehouse.query.all() +Item.query.all() +Stock.query.all() +Catalogue.query.all() +``` diff --git a/inventorymanager/__init__.py b/inventorymanager/__init__.py new file mode 100644 index 000000000..adc29a629 --- /dev/null +++ b/inventorymanager/__init__.py @@ -0,0 +1,59 @@ +""" +This module is used to start and retrieve a Flask application complete with all the required setups +""" +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +import os + +from inventorymanager.config import Config + +db = SQLAlchemy() + +# Structure learned from the following sources: +# https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/quickstart/ +# https://www.digitalocean.com/community/tutorials/how-to-structure-a-large-flask-application-with-flask-blueprints-and-flask-sqlalchemy#the-target-application-structure +# https://www.digitalocean.com/community/tutorials/how-to-use-flask-sqlalchemy-to-interact-with-databases-in-a-flask-application#step-2-setting-up-the-database-and-model + + +def create_app(test_config=None): + + app = Flask(__name__, instance_relative_config=True) + + app.config.from_mapping( + SECRET_KEY="dev", + SQLALCHEMY_DATABASE_URI="sqlite:///" + os.path.join(app.instance_path, "development.db"), + SQLALCHEMY_TRACK_MODIFICATIONS=False, + # CACHE_TYPE="FileSystemCache", + # CACHE_DIR=os.path.join(app.instance_path, "cache"), + ) + + if test_config is None: + app.config.from_pyfile("config.py", silent=True) + else: + app.config.from_mapping(test_config) + + try: + os.makedirs(app.instance_path) + + except OSError: + pass + + db.init_app(app) + #cache.init_app(app) + + # Import All Models (not sure why yet, its a thing-to-do to make this work) + from inventorymanager.models import Location, Warehouse, Item, Stock, Catalogue + + # CLI commands to populate db + from inventorymanager.models import init_db_command, create_dummy_data + + app.cli.add_command(init_db_command) + app.cli.add_command(create_dummy_data) + + from inventorymanager.api import api_bp + from inventorymanager.utils import WarehouseConverter, ItemConverter + app.url_map.converters["warehouse"] = WarehouseConverter + app.url_map.converters["item"] = ItemConverter + app.register_blueprint(api_bp) + + return app diff --git a/inventorymanager/api.py b/inventorymanager/api.py new file mode 100644 index 000000000..a1d5e9da3 --- /dev/null +++ b/inventorymanager/api.py @@ -0,0 +1,22 @@ +""" +This module instantiates the Api object and adds to it all the endpoints for the resources +""" +from flask import Blueprint +from flask_restful import Api + +from inventorymanager import create_app +from inventorymanager.resources.item import ItemCollection, ItemItem +from inventorymanager.resources.location import LocationCollection, LocationItem + +api_bp = Blueprint("api", __name__, url_prefix="/api") + +api = Api(api_bp) + +api.add_resource(ItemCollection, "/items/") +api.add_resource(ItemItem, "/items//") +api.add_resource(LocationCollection, "/locations/locations/") +api.add_resource(LocationItem, '/api/locations/') +# api.add_resource(SensorItem, "/sensors//") +# api.add_resource(LocationItem, "/locations//") +# api.add_resource(MeasurementCollection, "/sensors//measurements/") +# api.add_resource(LocationSensorPairing, "/locations///") \ No newline at end of file diff --git a/inventorymanager/config.py b/inventorymanager/config.py new file mode 100644 index 000000000..19d7e1545 --- /dev/null +++ b/inventorymanager/config.py @@ -0,0 +1,11 @@ +""" +This module contains configuration settings to set up the database +""" +import os + +basedir = os.path.abspath(os.path.dirname(__file__)) + +class Config: + SECRET_KEY = 'dev' + SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False \ No newline at end of file diff --git a/inventorymanager/constants.py b/inventorymanager/constants.py new file mode 100644 index 000000000..94f7a20d2 --- /dev/null +++ b/inventorymanager/constants.py @@ -0,0 +1,11 @@ +""" +This module contains constants used by the API +""" +#just copied this stuff, no idea what it does + +# MASON = "application/vnd.mason+json" +# LINK_RELATIONS_URL = "/sensorhub/link-relations/" +# ERROR_PROFILE = "/profiles/error/" +# SENSOR_PROFILE = "/profiles/sensor/" + +# MEASUREMENT_PAGE_SIZE = 50 \ No newline at end of file diff --git a/inventorymanager/models.py b/inventorymanager/models.py new file mode 100644 index 000000000..01b8821d5 --- /dev/null +++ b/inventorymanager/models.py @@ -0,0 +1,318 @@ +""" +This module contains all Model classes for our API, as well as click functions callable + from the command line +The classes are: + - Location + - Warehouse + - Item + - Stock + - Catalogue +The functions are responsible for initiliazing and populating the database +""" +import click + +from flask.cli import with_appcontext + +from inventorymanager import db + +# Association table for a many-to-many relationship between Items and Warehouses +# From https://lovelace.oulu.fi/ohjelmoitava-web/ohjelmoitava-web/introduction-to-web-development/#structure-of-databases +items_warehouses_association = db.Table('items_warehouses', + db.Column('item_id', db.Integer, db.ForeignKey('item.item_id'), primary_key=True), + db.Column('warehouse_id', db.Integer, db.ForeignKey('warehouse.warehouse_id'), primary_key=True) +) + +# Location model +class Location(db.Model): + location_id = db.Column(db.Integer, primary_key=True) + latitude = db.Column(db.Float, nullable=True) + longitude = db.Column(db.Float, nullable=True) + country = db.Column(db.String(64), nullable=False, default="Finland") + postal_code = db.Column(db.String(8), nullable=False) + city = db.Column(db.String(64), nullable=False) + street = db.Column(db.String(64), nullable=False) + + warehouse = db.relationship("Warehouse", back_populates="location", uselist=False) + + @staticmethod + def get_schema(): + return { + "type": "object", + "properties": { + "latitude": {"type": "number"}, + "longitude": {"type": "number"}, + "country": {"type": "string"}, + "postal_code": {"type": "string"}, + "city": {"type": "string"}, + "street": {"type": "string"} + }, + "required": ["country", "postal_code", "city", "street"], + "additionalProperties": False + } + + def serialize(self): + return { + "location_id": self.location_id, + "latitude": self.latitude, + "longitude": self.longitude, + "country": self.country, + "postal_code": self.postal_code, + "city": self.city, + "street": self.street + } + + def deserialize(self, doc): + self.latitude = doc.get("latitude", self.latitude) + self.longitude = doc.get("longitude", self.longitude) + self.country = doc.get("country", self.country) + self.postal_code = doc.get("postal_code", self.postal_code) + self.city = doc.get("city", self.city) + self.street = doc.get("street", self.street) + + + def __repr__(self): + return (f"") + +# Warehouse model +class Warehouse(db.Model): + warehouse_id = db.Column(db.Integer, primary_key=True) + manager = db.Column(db.String(64), nullable=True) + location_id = db.Column(db.Integer, db.ForeignKey('location.location_id', ondelete='CASCADE'), nullable=True) + #location = db.relationship('Location', backref=db.backref('warehouses', lazy=True)) + location = db.relationship("Location", back_populates="warehouse") + + # Many-to-many relationship with Items + items = db.relationship('Item', secondary=items_warehouses_association, back_populates="warehouses") + + @staticmethod + def get_schema(): + return { + "type": "object", + "properties": { + "manager": {"type": "string"}, + "location_id": {"type": "integer"} + }, + "required": [], + "additionalProperties": False + } + + def serialize(self): + return { + "warehouse_id": self.warehouse_id, + "manager": self.manager, + "location_id": self.location_id + } + + def deserialize(self, doc): + self.manager = doc.get("manager", self.manager) + self.location_id = doc.get("location_id", self.location_id) + + def __repr__(self): + return f"" + +# Item model +class Item(db.Model): + item_id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(64), nullable=False, unique=True) + category = db.Column(db.String(64), nullable=True) + weight = db.Column(db.Float, nullable=True) + + # Many-to-many relationship with Warehouses + warehouses = db.relationship('Warehouse', secondary=items_warehouses_association, back_populates="items") + + @staticmethod + def get_schema(): + return { + "type": "object", + "properties": { + "name": {"type": "string"}, + "category": {"type": "string"}, + "weight": {"type": "number"} + }, + "required": ["name"], + "additionalProperties": False + } + + def serialize(self): + return { + "item_id": self.item_id, + "name": self.name, + "category": self.category, + "weight": self.weight + } + + def deserialize(self, doc): + self.name = doc.get("name", self.name) + self.category = doc.get("category", self.category) + self.weight = doc.get("weight", self.weight) + + def __repr__(self): + return f"" + +# Stock model +class Stock(db.Model): + item_id = db.Column(db.Integer, db.ForeignKey('item.item_id'), primary_key=True) + warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouse.warehouse_id'), primary_key=True) + quantity = db.Column(db.Integer, nullable=False) + shelf_price = db.Column(db.Float, nullable=True) + + # Relationship + item = db.relationship('Item', backref=db.backref('stocks', lazy=True)) + warehouse = db.relationship('Warehouse', backref=db.backref('stocks', lazy=True)) + + @staticmethod + def get_schema(): + return { + "type": "object", + "properties": { + "quantity": {"type": "integer"}, + "shelf_price": {"type": "number"} + }, + "required": ["quantity"], + "additionalProperties": False + } + + def serialize(self): + return { + "item_id": self.item_id, + "warehouse_id": self.warehouse_id, + "quantity": self.quantity, + "shelf_price": self.shelf_price + } + + def deserialize(self, doc): + self.quantity = doc.get("quantity", self.quantity) + self.shelf_price = doc.get("shelf_price", self.shelf_price) + + def __repr__(self): + return f"" + +# Catalogue model +class Catalogue(db.Model): + item_id = db.Column(db.Integer, db.ForeignKey('item.item_id'), primary_key=True) + supplier_name = db.Column(db.String(64), primary_key=True) + min_order = db.Column(db.Integer, nullable=False) + order_price = db.Column(db.Float, nullable=True) + + # Relationship + item = db.relationship('Item', backref=db.backref('catalogues', lazy=True)) + + @staticmethod + def get_schema(): + return { + "type": "object", + "properties": { + "supplier_name": {"type": "string"}, + "min_order": {"type": "integer"}, + "order_price": {"type": "number"} + }, + "required": ["supplier_name", "min_order"], + "additionalProperties": False + } + def serialize(self): + return { + "item_id": self.item_id, + "supplier_name": self.supplier_name, + "min_order": self.min_order, + "order_price": self.order_price + } + + def deserialize(self, doc): + self.supplier_name = doc.get("supplier_name", self.supplier_name) + self.min_order = doc.get("min_order", self.min_order) + self.order_price = doc.get("order_price", self.order_price) + + def __repr__(self): + return f"" + + + +@click.command('init-db') +@with_appcontext +def init_db_command(): + """ + Initializes the database + """ + db.create_all() + +@click.command("populate-db") +@with_appcontext +def create_dummy_data(): + """ + Adds dummy data to the database + """ + # Create dummy locations + locations = [ + Location(latitude=60.1699, longitude=24.9384, country="Finland", postal_code="00100", city="Helsinki", street="Mannerheimintie"), + Location(latitude=60.4518, longitude=22.2666, country="Finland", postal_code="20100", city="Turku", street="Aurakatu"), + ] + + # Create dummy warehouses + warehouses = [ + Warehouse(manager="John Doe", location=locations[0]), + Warehouse(manager="Jane Doe", location=locations[1]), + ] + + # Create dummy items + items = [ + Item(name="Laptop", category="Electronics", weight=1.5), + Item(name="Smartphone", category="Electronics", weight=0.2), + ] + + # Create dummy stocks + stocks = [ + Stock(item=items[0], warehouse=warehouses[0], quantity=10, shelf_price=999.99), + Stock(item=items[1], warehouse=warehouses[1], quantity=20, shelf_price=599.99), + ] + + # Create dummy catalogues + catalogues = [ + Catalogue(item=items[0], supplier_name="TechSupplier A", min_order=5, order_price=950.00), + Catalogue(item=items[1], supplier_name="TechSupplier B", min_order=10, order_price=550.00), + ] + + # Add all to session and commit + db.session.add_all(locations + warehouses + items + stocks + catalogues) + db.session.commit() + + +if __name__ == "__main__": + test_location = Location(location_id = 5, latitude=60.1699, longitude=24.9384, country="Finland", postal_code="00100", city="Helsinki", street="Mannerheimintie") + print(test_location.serialize()) + test_location_json = {'latitude': 69, 'longitude': 42, 'country': 'Finland', 'postal_code': '00100', 'city': 'Helsinki', 'street': 'Mannerheimintie'} + test_location.deserialize(test_location_json) + print(test_location) + + test_warehouse = Warehouse(manager="John Doe", location=test_location) + print(test_warehouse.serialize()) + test_warehouse_json = {'manager': 'Jane Doe', 'location_id': 5} + test_warehouse.deserialize(test_warehouse_json) + print(test_warehouse) + + test_item = Item(name="Laptop", category="Electronics", weight=1.5) + print(test_item.serialize()) + test_item_json = {'name': 'Smartphone', 'category': 'Electronics', 'weight': 0.2} + test_item.deserialize(test_item_json) + print(test_item) + + test_stock = Stock(item=test_item, warehouse=test_warehouse, quantity=10, shelf_price=999.99) + print(test_stock.serialize()) + test_stock_json = {'quantity': 20, 'shelf_price': 599.99} + test_stock.deserialize(test_stock_json) + print(test_stock) + + test_catalogue = Catalogue(item=test_item, supplier_name="TechSupplier A", min_order=5, order_price=950.00) + print(test_catalogue.serialize()) + test_catalogue_json = {'supplier_name': 'TechSupplier B', 'min_order': 10, 'order_price': 550.00} + test_catalogue.deserialize(test_catalogue_json) + print(test_catalogue) + + from jsonschema import validate + validate(test_location_json, Location.get_location_schema()) + validate(test_warehouse_json, Warehouse.get_warehouse_schema()) + validate(test_item_json, Item.get_item_schema()) + validate(test_stock_json, Stock.get_stock_schema()) + validate(test_catalogue_json, Catalogue.get_catalogue_schema()) + \ No newline at end of file diff --git a/inventorymanager/resources/__init__.py b/inventorymanager/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/inventorymanager/resources/catalogueEntry.py b/inventorymanager/resources/catalogueEntry.py new file mode 100644 index 000000000..e69de29bb diff --git a/inventorymanager/resources/item.py b/inventorymanager/resources/item.py new file mode 100644 index 000000000..cc4981acc --- /dev/null +++ b/inventorymanager/resources/item.py @@ -0,0 +1,51 @@ +import json +from jsonschema import validate, ValidationError +from flask import Response, abort, request, url_for +from flask_restful import Resource +from sqlalchemy.exc import IntegrityError + +from inventorymanager.models import Item +from inventorymanager import db +from inventorymanager.constants import * + + +class ItemCollection(Resource): + + + def get(self): + body = [] + for item in Item.query.all(): + item_json = item.serialize() + item_json["uri"] = url_for("api.itemitem", item=item) + body.append(item_json) + + return Response(json.dumps(body), 200) + + + def post(self): + try: + validate(request.json, Item.get_schema()) + item = Item() + item.deserialize(request.json) + + db.session.add(item) + db.session.commit() + + except ValidationError as e: + return abort(400, e.message) + + except IntegrityError: + return abort(409, "Item already exists") + + return Response(status=201, headers={ + "Location": url_for("api.itemitem", item=item) + }) + + + + +class ItemItem(Resource): + + def get(self, item): + pass + diff --git a/inventorymanager/resources/location.py b/inventorymanager/resources/location.py new file mode 100644 index 000000000..e48cacd25 --- /dev/null +++ b/inventorymanager/resources/location.py @@ -0,0 +1,124 @@ +""" +Code edited from course example +https://github.com/enkwolf/pwp-course-sensorhub-api-example/blob/master/sensorhub/resources/location.py +Examples from PWP course exercise 2 +https://lovelace.oulu.fi/ohjelmoitava-web/ohjelmoitava-web/implementing-rest-apis-with-flask/#dynamic-schemas-static-methods +""" + +import json + +from flask import Response, request, url_for, abort +from flask_restful import Resource +from sqlalchemy.exc import IntegrityError +from werkzeug.exceptions import NotFound +from werkzeug.routing import BaseConverter + +from inventorymanager import db +from inventorymanager.models import Location +from jsonschema import validate, ValidationError + + +class LocationCollection(Resource): + """Class for collection of warehouse locations including addresses. /api/Locations/""" + + def get(self): + """Gets list of locations from database""" + body = [] + for location in Location.query.all(): + location_json = location.serialize() + location_json["uri"] = url_for("api.locationitem", location_id=location.location_id, _external=True) + body.append(location_json) + + return Response(json.dumps(body), 200, mimetype='application/json') + + def post(self): + try: + validate(request.json, Location.get_schema()) + location = Location() + location.deserialize(request.json) + + db.session.add(location) + db.session.commit() + + + except ValidationError as e: + return {'message': 'Validation error', 'errors': str(e)}, 400 + + + except IntegrityError: + db.session.rollback() + return {'message': 'Location already exists'}, 409 + + location_uri = url_for('api.locationitem', location_id=location.location_id, _external=True) + response = Response(status=201) + response.headers['Location'] = location_uri + return response + + +class LocationItem(Resource): + """ Class for a location resource. '/api/Locations/location_id/' """ + + def get(self, location_id): + + location = Location.query.get(location_id) + if not location: + return {"message": "Location not found"}, 404 + return location.serialize(), 200 + + def put(self, location_id): + """ + Updates existing location_id. Validates against JSON schema. + :parameter location_id: integer ID of location object + """ + if not request.is_json: + return {'message': 'Request must be JSON'}, 415 + + data = request.get_json() + try: + validate(instance=data, schema=Location.get_schema()) + except ValidationError as e: + return {'message': 'Validation error', 'errors': str(e)}, 400 + + location = Location.query.get(location_id) + if not location: + return {'message': 'Location not found'}, 404 + + location.deserialize(data) + + try: + db.session.add(location) + db.session.commit() + except Exception as e: + db.session.rollback() + return {'message': 'Database error', 'errors': str(e)}, 500 + + return {}, 204 + + def delete(self, location_id): + """ + Deletes existing location. Returns status code 204 if deletion is successful. + """ + location = Location.query.get(location_id) + if not location: + return {'message': 'Location not found'}, 404 + db.session.delete(location) + db.session.commit() + return Response(status=204) + + +# app.url_map.converters['db_location'] = LocationConverter + + +# class SensorItem(Resource): + +# @cache.cached() +# def get(self, sensor): +# db_sensor = Sensor.query.filter_by(name=sensor).first() +# if db_sensor is None: +# raise NotFound +# body = { +# "name": db_sensor.name, +# "model": db_sensor.model, +# "location": db_sensor.location.description +# } +# return Response(json.dumps(body), 200, mimetype=JSON) diff --git a/inventorymanager/resources/warehouse.py b/inventorymanager/resources/warehouse.py new file mode 100644 index 000000000..e69de29bb diff --git a/inventorymanager/static/readme.md b/inventorymanager/static/readme.md new file mode 100644 index 000000000..5223c0f00 --- /dev/null +++ b/inventorymanager/static/readme.md @@ -0,0 +1 @@ +This folder should contain html/css/js or any scripts related to front-end/end-user functionality \ No newline at end of file diff --git a/inventorymanager/utils.py b/inventorymanager/utils.py new file mode 100644 index 000000000..7e2320392 --- /dev/null +++ b/inventorymanager/utils.py @@ -0,0 +1,76 @@ +from werkzeug.routing import BaseConverter +from werkzeug.exceptions import NotFound +import json +from flask import url_for, request, Response + +from inventorymanager.constants import * +from inventorymanager.models import * + +class WarehouseConverter(BaseConverter): + + def to_python(self, value): + warehouse = Warehouse.query.filter_by(warehouse_id=value).first() + if warehouse is None: + raise NotFound + return warehouse + + def to_url(self, value): + return value.warehouse_id + + +class ItemConverter(BaseConverter): + + def to_python(self, value): + item = Item.query.filter_by(name=value).first() + if item is None: + raise NotFound + return item + + def to_url(self, value): + return value.name + + +class LocationConverter(BaseConverter): + """ + URLConverter for a location resource. + to_python takes a location_id and returns a Location object. + to_url takes a Location object and returns the corresponding location_id + """ + + def to_python(self, value): + """ + Converts a location_id in a location object with information from database + :parameter value: str representing the location id + raises a NotFound error if it is impossible to convert the string in an int or if the + location is not found. + :return: a Location object corresponding to the location_id. + """ + + location = Location.query.filter_by(location_id=value).first() + if location is None: + raise NotFound + return location + + def to_url(self, value): + """ + Converts a location object to a value used in the URI + :param value: Location Object + :return: the value + """ + + return value.location_id + + +# def create_error_response(status_code, title, message=None): +# """ +# Utility function that creates a Mason error response +# :param status_code: integer that represents a valid HTTP status code +# :param title: The title of the error (e.g. `Bad Request', 'Conflict') +# :param message: Longer message explaining what caused the error +# :return: A populated Response object +# """ +# resource_url = request.path +# body = MasonBuilder(resource_url=resource_url) +# body.add_error(title, message) +# body.add_control("profile", href=ERROR_PROFILE) +# return Response(json.dumps(body), status_code, mimetype=MASON) \ No newline at end of file diff --git a/meetings.md b/meetings.md index ed26b2baf..51e86c1fd 100644 --- a/meetings.md +++ b/meetings.md @@ -1,15 +1,20 @@ # Meetings notes ## Meeting 1. -* **DATE:** -* **ASSISTANTS:** +* **DATE:2024-01-29** +* **ASSISTANTS: Ivan Sanchez** ### Minutes *Summary of what was discussed during the meeting* +The discussion of the meeting was about the 1st deliverable about the api description. How different our project needs to be compared to the exercises and what grade are we aiming for as a group. ### Action points *List here the actions points discussed with assistants* - +1) fix typos in the main concept and relation section. +2) update diagram to include multiple warehouses +3) extend the uses section to include other clients that could use the api and proivde more ideas on how te api could be used. +4) Mention the methods used in the related work section and try to find clearly the api of the related work mentioned or find new/more services to which we can see the api +5) look for clients(applications) who use the apis mentioned in related work diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..f0010bb83 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +blinker==1.7.0 +click==8.1.7 +Flask==3.0.2 +Flask-SQLAlchemy==3.1.1 +greenlet==3.0.3 +itsdangerous==2.1.2 +Jinja2==3.1.3 +MarkupSafe==2.1.5 +SQLAlchemy==2.0.25 +typing_extensions==4.9.0 +Werkzeug==3.0.1 diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..20ac5a111 --- /dev/null +++ b/setup.py @@ -0,0 +1,18 @@ +from setuptools import find_packages, setup + +setup( + name="inventorymanager", + version="0.0.1", + packages=find_packages(), + include_package_data=True, + zip_safe=False, + install_requires=[ + "flask", + "flask-caching", + "flask-restful", + "flask-sqlalchemy", + "jsonschema", + "rfc3339-validator", + "SQLAlchemy", + ] +) \ No newline at end of file diff --git a/tests/api_test.py b/tests/api_test.py new file mode 100644 index 000000000..461456179 --- /dev/null +++ b/tests/api_test.py @@ -0,0 +1,313 @@ +""" +This module contains functionality related to testing the API +""" + +import json +import os +import pytest +import tempfile +from flask.testing import FlaskClient +from jsonschema import validate +from sqlalchemy.engine import Engine +from sqlalchemy import event +from sqlalchemy.exc import IntegrityError, StatementError +from werkzeug.datastructures import Headers + +from inventorymanager import create_app, db +from inventorymanager.models import Location, Warehouse, Item, Stock, Catalogue, create_dummy_data + +@event.listens_for(Engine, "connect") +def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + +# based on http://flask.pocoo.org/docs/1.0/testing/ +# we don't need a client for database testing, just the db handle +@pytest.fixture +def client(): + db_fd, db_fname = tempfile.mkstemp() + config = { + "SQLALCHEMY_DATABASE_URI": "sqlite:///" + db_fname, + "TESTING": True + } + + app = create_app(config) + + with app.app_context(): + db.create_all() + _populate_db() #couldn't get create_dummy_data to work + + yield app.test_client() + + os.close(db_fd) + os.unlink(db_fname) + +def _populate_db(): + """ + Adds dummy data to the database + """ + # Create dummy locations + locations = [ + Location(latitude=60.1699, longitude=24.9384, country="Finland", postal_code="00100", city="Helsinki", street="Mannerheimintie"), + Location(latitude=60.4518, longitude=22.2666, country="Finland", postal_code="20100", city="Turku", street="Aurakatu"), + ] + + # Create dummy warehouses + warehouses = [ + Warehouse(manager="John Doe", location=locations[0]), + Warehouse(manager="Jane Doe", location=locations[1]), + ] + + # Create dummy items + items = [ + Item(name="Laptop", category="Electronics", weight=1.5), + Item(name="Smartphone", category="Electronics", weight=0.2), + ] + + # Create dummy stocks + stocks = [ + Stock(item=items[0], warehouse=warehouses[0], quantity=10, shelf_price=999.99), + Stock(item=items[1], warehouse=warehouses[1], quantity=20, shelf_price=599.99), + ] + + # Create dummy catalogues + catalogues = [ + Catalogue(item=items[0], supplier_name="TechSupplier A", min_order=5, order_price=950.00), + Catalogue(item=items[1], supplier_name="TechSupplier B", min_order=10, order_price=550.00), + ] + + # Add all to session and commit + db.session.add_all(locations + warehouses + items + stocks + catalogues) + db.session.commit() + +def _get_item_json(number=1): + """ + Creates a valid sensor JSON object to be used for PUT and POST tests. + """ + return {'name': f'Smartphone-{number}', 'category': 'Electronics', 'weight': 0.2} + +# def _check_namespace(client, response): +# """ +# Checks that the "senhub" namespace is found from the response body, and +# that its "name" attribute is a URL that can be accessed. +# """ + +# ns_href = response["@namespaces"]["senhub"]["name"] +# resp = client.get(ns_href) +# assert resp.status_code == 200 + +# def _check_control_get_method(ctrl, client, obj): +# """ +# Checks a GET type control from a JSON object be it root document or an item +# in a collection. Also checks that the URL of the control can be accessed. +# """ + +# href = obj["@controls"][ctrl]["href"] +# resp = client.get(href) +# assert resp.status_code == 200 + +# def _check_control_delete_method(ctrl, client, obj): +# """ +# Checks a DELETE type control from a JSON object be it root document or an +# item in a collection. Checks the contrl's method in addition to its "href". +# Also checks that using the control results in the correct status code of 204. +# """ + +# href = obj["@controls"][ctrl]["href"] +# method = obj["@controls"][ctrl]["method"].lower() +# assert method == "delete" +# resp = client.delete(href) +# assert resp.status_code == 204 + +# def _check_control_put_method(ctrl, client, obj): +# """ +# Checks a PUT type control from a JSON object be it root document or an item +# in a collection. In addition to checking the "href" attribute, also checks +# that method, encoding and schema can be found from the control. Also +# validates a valid sensor against the schema of the control to ensure that +# they match. Finally checks that using the control results in the correct +# status code of 204. +# """ + +# ctrl_obj = obj["@controls"][ctrl] +# href = ctrl_obj["href"] +# method = ctrl_obj["method"].lower() +# encoding = ctrl_obj["encoding"].lower() +# schema = ctrl_obj["schema"] +# assert method == "put" +# assert encoding == "json" +# body = _get_sensor_json() +# body["name"] = obj["name"] +# validate(body, schema) +# resp = client.put(href, json=body) +# assert resp.status_code == 204 + +# def _check_control_post_method(ctrl, client, obj, obj_to_post): # currently not used +# """ +# Checks a POST type control from a JSON object be it root document or an item +# in a collection. In addition to checking the "href" attribute, also checks +# that method, encoding and schema can be found from the control. Also +# validates a valid sensor against the schema of the control to ensure that +# they match. Finally checks that using the control results in the correct +# status code of 201. +# """ + +# ctrl_obj = obj["@controls"][ctrl] +# href = ctrl_obj["href"] +# method = ctrl_obj["method"].lower() +# encoding = ctrl_obj["encoding"].lower() +# schema = ctrl_obj["schema"] +# assert method == "post" +# assert encoding == "json" +# body = obj_to_post +# validate(body, schema) +# resp = client.post(href, json=body) +# assert resp.status_code == 201 + +class TestLocationCollection(object): + RESOURCE_URL = "/api/location/" + + def test_get(self, client): + resp = client.get(self.RESOURCE_URL) + assert resp.status_code == 200 + body = json.loads(resp.data) + assert len(body) == 2 + + for item in body: + assert "uri" in item + resp = client.get(item["uri"]) + assert resp.status_code == 200 + + def test_post(self, client): + valid = _get_item_json() + + # test with wrong content type + resp = client.post(self.RESOURCE_URL, data="notjson") + assert resp.status_code in (400, 415) + + # test with valid and see that it exists afterward + resp = client.post(self.RESOURCE_URL, json=valid) + assert resp.status_code == 201 + assert resp.headers["Location"].endswith(self.RESOURCE_URL + valid["name"] + "/") + resp = client.get(resp.headers["Location"]) + assert resp.status_code == 200 + + # send same data again for 409 + resp = client.post(self.RESOURCE_URL, json=valid) + assert resp.status_code == 409 + + # remove model field for 400 + valid.pop("name") + resp = client.post(self.RESOURCE_URL, json=valid) + assert resp.status_code == 400 + +class TestItemCollection(object): + + RESOURCE_URL = "/api/items/" + + def test_get(self, client): + resp = client.get(self.RESOURCE_URL) + assert resp.status_code == 200 + body = json.loads(resp.data) + assert len(body) == 2 + + for item in body: + + assert "uri" in item + resp = client.get(item["uri"]) + assert resp.status_code == 200 + + def test_post(self, client): + valid = _get_item_json() + + # test with wrong content type + resp = client.post(self.RESOURCE_URL, data="notjson") + assert resp.status_code in (400, 415) + + # test with valid and see that it exists afterward + resp = client.post(self.RESOURCE_URL, json=valid) + assert resp.status_code == 201 + assert resp.headers["Location"].endswith(self.RESOURCE_URL + valid["name"] + "/") + resp = client.get(resp.headers["Location"]) + assert resp.status_code == 200 + + # send same data again for 409 + resp = client.post(self.RESOURCE_URL, json=valid) + assert resp.status_code == 409 + + # remove model field for 400 + valid.pop("name") + resp = client.post(self.RESOURCE_URL, json=valid) + assert resp.status_code == 400 + + +# class TestSensorItem(object): + +# RESOURCE_URL = "/api/sensors/test-sensor-1/" +# INVALID_URL = "/api/sensors/non-sensor-x/" + +# def test_get(self, client): +# resp = client.get(self.RESOURCE_URL) +# assert resp.status_code == 200 +# body = json.loads(resp.data) +# _check_namespace(client, body) +# _check_control_get_method("profile", client, body) +# _check_control_get_method("collection", client, body) +# _check_control_put_method("edit", client, body) +# _check_control_delete_method("senhub:delete", client, body) +# resp = client.get(self.INVALID_URL) +# assert resp.status_code == 404 + +# def test_put(self, client): +# valid = _get_sensor_json() + +# # test with wrong content type +# resp = client.put(self.RESOURCE_URL, data="notjson", headers=Headers({"Content-Type": "text"})) +# assert resp.status_code in (400, 415) + +# resp = client.put(self.INVALID_URL, json=valid) +# assert resp.status_code == 404 + +# # test with another sensor's name +# valid["name"] = "test-sensor-2" +# resp = client.put(self.RESOURCE_URL, json=valid) +# assert resp.status_code == 409 + +# # test with valid (only change model) +# valid["name"] = "test-sensor-1" +# resp = client.put(self.RESOURCE_URL, json=valid) +# assert resp.status_code == 204 + +# # remove field for 400 +# valid.pop("model") +# resp = client.put(self.RESOURCE_URL, json=valid) +# assert resp.status_code == 400 + +# def test_delete(self, client): +# resp = client.delete(self.RESOURCE_URL) +# assert resp.status_code == 204 +# resp = client.delete(self.RESOURCE_URL) +# assert resp.status_code == 404 +# resp = client.delete(self.INVALID_URL) +# assert resp.status_code == 404 + + +if __name__ == "__main__": + client() + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/db_test.py b/tests/db_test.py new file mode 100644 index 000000000..b5c93c83e --- /dev/null +++ b/tests/db_test.py @@ -0,0 +1,297 @@ +# """ +# This module contains functionality related to testing the database +# """ + +# import os +# import pytest +# import tempfile +# import time +# from datetime import datetime +# from sqlalchemy.engine import Engine +# from sqlalchemy import event +# from sqlalchemy.exc import IntegrityError, StatementError + +# from sensorhub import create_app, db +# from sensorhub.models import Location, Sensor, Deployment, Measurement + +# @event.listens_for(Engine, "connect") +# def set_sqlite_pragma(dbapi_connection, connection_record): +# cursor = dbapi_connection.cursor() +# cursor.execute("PRAGMA foreign_keys=ON") +# cursor.close() + +# # based on http://flask.pocoo.org/docs/1.0/testing/ +# # we don't need a client for database testing, just the db handle +# @pytest.fixture +# def app(): +# db_fd, db_fname = tempfile.mkstemp() +# config = { +# "SQLALCHEMY_DATABASE_URI": "sqlite:///" + db_fname, +# "TESTING": True +# } + +# app = create_app(config) + +# with app.app_context(): +# db.create_all() + +# yield app + +# os.close(db_fd) +# os.unlink(db_fname) + +# def _get_location(sitename="alpha"): +# return Location( +# name="site-{}".format(sitename), +# latitude=63.3, +# longitude=22.6, +# altitude=24.5, +# description="test site {}".format(sitename) +# ) + +# def _get_sensor(number=1): +# return Sensor( +# name="donkeysensor-{}".format(number), +# model="donkeysensor2000", +# ) + +# def _get_measurement(): +# return Measurement( +# value=44.51, +# time=datetime.now() +# ) + +# def _get_deployment(): +# return Deployment( +# start=datetime(2019, 1, 1, 0, 0, 1), +# end=datetime(2020, 1, 1, 0, 0, 0), +# name="test deployment" +# ) + +# def test_create_instances(app): +# """ +# Tests that we can create one instance of each model and save them to the +# database using valid values for all columns. After creation, test that +# everything can be found from database, and that all relationships have been +# saved correctly. +# """ + +# with app.app_context(): +# # Create everything +# location = _get_location() +# sensor = _get_sensor() +# measurement = _get_measurement() +# deployment = _get_deployment() +# sensor.location = location +# measurement.sensor = sensor +# deployment.sensors.append(sensor) +# db.session.add(location) +# db.session.add(sensor) +# db.session.add(measurement) +# db.session.add(deployment) +# db.session.commit() + +# # Check that everything exists +# assert Location.query.count() == 1 +# assert Sensor.query.count() == 1 +# assert Measurement.query.count() == 1 +# assert Deployment.query.count() == 1 +# db_sensor = Sensor.query.first() +# db_measurement = Measurement.query.first() +# db_location = Location.query.first() +# db_deployment = Deployment.query.first() + +# # Check all relationships (both sides) +# assert db_measurement.sensor == db_sensor +# assert db_location.sensor == db_sensor +# assert db_sensor.location == db_location +# assert db_sensor in db_deployment.sensors +# assert db_deployment in db_sensor.deployments +# assert db_measurement in db_sensor.measurements + +# def test_location_sensor_one_to_one(app): +# """ +# Tests that the relationship between sensor and location is one-to-one. +# i.e. that we cannot assign the same location for two sensors. +# """ + +# with app.app_context(): +# location = _get_location() +# sensor_1 = _get_sensor(1) +# sensor_2 = _get_sensor(2) +# sensor_1.location = location +# sensor_2.location = location +# db.session.add(location) +# db.session.add(sensor_1) +# db.session.add(sensor_2) +# with pytest.raises(IntegrityError): +# db.session.commit() + +# def test_measurement_ondelete_sensor(app): +# """ +# Tests that measurement's sensor foreign key is set to null when the sensor +# is deleted. +# """ + +# with app.app_context(): +# measurement = _get_measurement() +# sensor = _get_sensor() +# measurement.sensor = sensor +# db.session.add(measurement) +# db.session.commit() +# db.session.delete(sensor) +# db.session.commit() +# assert measurement.sensor is None + +# def test_location_columns(app): +# """ +# Tests the types and restrictions of location columns. Checks that numerical +# values only accepts numbers, name must be present and is unique, and that +# all of the columns are optional. +# """ + +# with app.app_context(): +# location = _get_location() +# location.latitude = str(location.latitude) + "°" +# db.session.add(location) +# with pytest.raises(StatementError): +# db.session.commit() + +# db.session.rollback() + +# location = _get_location() +# location.longitude = str(location.longitude) + "°" +# db.session.add(location) +# with pytest.raises(StatementError): +# db.session.commit() + +# db.session.rollback() + +# location = _get_location() +# location.altitude = str(location.altitude) + "m" +# db.session.add(location) +# with pytest.raises(StatementError): +# db.session.commit() + +# db.session.rollback() + +# location = _get_location() +# location.name = None +# db.session.add(location) +# with pytest.raises(IntegrityError): +# db.session.commit() + +# db.session.rollback() + +# location_1 = _get_location() +# location_2 = _get_location() +# db.session.add(location_1) +# db.session.add(location_2) +# with pytest.raises(IntegrityError): +# db.session.commit() + +# db.session.rollback() + +# location = Location(name="site-test") +# db.session.add(location) +# db.session.commit() + +# def test_sensor_columns(app): +# """ +# Tests sensor columns' restrictions. Name must be unique, and name and model +# must be mandatory. +# """ + +# with app.app_context(): +# sensor_1 = _get_sensor() +# sensor_2 = _get_sensor() +# db.session.add(sensor_1) +# db.session.add(sensor_2) +# with pytest.raises(IntegrityError): +# db.session.commit() + +# db.session.rollback() + +# sensor = _get_sensor() +# sensor.name = None +# db.session.add(sensor) +# with pytest.raises(IntegrityError): +# db.session.commit() + +# db.session.rollback() + +# sensor = _get_sensor() +# sensor.model = None +# db.session.add(sensor) +# with pytest.raises(IntegrityError): +# db.session.commit() + +# def test_measurement_columns(app): +# """ +# Tests that a measurement value only accepts floating point values and that +# time only accepts datetime values. +# """ + +# with app.app_context(): +# measurement = _get_measurement() +# measurement.value = str(measurement.value) + "kg" +# db.session.add(measurement) +# with pytest.raises(StatementError): +# db.session.commit() + +# db.session.rollback() + +# measurement = _get_measurement() +# measurement.time = time.time() +# db.session.add(measurement) +# with pytest.raises(StatementError): +# db.session.commit() + +# def test_deployment_columns(app): +# """ +# Tests that all columns in the deployment table are mandatory. Also tests +# that start and end only accept datetime values. +# """ + +# with app.app_context(): +# # Tests for nullable +# deployment = _get_deployment() +# deployment.start = None +# db.session.add(deployment) +# with pytest.raises(IntegrityError): +# db.session.commit() + +# db.session.rollback() + +# deployment = _get_deployment() +# deployment.end = None +# db.session.add(deployment) +# with pytest.raises(IntegrityError): +# db.session.commit() + +# db.session.rollback() + +# deployment = _get_deployment() +# deployment.name = None +# db.session.add(deployment) +# with pytest.raises(IntegrityError): +# db.session.commit() + +# db.session.rollback() + +# # Tests for column type +# deployment = _get_deployment() +# deployment.start = time.time() +# db.session.add(deployment) +# with pytest.raises(StatementError): +# db.session.commit() + +# db.session.rollback() + +# deployment = _get_deployment() +# deployment.end = time.time() +# db.session.add(deployment) +# with pytest.raises(StatementError): +# db.session.commit() + +# db.session.rollback() \ No newline at end of file