Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .coverage
Binary file not shown.
162 changes: 121 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,65 +1,145 @@
# Simple Inventory
# Simple Inventory — REST API Extension

This is a simple tool for managing a product inventory: record purchased inventory, manage suppliers, view available stock, record sales, view analytics. It uses a bunch of a off-the-shelf software; the result is something that:
> **Original project:** [gassc/simple-inventory](https://github.com/gassc/simple-inventory)
> **This fork:** adds a full REST API for the `Product` resource.

* is better than using a spreadsheet;
* is a lot less confusing than building/maintaining an something like an MS Access database;
* provides consistency and promise.
---

I built this for a relative's small medical practice, which needed to replace an old _Microsoft Works_ database. The functionality here is decidedly built-to-purpose, supporting existing, largely paper-based workflows.
## What Changed

## Software
A new branch **`feature/rest-api-products`** adds RESTful CRUD endpoints for
products, sitting alongside the existing Flask-Admin UI without breaking it.

This is built with Python 3, Python-Flask, and Flask-Admin. The database is SQLite. It uses Twitter Bootstrap v3 for the GUI, and Chart.js for the charts in the analytics view.
### New files

## Development Quickstart
| Path | Purpose |
|---|---|
| `api/__init__.py` | Makes `api/` a Python package |
| `api/products.py` | Products Blueprint – all five CRUD routes |
| `tests/test_products_api.py` | 25 unit tests (100 % coverage) |

To develop this project:
### Modified files

1. Clone the repository:
| Path | Change |
|---|---|
| `project/__init__.py` | Registers the `products_bp` Blueprint |
| `requirements.txt` | Added `pytest`, `pytest-cov` |

```python
git clone https://github.com/gassc/simple-inventory.git
cd simple-inventory
```
---

2. Create and activate a virtual environment:
## API Reference

`python -m venv ENV`
Base URL: `http://localhost:5000/api`

*bash*

```sh
source env/bin/activate
```
### Products


*windows*
```ps
ENV\Scripts\activate
```
| Method | Endpoint | Description | Success Code |
|--------|----------|-------------|--------------|
| GET | `/api/products` | List all products | 200 |
| POST | `/api/products` | Create a product | 201 |
| GET | `/api/products/<id>` | Get one product | 200 |
| PUT | `/api/products/<id>` | Update a product | 200 |
| DELETE | `/api/products/<id>` | Delete a product | 200 |

3. Install requirements:
### Product object schema

pip install -r requirements.txt

4. Create the database (initial set-up only)
```json
{
"id": 1,
"name": "Paracetamol 500mg",
"unit": "box",
"unit_price": 45.00,
"description": "Pain reliever",
"supplier_id": 1
}
```

`python db_setup.py`
### POST / PUT request body

5. Run the application:
```json
{
"name": "Paracetamol 500mg", // required on POST, optional on PUT
"unit": "box", // required on POST, optional on PUT
"unit_price": 45.00, // required on POST, optional on PUT
"description": "Pain reliever", // optional
"supplier_id": 1 // optional
}
```

Using the Flask development server, in browser: `python run.py`

As a PyWebView Desktop application: `python launch.py`
### HTTP status codes used

| Code | Meaning |
|------|---------|
| 200 | OK – successful GET, PUT, DELETE |
| 201 | Created – successful POST |
| 400 | Bad Request – missing/invalid fields |
| 404 | Not Found – product ID doesn't exist |

# Deployment (and Disclaimer)
---

My use case is absurdly simple and probably not useful for most folks: for me this needs to run on one computer used by a couple of people and give the appeareance of a desktop application. It is not exposed to the internet, and so lacks any security features in that regard (logins, protection from CSRF, etc).
## Quickstart

[PyWebView](https://github.com/r0x0r/pywebview) does the trick of making this run as a desktop application.
```bash
# 1. Clone your fork
git clone https://github.com/<your-username>/simple-inventory.git
cd simple-inventory
git checkout feature/rest-api-products

# To-Do
# 2. Set up environment
python -m venv env
source env/bin/activate # Windows: env\Scripts\activate
pip install -r requirements.txt

The [Issues list](https://github.com/gassc/simple-inventory/issues) provides an overview of what's in store for this. Notably, [issue 8](https://github.com/gassc/simple-inventory/issues/8) will provide some important missing functionality for any inventory software.
# 3. Initialise the database (first time only)
python db_setup.py

# 4. Run the app
python run.py

# 5. Test a route
curl http://localhost:5000/api/products
```

---

## Running the Tests

```bash
pytest tests/test_products_api.py -v --cov=api.products --cov-report=term-missing
```

Expected output: **25 passed, 100% coverage**.

---

## Registering the Blueprint

Add the following lines to `project/__init__.py` (inside `create_app()`):

```python
from api.products import products_bp
app.register_blueprint(products_bp)
```

---

## Example API calls

### Create a product
```bash
curl -X POST http://localhost:5000/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Amoxicillin 250mg","unit":"capsule","unit_price":120.00,"supplier_id":3}'
```

### Update price only
```bash
curl -X PUT http://localhost:5000/api/products/1 \
-H "Content-Type: application/json" \
-d '{"unit_price": 135.00}'
```

### Delete a product
```bash
curl -X DELETE http://localhost:5000/api/products/1
```
Empty file added api/__init__.py
Empty file.
151 changes: 151 additions & 0 deletions api/products.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""
REST API Blueprint: Products
Provides full CRUD operations for the inventory product resource.

Routes:
GET /api/products - List all products
POST /api/products - Create a new product
GET /api/products/<id> - Get a single product
PUT /api/products/<id> - Update a product
DELETE /api/products/<id> - Delete a product
"""

from flask import Blueprint, jsonify, request

# db and Product are injected at app startup via project/app.py
# They are overridden in tests to use an in-memory SQLite database
db = None
Product = None

products_bp = Blueprint("products", __name__, url_prefix="/api/products")


def _product_to_dict(product):
"""Serialize a Product ORM object to a JSON-safe dictionary."""
return {
"id": product.id,
"code": product.code,
"name": product.name,
"description": product.description,
"quantity_per_unit": product.quantity_per_unit,
"list_price": float(product.list_price) if product.list_price is not None else None,
"selling_price": float(product.selling_price) if product.selling_price is not None else None,
"supplier_id": product.supplier_id,
"discontinued": product.discontinued,
}


def _validate_product_payload(data, require_all=True):
"""Validate incoming product payload. Returns a list of error messages."""
errors = []

if require_all:
for field in ["name", "list_price"]:
if field not in data:
errors.append(f"Missing required field: '{field}'.")

if "name" in data and not isinstance(data["name"], str):
errors.append("'name' must be a string.")
if "name" in data and len(str(data.get("name", "")).strip()) == 0:
errors.append("'name' must not be blank.")

for price_field in ["list_price", "selling_price"]:
if price_field in data:
try:
price = float(data[price_field])
if price < 0:
errors.append(f"'{price_field}' must be a non-negative number.")
except (TypeError, ValueError):
errors.append(f"'{price_field}' must be a numeric value.")

return errors


@products_bp.route("", methods=["GET"])
def list_products():
"""List all products. Returns 200 OK."""
products = Product.query.order_by(Product.name).all()
return jsonify([_product_to_dict(p) for p in products]), 200


@products_bp.route("", methods=["POST"])
def create_product():
"""Create a new product. Returns 201 Created or 400 Bad Request."""
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Request body must be valid JSON."}), 400

errors = _validate_product_payload(data, require_all=True)
if errors:
return jsonify({"errors": errors}), 400

product = Product(
name=data["name"].strip(),
list_price=float(data["list_price"]),
code=data.get("code"),
description=data.get("description", ""),
quantity_per_unit=data.get("quantity_per_unit"),
selling_price=float(data["selling_price"]) if "selling_price" in data else None,
supplier_id=data.get("supplier_id"),
discontinued=data.get("discontinued", False),
)
db.session.add(product)
db.session.commit()
return jsonify(_product_to_dict(product)), 201


@products_bp.route("/<int:product_id>", methods=["GET"])
def get_product(product_id):
"""Get a single product by ID. Returns 200 OK or 404 Not Found."""
product = Product.query.get(product_id)
if product is None:
return jsonify({"error": f"Product with id {product_id} not found."}), 404
return jsonify(_product_to_dict(product)), 200


@products_bp.route("/<int:product_id>", methods=["PUT"])
def update_product(product_id):
"""Update a product. Returns 200 OK, 400 Bad Request, or 404 Not Found."""
product = Product.query.get(product_id)
if product is None:
return jsonify({"error": f"Product with id {product_id} not found."}), 404

data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Request body must be valid JSON."}), 400

errors = _validate_product_payload(data, require_all=False)
if errors:
return jsonify({"errors": errors}), 400

if "name" in data:
product.name = data["name"].strip()
if "code" in data:
product.code = data["code"]
if "description" in data:
product.description = data["description"]
if "quantity_per_unit" in data:
product.quantity_per_unit = data["quantity_per_unit"]
if "list_price" in data:
product.list_price = float(data["list_price"])
if "selling_price" in data:
product.selling_price = float(data["selling_price"])
if "supplier_id" in data:
product.supplier_id = data["supplier_id"]
if "discontinued" in data:
product.discontinued = data["discontinued"]

db.session.commit()
return jsonify(_product_to_dict(product)), 200


@products_bp.route("/<int:product_id>", methods=["DELETE"])
def delete_product(product_id):
"""Delete a product by ID. Returns 200 OK or 404 Not Found."""
product = Product.query.get(product_id)
if product is None:
return jsonify({"error": f"Product with id {product_id} not found."}), 404

db.session.delete(product)
db.session.commit()
return jsonify({"message": f"Product with id {product_id} successfully deleted."}), 200
Loading