From 242c5c08f0c3fa3f67d40c0ee36fc9ae15e3ddf9 Mon Sep 17 00:00:00 2001 From: Jasen Qin Date: Wed, 17 Jul 2024 07:54:02 +1000 Subject: [PATCH] autocommit 17-07-2024-07-54 --- py-kivy/API.md | 534 ++++++++++++++++++ py-kivy/poetry.lock | 36 +- py-kivy/pos_system/models.py | 33 +- py-kivy/pos_system/routers/items.py | 8 +- py-kivy/pyproject.toml | 2 + py-kivy/pytest.ini | 11 + py-kivy/tests/.DS_Store | Bin 0 -> 6148 bytes py-kivy/tests/.gitignore | 1 + py-kivy/tests/__init__.py | 0 ...{test_fastapi.py => test_items_fastapi.py} | 56 +- 10 files changed, 645 insertions(+), 36 deletions(-) create mode 100644 py-kivy/API.md create mode 100644 py-kivy/pytest.ini create mode 100644 py-kivy/tests/.DS_Store create mode 100644 py-kivy/tests/.gitignore create mode 100644 py-kivy/tests/__init__.py rename py-kivy/tests/{test_fastapi.py => test_items_fastapi.py} (64%) diff --git a/py-kivy/API.md b/py-kivy/API.md new file mode 100644 index 0000000..69316ef --- /dev/null +++ b/py-kivy/API.md @@ -0,0 +1,534 @@ +# POS System API Documentation + +## Base URL + +All URLs referenced in the documentation have the following base: + +``` +http://localhost:8000/api/v1 +``` + +## Authentication + +Most endpoints require authentication. Use the following endpoint to obtain a JWT token: + +### Login for Access Token + +``` +POST /token +``` + +**Request Body:** + +```json +{ + "username": "string", + "password": "string" +} +``` + +**Response:** + +```json +{ + "access_token": "string", + "token_type": "bearer" +} +``` + +Use the received token in the Authorization header for subsequent requests: + +``` +Authorization: Bearer +``` + +## Items + +### Create a new item + +``` +POST /items +``` + +**Request Body:** + +```json +{ + "name": "string", + "price": 0, + "quantity": 0, + "unit": "string", + "related_items": ["string"] +} +``` + +**Response:** + +```json +{ + "id": "string", + "name": "string", + "price": 0, + "quantity": 0, + "unit": "string", + "related_items": ["string"] +} +``` + +### Get all items + +``` +GET /items +``` + +**Query Parameters:** + +- `skip` (optional): number of items to skip +- `limit` (optional): maximum number of items to return + +**Response:** + +```json +[ + { + "id": "string", + "name": "string", + "price": 0, + "quantity": 0, + "unit": "string", + "related_items": ["string"] + } +] +``` + +### Get a specific item + +``` +GET /items/{item_id} +``` + +**Response:** + +```json +{ + "id": "string", + "name": "string", + "price": 0, + "quantity": 0, + "unit": "string", + "related_items": ["string"] +} +``` + +### Update an item + +``` +PUT /items/{item_id} +``` + +**Request Body:** + +```json +{ + "name": "string", + "price": 0, + "quantity": 0, + "unit": "string", + "related_items": ["string"] +} +``` + +**Response:** + +```json +{ + "id": "string", + "name": "string", + "price": 0, + "quantity": 0, + "unit": "string", + "related_items": ["string"] +} +``` + +### Delete an item + +``` +DELETE /items/{item_id} +``` + +**Response:** + +```json +{ + "message": "Item successfully deleted" +} +``` + +## Orders + +### Create a new order + +``` +POST /orders +``` + +**Request Body:** + +```json +{ + "user_id": "string", + "items": [ + { + "item_id": "string", + "quantity": 0, + "price_at_order": 0 + } + ], + "total_amount": 0, + "payment_method": "string", + "notes": "string" +} +``` + +**Response:** + +```json +{ + "id": "string", + "user_id": "string", + "items": [ + { + "item_id": "string", + "quantity": 0, + "price_at_order": 0 + } + ], + "total_amount": 0, + "payment_method": "string", + "payment_status": "string", + "order_status": "string", + "created_at": "string", + "updated_at": "string", + "discount_applied": 0, + "notes": "string" +} +``` + +### Get all orders + +``` +GET /orders +``` + +**Query Parameters:** + +- `skip` (optional): number of orders to skip +- `limit` (optional): maximum number of orders to return + +**Response:** + +```json +[ + { + "id": "string", + "user_id": "string", + "items": [ + { + "item_id": "string", + "quantity": 0, + "price_at_order": 0 + } + ], + "total_amount": 0, + "payment_method": "string", + "payment_status": "string", + "order_status": "string", + "created_at": "string", + "updated_at": "string", + "discount_applied": 0, + "notes": "string" + } +] +``` + +### Get a specific order + +``` +GET /orders/{order_id} +``` + +**Response:** + +```json +{ + "id": "string", + "user_id": "string", + "items": [ + { + "item_id": "string", + "quantity": 0, + "price_at_order": 0 + } + ], + "total_amount": 0, + "payment_method": "string", + "payment_status": "string", + "order_status": "string", + "created_at": "string", + "updated_at": "string", + "discount_applied": 0, + "notes": "string" +} +``` + +### Update an order + +``` +PUT /orders/{order_id} +``` + +**Request Body:** + +```json +{ + "items": [ + { + "item_id": "string", + "quantity": 0, + "price_at_order": 0 + } + ], + "total_amount": 0, + "payment_method": "string", + "payment_status": "string", + "order_status": "string", + "discount_applied": 0, + "notes": "string" +} +``` + +**Response:** + +```json +{ + "id": "string", + "user_id": "string", + "items": [ + { + "item_id": "string", + "quantity": 0, + "price_at_order": 0 + } + ], + "total_amount": 0, + "payment_method": "string", + "payment_status": "string", + "order_status": "string", + "created_at": "string", + "updated_at": "string", + "discount_applied": 0, + "notes": "string" +} +``` + +### Delete an order + +``` +DELETE /orders/{order_id} +``` + +**Response:** + +```json +{ + "message": "Order successfully deleted" +} +``` + +### Process payment for an order + +``` +POST /orders/{order_id}/process_payment +``` + +**Request Body:** + +```json +{ + "payment_method": "string" +} +``` + +**Response:** + +```json +{ + "message": "Payment processed successfully" +} +``` + +### Apply discount to an order + +``` +POST /orders/{order_id}/apply_discount +``` + +**Request Body:** + +```json +{ + "discount_percentage": 0 +} +``` + +**Response:** + +```json +{ + "id": "string", + "total_amount": 0, + "discount_applied": 0 +} +``` + +## Users + +### Register a new user + +``` +POST /users +``` + +**Request Body:** + +```json +{ + "username": "string", + "email": "string", + "full_name": "string", + "password": "string" +} +``` + +**Response:** + +```json +{ + "id": "string", + "username": "string", + "email": "string", + "full_name": "string", + "is_active": true, + "is_superuser": false +} +``` + +### Get current user + +``` +GET /users/me +``` + +**Response:** + +```json +{ + "id": "string", + "username": "string", + "email": "string", + "full_name": "string", + "is_active": true, + "is_superuser": false +} +``` + +### Get all users + +``` +GET /users +``` + +**Query Parameters:** + +- `skip` (optional): number of users to skip +- `limit` (optional): maximum number of users to return + +**Response:** + +```json +[ + { + "id": "string", + "username": "string", + "email": "string", + "full_name": "string", + "is_active": true, + "is_superuser": false + } +] +``` + +### Update a user + +``` +PUT /users/{user_id} +``` + +**Request Body:** + +```json +{ + "email": "string", + "full_name": "string", + "password": "string", + "is_active": true, + "is_superuser": false +} +``` + +**Response:** + +```json +{ + "id": "string", + "username": "string", + "email": "string", + "full_name": "string", + "is_active": true, + "is_superuser": false +} +``` + +### Delete a user + +``` +DELETE /users/{user_id} +``` + +**Response:** + +```json +{ + "message": "User successfully deleted" +} +``` + +## Error Responses + +All endpoints can return the following error responses: + +- 400 Bad Request +- 401 Unauthorized +- 403 Forbidden +- 404 Not Found +- 422 Unprocessable Entity +- 500 Internal Server Error + +Error response body: + +```json +{ + "detail": "Error message" +} +``` diff --git a/py-kivy/poetry.lock b/py-kivy/poetry.lock index e0b54b0..dfe724e 100644 --- a/py-kivy/poetry.lock +++ b/py-kivy/poetry.lock @@ -401,6 +401,20 @@ files = [ dnspython = ">=2.0.0" idna = ">=2.0.0" +[[package]] +name = "execnet" +version = "2.1.1" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +files = [ + {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, + {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + [[package]] name = "executing" version = "2.0.1" @@ -1511,6 +1525,26 @@ pluggy = ">=1.5,<2.0" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-xdist" +version = "3.6.1" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, + {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2308,4 +2342,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "e206ba057f9668e090364cc695646a317f0896de3d81b60e05450b7498324d84" +content-hash = "0d6489d261e19e3bffaafb61a05fdc436dae4801beefcaf640e9abc4b7bad8fd" diff --git a/py-kivy/pos_system/models.py b/py-kivy/pos_system/models.py index 531dd6a..c2e00b5 100644 --- a/py-kivy/pos_system/models.py +++ b/py-kivy/pos_system/models.py @@ -1,13 +1,7 @@ -from pydantic import BaseModel, Field, EmailStr, field_validator, validator, ConfigDict +from pydantic import BaseModel, Field, EmailStr, field_validator, ConfigDict from typing import List, Optional -from datetime import datetime from bson import ObjectId - - -def validate_object_id(value: str) -> ObjectId: - if not ObjectId.is_valid(value): - raise ValueError("Invalid ObjectId") - return ObjectId(value) +from datetime import datetime class MongoBaseModel(BaseModel): @@ -20,11 +14,12 @@ class MongoBaseModel(BaseModel): json_encoders={ObjectId: str} ) - # @field_validator("id", pre=True) - # def validate_id(cls, v): - # if isinstance(v, ObjectId): - # return str(v) - # return v + @field_validator('id', mode='before') + @classmethod + def convert_object_id_to_string(cls, v): + if isinstance(v, ObjectId): + return str(v) + return v class Item(MongoBaseModel): @@ -39,10 +34,12 @@ class OrderItem(BaseModel): item_id: str quantity: int price_at_order: float - - # @field_validator("item_id") - # def validate_item_id(cls, v): - # return validate_object_id(v) + + @field_validator("item_id") + def validate_id(cls, v): + if isinstance(v, ObjectId): + return str(v) + return v class Order(MongoBaseModel): @@ -57,7 +54,7 @@ class Order(MongoBaseModel): discount_applied: Optional[float] = None notes: Optional[str] = None - # @validator("user_id") + # @field_validator("user_id") # def validate_user_id(cls, v): # return validate_object_id(v) diff --git a/py-kivy/pos_system/routers/items.py b/py-kivy/pos_system/routers/items.py index ad17a03..62d3430 100644 --- a/py-kivy/pos_system/routers/items.py +++ b/py-kivy/pos_system/routers/items.py @@ -16,14 +16,14 @@ async def create_item(item: Item): item_dict = item.model_dump(exclude={"id"}) result = db.items.insert_one(item_dict) created_item = db.items.find_one({"_id": result.inserted_id}) - return Item(**created_item) + return Item.model_validate(created_item) @router.get("/", response_model=List[Item]) async def read_items(): db = get_db() items = list(db.items.find()) - return [Item(**item) for item in items] + return [Item.model_validate(item) for item in items] @router.get("/{item_id}", response_model=Item) @@ -32,7 +32,7 @@ async def read_item(item_id: str): item = db.items.find_one({"_id": ObjectId(item_id)}) if item is None: raise HTTPException(status_code=404, detail="Item not found") - return Item(**item) + return Item.model_validate(item) @router.put("/{item_id}", response_model=Item) @@ -44,7 +44,7 @@ async def update_item(item_id: str, item: Item): if result.modified_count == 0: raise HTTPException(status_code=404, detail="Item not found") updated_item = db.items.find_one({"_id": ObjectId(item_id)}) - return Item(**updated_item) + return Item.model_validate(updated_item) @router.delete("/{item_id}", response_model=dict) diff --git a/py-kivy/pyproject.toml b/py-kivy/pyproject.toml index 40897d1..2039b63 100644 --- a/py-kivy/pyproject.toml +++ b/py-kivy/pyproject.toml @@ -17,6 +17,7 @@ python-jose = "^3.3.0" passlib = "^1.7.4" pydantic = "^2.8.2" +pytest-xdist = "^3.6.1" [tool.poetry.group.dev.dependencies] ipykernel = "^6.29.5" @@ -30,3 +31,4 @@ indent-size = 2 [tool.poetry.scripts] server = "pos_system.server:main" ui = "pos_system.ui:main" +test-items = "tests.test_items_fastapi:test" diff --git a/py-kivy/pytest.ini b/py-kivy/pytest.ini new file mode 100644 index 0000000..d99a3aa --- /dev/null +++ b/py-kivy/pytest.ini @@ -0,0 +1,11 @@ +# File: pytest.ini (place this in your project root directory) + +[pytest] +log_cli = true +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s) +log_cli_date_format=%Y-%m-%d %H:%M:%S +log_file = tests/output/pytest.log +log_file_level = DEBUG +log_file_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s) +log_file_date_format=%Y-%m-%d %H:%M:%S diff --git a/py-kivy/tests/.DS_Store b/py-kivy/tests/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7059d2165893775ec2fe0990bc991f0a40bf0b29 GIT binary patch literal 6148 zcmeHK%}T>S5Z-NTn^J@x6!f;>wP0F{SiFQ-U%-eSRBA$s24l7~sX3HF?)pN$h|lB9 z?gk7NyouNu*!^bbXE*af_J=XX-9^}Etjid)pdoTp)(D!{x@smEk*hftEDQ2<7Q`}G zGtpl(;kP%LWbox|hW(fCe-CDHlxDX5!E5!#R|p0b<}M25^56&=4Jig+{e?K!?|7^fwSuK*zTPqA=(fEHr`# zgzHp5oyyGf7MVYhpTX}fa3TXGxP%y4Q1qAfUB>)DvkL)R@_6yV@ Z&M{bM#97d;(gEorpa`Lk82AMSJ^_>`Ov?ZO literal 0 HcmV?d00001 diff --git a/py-kivy/tests/.gitignore b/py-kivy/tests/.gitignore new file mode 100644 index 0000000..ea1472e --- /dev/null +++ b/py-kivy/tests/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/py-kivy/tests/__init__.py b/py-kivy/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/py-kivy/tests/test_fastapi.py b/py-kivy/tests/test_items_fastapi.py similarity index 64% rename from py-kivy/tests/test_fastapi.py rename to py-kivy/tests/test_items_fastapi.py index b374a42..677850a 100644 --- a/py-kivy/tests/test_fastapi.py +++ b/py-kivy/tests/test_items_fastapi.py @@ -1,11 +1,13 @@ import pytest +import logging from fastapi.testclient import TestClient -from hypothesis import given, strategies as st +from hypothesis import given, strategies as st, settings from bson import ObjectId from pos_system.server import app from pos_system.database import get_db client = TestClient(app) +logger = logging.getLogger(__name__) @pytest.fixture(autouse=True) @@ -17,15 +19,18 @@ def clear_db(): def test_create_item(): + logger.info("Testing create item") response = client.post( "/items/", json={"name": "Test Item", "price": 10.99, "quantity": 5, "unit": "piece"} ) assert response.status_code == 200 - assert "id" in response.json() + assert "_id" in response.json() + logger.info(f"Created item with ID: {response.json()['_id']}") def test_read_items(): + logger.info("Testing read items") client.post("/items/", json={"name": "Test Item", "price": 10.99, "quantity": 5, "unit": "piece"}) response = client.get("/items/") @@ -33,54 +38,62 @@ def test_read_items(): items = response.json() assert len(items) == 1 assert items[0]["name"] == "Test Item" + logger.info(f"Retrieved {len(items)} items") def test_read_item(): + logger.info("Testing read single item") create_response = client.post( "/items/", json={"name": "Test Item", "price": 10.99, "quantity": 5, "unit": "piece"}) - item_id = create_response.json()["id"] + item_id = create_response.json()["_id"] response = client.get(f"/items/{item_id}") assert response.status_code == 200 assert response.json()["name"] == "Test Item" + logger.info(f"Retrieved item with ID: {item_id}") def test_update_item(): + logger.info("Testing update item") create_response = client.post( "/items/", json={"name": "Test Item", "price": 10.99, "quantity": 5, "unit": "piece"}) - item_id = create_response.json()["id"] + item_id = create_response.json()["_id"] update_data = {"name": "Updated Item", "price": 15.99, "quantity": 10, "unit": "piece"} response = client.put(f"/items/{item_id}", json=update_data) assert response.status_code == 200 assert response.json()["name"] == "Updated Item" + logger.info(f"Updated item with ID: {item_id}") def test_delete_item(): + logger.info("Testing delete item") create_response = client.post( "/items/", json={"name": "Test Item", "price": 10.99, "quantity": 5, "unit": "piece"}) - item_id = create_response.json()["id"] + item_id = create_response.json()["_id"] response = client.delete(f"/items/{item_id}") assert response.status_code == 200 assert response.json()["message"] == "Item deleted successfully" get_response = client.get(f"/items/{item_id}") assert get_response.status_code == 404 + logger.info(f"Deleted item with ID: {item_id}") @given( - name=st.text(min_size=1, max_size=50), - price=st.floats(min_value=0.01, max_value=1000000, + name=st.text(min_size=1, max_size=20), + price=st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False), - quantity=st.integers(min_value=0, max_value=1000000), + quantity=st.integers(min_value=0, max_value=100), unit=st.text(min_size=1, max_size=10) ) +@settings(max_examples=50) # Limit the number of examples to 50 def test_create_item_property(name, price, quantity, unit): response = client.post( "/items/", json={"name": name, "price": price, "quantity": quantity, "unit": unit} ) assert response.status_code == 200 - assert "id" in response.json() - item_id = response.json()["id"] + assert "_id" in response.json() + item_id = response.json()["_id"] get_response = client.get(f"/items/{item_id}") assert get_response.status_code == 200 item = get_response.json() @@ -93,15 +106,16 @@ def test_create_item_property(name, price, quantity, unit): @given( st.lists( st.fixed_dictionaries({ - "name": st.text(min_size=1, max_size=50), - "price": st.floats(min_value=0.01, max_value=1000000, allow_nan=False, allow_infinity=False), - "quantity": st.integers(min_value=0, max_value=1000000), + "name": st.text(min_size=1, max_size=20), + "price": st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False), + "quantity": st.integers(min_value=0, max_value=100), "unit": st.text(min_size=1, max_size=10) }), min_size=1, max_size=10 ) ) +@settings(max_examples=20) # Limit the number of examples to 20 def test_read_items_property(items): for item in items: client.post("/items/", json=item) @@ -115,6 +129,22 @@ def test_read_items_property(items): assert "quantity" in retrieved_item assert "unit" in retrieved_item +# Add this function to log only failed property tests + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + report = outcome.get_result() + if report.when == "call" and report.failed: + if "hypothesis" in item.keywords: + logger.error(f"Property test failed: {item.name}") + logger.error(f"Falsifying example: {call.excinfo.value}") + if __name__ == "__main__": pytest.main([__file__]) + + +def test(): + pytest.main([__file__])