├── tests ├── __init__.py ├── ssiql_test.py └── database_test.py ├── ssiql ├── resources │ ├── __init__.py │ ├── ssiql.icns │ ├── ssiql.ico │ └── ssiql.png ├── __init__.py ├── __main__.py ├── views │ ├── __init__.py │ ├── views.py │ ├── products.py │ ├── product_create.py │ ├── product_update.py │ ├── reports.py │ └── sales.py ├── components │ ├── __init__.py │ ├── style.py │ ├── table.py │ └── common.py ├── database │ ├── __init__.py │ ├── database_models.py │ ├── database_files.py │ └── database_api.py └── app.py ├── requirements.txt ├── .vscode └── settings.json ├── requirements-dev.txt ├── .github └── workflows │ ├── build.yml │ └── bandit.yml ├── ssiql.code-workspace ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml └── CODE_OF_CONDUCT.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ssiql/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | toga==0.3.1 2 | tinydb==4.7.1 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic" 3 | } -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | briefcase==0.3.14 2 | toga==0.3.1 3 | tinydb==4.7.1 4 | pytest==7.3.1 -------------------------------------------------------------------------------- /ssiql/resources/ssiql.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Diegiwg/ssiql/HEAD/ssiql/resources/ssiql.icns -------------------------------------------------------------------------------- /ssiql/resources/ssiql.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Diegiwg/ssiql/HEAD/ssiql/resources/ssiql.ico -------------------------------------------------------------------------------- /ssiql/resources/ssiql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Diegiwg/ssiql/HEAD/ssiql/resources/ssiql.png -------------------------------------------------------------------------------- /ssiql/__init__.py: -------------------------------------------------------------------------------- 1 | from .views import product_create, product_update, products, reports, sales # noqa 2 | -------------------------------------------------------------------------------- /ssiql/__main__.py: -------------------------------------------------------------------------------- 1 | from ssiql.app import main 2 | 3 | if __name__ == '__main__': 4 | main().main_loop() 5 | -------------------------------------------------------------------------------- /ssiql/views/__init__.py: -------------------------------------------------------------------------------- 1 | from ..database import db_manager 2 | from .views import ViewController, ViewModel 3 | 4 | view_controller = ViewController(db_manager()) 5 | -------------------------------------------------------------------------------- /ssiql/components/__init__.py: -------------------------------------------------------------------------------- 1 | from .common import ( 2 | Box, 3 | Button, 4 | Column, 5 | Divider, 6 | Label, 7 | MultilineTextInput, 8 | NumberInput, 9 | Row, 10 | Selection, 11 | TextInput, 12 | ) 13 | from .style import Style, StyleDocument 14 | from .table import Table 15 | -------------------------------------------------------------------------------- /ssiql/database/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from .database_api import DatabaseManager 4 | from .database_models import PaymentMethod, Product, Sale # noqa: F401 5 | 6 | 7 | def db_manager(storage: Literal["Memory", None] = None) -> DatabaseManager: 8 | """ 9 | Initializes a new instance of the database manager. 10 | 11 | Parameters: 12 | storage (Literal['Memory', None], optional): The type of storage to use for the database. 13 | Defaults to None. 14 | 15 | Returns: 16 | API: An instance of the database manager. 17 | """ 18 | return DatabaseManager(storage) 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | jobs: 4 | Windows-Build: 5 | runs-on: windows-latest 6 | steps: 7 | - name: Sync Repository 8 | uses: actions/checkout@v3 9 | 10 | - name: Install Python 11 | uses: actions/setup-python@v4 12 | with: 13 | python-version: "3.11" 14 | cache: "pip" 15 | 16 | - name: Install Libraries 17 | run: | 18 | pip install -r .\requirements-dev.txt 19 | briefcase build -r 20 | briefcase package 21 | 22 | - name: Save Compiled Files 23 | uses: actions/upload-artifact@v3 24 | with: 25 | name: build_files_windows 26 | path: dist 27 | -------------------------------------------------------------------------------- /ssiql.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "editor.formatOnSave": true, 9 | "editor.codeActionsOnSave": { 10 | "source.fixAll": true, 11 | "source.organizeImports": true 12 | }, 13 | "editor.inlayHints.enabled": "on", 14 | "python.analysis.inlayHints.functionReturnTypes": true, 15 | "python.analysis.inlayHints.variableTypes": true, 16 | "python.linting.enabled": true, 17 | "python.analysis.diagnosticMode": "workspace", 18 | "python.analysis.indexing": true, 19 | "python.analysis.typeCheckingMode": "strict", 20 | "python.formatting.provider": "autopep8", 21 | "cSpell.words": ["metodo"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ssiql/database/database_models.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import TypedDict 3 | 4 | 5 | class PaymentMethod(Enum): 6 | """ 7 | Enum representing the available payment methods. 8 | """ 9 | 10 | CASH = "CASH" 11 | CARD = "CARD" 12 | PIX = "PIX" 13 | ON_CREDIT = "ON_CREDIT" 14 | 15 | 16 | class Product(TypedDict): 17 | """ 18 | Represents a product in the database. 19 | """ 20 | 21 | id: int 22 | name: str 23 | brand: str 24 | reference: str 25 | price: float 26 | quantity: int 27 | 28 | 29 | class Sale(TypedDict): 30 | """ 31 | Represents a sale in the database. 32 | """ 33 | 34 | id: int 35 | occurred_at: str 36 | total_price: float 37 | payment_method: PaymentMethod 38 | products: list[Product] 39 | customer_name: str 40 | -------------------------------------------------------------------------------- /ssiql/app.py: -------------------------------------------------------------------------------- 1 | import toga 2 | 3 | from .views import view_controller 4 | 5 | 6 | class App(toga.App): 7 | """ 8 | Main application class. 9 | """ 10 | def startup(self): 11 | """ 12 | Initializes the application and sets up the main view model, 13 | the main window, and the full screen mode. 14 | """ 15 | # Main View Model 16 | view_controller.redirect_to("sales") 17 | view_controller.update_model() 18 | 19 | # Main Window 20 | self.main_window = toga.MainWindow(title=self.formal_name) 21 | self.main_window.content = view_controller.main() 22 | self.main_window.show() 23 | 24 | # Set Full Screen Mode 25 | self.set_full_screen(self.main_window) 26 | 27 | 28 | def main(): 29 | """ 30 | Initializes and returns an instance of the `App` class. 31 | """ 32 | return App() 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # OSX useful to ignore 7 | *.DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | # C extensions 31 | *.so 32 | 33 | # Distribution / packaging 34 | .Python 35 | env/ 36 | build/ 37 | develop-eggs/ 38 | dist/ 39 | downloads/ 40 | eggs/ 41 | .eggs/ 42 | lib/ 43 | lib64/ 44 | parts/ 45 | sdist/ 46 | var/ 47 | *.dist-info/ 48 | *.egg-info/ 49 | .installed.cfg 50 | *.egg 51 | 52 | # IntelliJ Idea family of suites 53 | .idea 54 | *.iml 55 | ## File-based project format: 56 | *.ipr 57 | *.iws 58 | ## mpeltonen/sbt-idea plugin 59 | .idea_modules/ 60 | 61 | # Briefcase log files 62 | logs/ 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Diego Queiroz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/ssiql_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import tempfile 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | 9 | def run_tests(): 10 | """ 11 | Run the test suite. 12 | 13 | This function changes the current working directory to the project path, 14 | determines any arguments to pass to pytest, and runs pytest with the 15 | specified arguments. If no arguments are provided, the function defaults to 16 | running the entire test suite. 17 | 18 | Returns: 19 | int: The return code of the pytest execution. 20 | """ 21 | project_path = Path(__file__).parent.parent 22 | os.chdir(project_path) 23 | 24 | # Determine any args to pass to pytest. If there aren't any, 25 | # default to running the whole test suite. 26 | args = sys.argv[1:] 27 | if len(args) == 0: 28 | args = ["tests"] 29 | 30 | returncode = pytest.main( 31 | [ 32 | # Turn up verbosity 33 | "-vv", 34 | # Disable color 35 | "--color=no", 36 | # Overwrite the cache directory to somewhere writable 37 | "-o", 38 | f"cache_dir={tempfile.gettempdir()}/.pytest_cache", 39 | ] 40 | + args 41 | ) 42 | 43 | print(f">>>>>>>>>> EXIT {returncode} <<<<<<<<<<") 44 | 45 | 46 | if __name__ == "__main__": 47 | run_tests() 48 | -------------------------------------------------------------------------------- /ssiql/database/database_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Literal 3 | 4 | from tinydb import TinyDB 5 | from tinydb.storages import MemoryStorage 6 | 7 | # Create a directory if it doesn't exist 8 | if not os.path.exists(os.path.expanduser("~/data")): 9 | os.mkdir(os.path.expanduser("~/data")) 10 | 11 | 12 | def products_db(storage: Literal["Memory", None] = None) -> TinyDB: 13 | """ 14 | Initializes a TinyDB instance for the products database. 15 | 16 | Args: 17 | storage (Literal["Memory", None], optional): The storage type for the database. 18 | Defaults to None. 19 | 20 | Returns: 21 | TinyDB: The initialized TinyDB instance. 22 | 23 | Raises: 24 | None 25 | """ 26 | if storage is None: 27 | return TinyDB(os.path.expanduser("~/data/products.json")) 28 | return TinyDB(storage=MemoryStorage) 29 | 30 | 31 | def sales_db(storage: Literal["Memory", None] = None) -> TinyDB: 32 | """ 33 | Initializes and returns a TinyDB instance for the sales database. 34 | 35 | Args: 36 | storage (Literal["Memory", None], optional): The storage option for the database. 37 | Defaults to None, which indicates a file-based storage. 38 | 39 | Returns: 40 | TinyDB: A TinyDB instance for the sales database. 41 | """ 42 | if storage is None: 43 | return TinyDB(os.path.expanduser("~/data/sales.json")) 44 | return TinyDB(storage=MemoryStorage) 45 | -------------------------------------------------------------------------------- /ssiql/components/style.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, TypedDict, Union 2 | 3 | from toga import Widget 4 | 5 | 6 | class StyleDocument(TypedDict): 7 | """ 8 | Represents the style document of a widget. 9 | """ 10 | 11 | width: int 12 | height: int 13 | 14 | direction: Literal["row", "column", None] 15 | alignment: Literal["top", "bottom", "left", "right", "center"] 16 | 17 | flex: int 18 | padding: int 19 | padding_top: int 20 | padding_bottom: int 21 | padding_left: int 22 | padding_right: int 23 | 24 | text_align: Literal["left", "center", "right", "justify"] 25 | text_direction: Literal["ltr", "rtl"] 26 | 27 | font_size: int 28 | font_weight: Literal["normal", "bold"] 29 | font_variant: Literal["normal", "small_caps"] 30 | 31 | 32 | class Style: 33 | """ 34 | Represents the style of a widget. 35 | """ 36 | 37 | def __init__( 38 | self, 39 | widget: Widget, 40 | document: Union[StyleDocument, None] = None, 41 | ): 42 | """ 43 | Initializes the class instance with the given widget and document. 44 | 45 | Parameters: 46 | widget (Widget): The widget to be initialized. 47 | 48 | document (Union[StyleDocument, None], optional): The document containing 49 | the style information. Defaults to None. 50 | 51 | Returns: 52 | None 53 | """ 54 | # DEFAULTS 55 | widget.style["font_size"] = "10" 56 | 57 | if document is None: 58 | return 59 | 60 | if "padding" in document: 61 | widget.style["padding_top"] = document["padding"] 62 | widget.style["padding_bottom"] = document["padding"] 63 | widget.style["padding_left"] = document["padding"] 64 | widget.style["padding_right"] = document["padding"] 65 | del document["padding"] 66 | 67 | for attr, value in document.items(): 68 | widget.style[attr] = value 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sales and Inventory System 2 | 3 | ## Project Demo 4 | 5 | 6 | 7 | ## Project Description 8 | 9 | This project is a system with a simplified interface that covers product and inventory control, including operations for product registration, listing, modification, and deletion. The project also includes a sales system where you can name the customer, select the payment method, and complete the purchase. Finally, there is an option to view sales that occurred on a specific date. 10 | 11 | ## How to Use 12 | 13 | The simplest way to use the system is to download the latest release from the [releases section](https://github.com/Diegiwg/ssiql/releases). 14 | 15 | If you want to run the development version, follow these steps: 16 | 17 | Clone the repository: 18 | 19 | ```bash 20 | git clone https://github.com/Diegiwg/ssiql.git 21 | ``` 22 | 23 | Navigate to the directory: 24 | 25 | ```bash 26 | cd ssiql 27 | ``` 28 | 29 | Run the command: 30 | 31 | ```bash 32 | pip install -r requirements-dev.txt 33 | ``` 34 | 35 | Run the command: 36 | 37 | ```bash 38 | briefcase dev 39 | ``` 40 | 41 | ### Database 42 | 43 | The database is automatically generated and uses two [JSON](https://www.json.org/) files for storage, which can be found in `~/data/`. 44 | 45 | Currently, there are releases available for Windows, but in the development version, it is possible to run on Linux. 46 | 47 | ### Available Features 48 | 49 | - Product registration 50 | - Product listing 51 | - Product modification 52 | - Product deletion 53 | - Stock quantity modification 54 | - Sales registration 55 | - Sales listing 56 | 57 | ## License 58 | 59 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 60 | 61 | ## Developer 62 | 63 | - Diego Queiroz - [@Diegiwg](https://www.linkedin.com/in/diego-silva-queiroz/) 64 | 65 | ## Contributions 66 | 67 | Contributions are welcome! To contribute, fork the repository, create a new branch, and send a pull request with your changes. 68 | -------------------------------------------------------------------------------- /ssiql/views/views.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from ..components import Box, Button, Column, Divider, Row 4 | from ..database import DatabaseManager 5 | 6 | 7 | class ViewModel: 8 | id: str 9 | update: Callable 10 | 11 | 12 | class ViewController: 13 | def __init__(self, database: DatabaseManager) -> None: 14 | self.database = database 15 | 16 | self.models: dict[str, ViewModel] = {} 17 | self.navigation: list[ViewModel] = [] 18 | 19 | self.main_part1 = Column( 20 | [ 21 | Button("Sales", lambda _: self.redirect_to("sales")), 22 | Button("Products", lambda _: self.redirect_to("products")), 23 | Button("Reports", lambda _: self.redirect_to("reports")), 24 | ], 25 | style={"flex": 1}, 26 | ) 27 | 28 | self.main_part2 = Box() 29 | 30 | self.main_part0 = Row( 31 | [ 32 | self.main_part1, 33 | Divider("v", {"padding_left": 10, "padding_right": 10}), 34 | self.main_part2, 35 | ], 36 | {"flex": 1, "padding": 10}, 37 | ) 38 | 39 | def main(self): 40 | return self.main_part0 41 | 42 | def update_main(self, widgets: list): 43 | self.main_part0.remove(self.main_part2) 44 | self.main_part2 = Column(widgets, {"flex": 6}) 45 | self.main_part0.add(self.main_part2) 46 | 47 | def register_model(self, model: ViewModel): 48 | self.models[model.id] = model 49 | 50 | def update_model(self, custom_data=None): 51 | if not self.navigation: 52 | return 53 | 54 | current_model = self.navigation[-1] 55 | 56 | if custom_data: 57 | current_model.update(custom_data) 58 | else: 59 | current_model.update() 60 | 61 | def redirect_to(self, model_id: str, custom_data=None): 62 | if model_id not in self.models: 63 | return 64 | 65 | if self.navigation and model_id == self.navigation[-1].id: 66 | return 67 | 68 | if len(self.navigation) > 50: 69 | self.navigation = self.navigation[25:] 70 | 71 | self.navigation.append(self.models[model_id]) 72 | self.update_model(custom_data) 73 | 74 | def redirect_to_previous(self): 75 | if not self.navigation: 76 | return 77 | 78 | self.navigation.pop() 79 | self.update_model() 80 | -------------------------------------------------------------------------------- /.github/workflows/bandit.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # Bandit is a security linter designed to find common security issues in Python code. 7 | # This action will run Bandit on your codebase. 8 | # The results of the scan will be found under the Security tab of your repository. 9 | 10 | # https://github.com/marketplace/actions/bandit-scan is ISC licensed, by abirismyname 11 | # https://pypi.org/project/bandit/ is Apache v2.0 licensed, by PyCQA 12 | 13 | name: Bandit 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '25 4 * * 2' 22 | 23 | jobs: 24 | bandit: 25 | permissions: 26 | contents: read # for actions/checkout to fetch code 27 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 28 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 29 | 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: Bandit Scan 34 | uses: shundor/python-bandit-scan@9cc5aa4a006482b8a7f91134412df6772dbda22c 35 | with: # optional arguments 36 | # exit with 0, even with results found 37 | exit_zero: true # optional, default is DEFAULT 38 | # Github token of the repository (automatically created by Github) 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information. 40 | # File or directory to run bandit on 41 | # path: # optional, default is . 42 | # Report only issues of a given severity level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything) 43 | # level: # optional, default is UNDEFINED 44 | # Report only issues of a given confidence level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything) 45 | # confidence: # optional, default is UNDEFINED 46 | # comma-separated list of paths (glob patterns supported) to exclude from scan (note that these are in addition to the excluded paths provided in the config file) (default: .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg) 47 | # excluded_paths: # optional, default is DEFAULT 48 | # comma-separated list of test IDs to skip 49 | # skips: # optional, default is DEFAULT 50 | # path to a .bandit file that supplies command line arguments 51 | # ini_path: # optional, default is DEFAULT 52 | 53 | -------------------------------------------------------------------------------- /ssiql/views/products.py: -------------------------------------------------------------------------------- 1 | from ..components import Button, Column, Divider, Label, Row, Table 2 | from ..views import ViewModel, view_controller 3 | 4 | 5 | class ProductViewModel(ViewModel): 6 | """ 7 | Represents the model for the products view. 8 | """ 9 | 10 | def __init__(self) -> None: 11 | self.id = "products" 12 | 13 | self.product_table: Table 14 | 15 | def update(self): 16 | """ 17 | Update the View 18 | """ 19 | 20 | table_headings = ["Name", "Brand", "Reference", "Price", "Quantity"] 21 | 22 | self.product_table = Table( 23 | headings=table_headings, 24 | data_source=view_controller.database.product.list, 25 | filter_source=view_controller.database.product.search, 26 | style={"flex": 1}, 27 | ) 28 | 29 | create_product_btn = Button( 30 | "Create Product", 31 | lambda _: view_controller.redirect_to("product_create"), 32 | {"flex": 1, "padding_right": 10}, 33 | ) 34 | 35 | update_product_btn = Button( 36 | "Update Product", 37 | lambda _: self.product_update_handle(), 38 | {"flex": 1, "padding_right": 10}, 39 | ) 40 | 41 | delete_product_btn = Button( 42 | "Delete Product", 43 | lambda _: self.product_delete_handle(), 44 | {"flex": 1}, 45 | ) 46 | 47 | view_controller.update_main( 48 | [ 49 | Column( 50 | [ 51 | Label("Products"), 52 | Divider(), 53 | self.product_table, 54 | Divider(), 55 | Row( 56 | [create_product_btn, update_product_btn, delete_product_btn] 57 | ), 58 | ], 59 | {"flex": 1}, 60 | ) 61 | ] 62 | ) 63 | 64 | def product_delete_handle(self): 65 | """ 66 | Delete a product from the database. 67 | 68 | This function deletes a selected product from the database. It first checks if a product is selected and if it has an "id" field. If not, it returns without performing any action. If a valid product is selected, it calls the delete method from the product table in the database, passing the "id" of the selected product. After deleting the product, it updates the product table. 69 | """ 70 | selected_product = self.product_table.selected() 71 | if not selected_product or "id" not in selected_product: 72 | return 73 | 74 | view_controller.database.product.delete(selected_product["id"]) 75 | self.product_table.update() 76 | 77 | def product_update_handle(self): 78 | """ 79 | Updates the product in the database based on the selected product. 80 | """ 81 | selected_product = self.product_table.selected() 82 | if not selected_product: 83 | return 84 | 85 | view_controller.redirect_to("product_update", selected_product) 86 | 87 | 88 | view_controller.register_model(ProductViewModel()) 89 | -------------------------------------------------------------------------------- /ssiql/views/product_create.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | from ..components import Button, Column, Divider, Label, Row, TextInput 4 | from ..database import Product 5 | from ..views import ViewModel, view_controller 6 | 7 | 8 | class ProductForm(TypedDict): 9 | """ 10 | Represents the form for creating a new product. 11 | """ 12 | 13 | name: TextInput 14 | brand: TextInput 15 | reference: TextInput 16 | price: TextInput 17 | quantity: TextInput 18 | 19 | 20 | def create_handle(form: ProductForm): 21 | """ 22 | Create a new product in the database. 23 | """ 24 | if ( 25 | form["name"].value == "" 26 | or form["brand"].value == "" 27 | or form["reference"].value == "" 28 | or form["price"].value == "" 29 | or form["quantity"].value == "" 30 | ): 31 | return 32 | 33 | # Try to convert the price 34 | price: float 35 | try: 36 | price = float(form["price"].value.replace(",", ".")) 37 | except ValueError: 38 | return 39 | 40 | # Try to convert the quantity 41 | quantity: int 42 | try: 43 | quantity = int(form["quantity"].value) 44 | except ValueError: 45 | return 46 | 47 | # Create and add the new product to the database 48 | new_product = Product( 49 | name=form["name"].value, 50 | brand=form["brand"].value, 51 | reference=form["reference"].value, 52 | price=price, 53 | quantity=quantity, 54 | ) 55 | 56 | view_controller.database.product.create(new_product) 57 | view_controller.redirect_to_previous() 58 | 59 | 60 | class Model(ViewModel): 61 | """ 62 | Represents the model for the product create view. 63 | """ 64 | 65 | def __init__(self) -> None: 66 | self.id = "product_create" 67 | 68 | def update(self): 69 | """ 70 | Update the View 71 | """ 72 | 73 | form = ProductForm( 74 | name=TextInput(style={"flex": 2}), 75 | brand=TextInput(style={"flex": 2}), 76 | reference=TextInput(style={"flex": 2}), 77 | price=TextInput(style={"flex": 2}), 78 | quantity=TextInput(style={"flex": 2}), 79 | ) 80 | 81 | label_name = Label("Name", {"flex": 1}) 82 | label_brand = Label("Brand", {"flex": 1}) 83 | label_reference = Label("Reference", {"flex": 1}) 84 | label_price = Label("Price", {"flex": 1}) 85 | label_quantity = Label("Quantity", {"flex": 1}) 86 | 87 | create_product_btn = Button( 88 | "Create Product", 89 | lambda _: create_handle(form), 90 | {"flex": 1}, 91 | ) 92 | 93 | cancel_action_btn = Button( 94 | "Cancel", 95 | lambda _: view_controller.redirect_to_previous(), 96 | {"flex": 1}, 97 | ) 98 | 99 | view_controller.update_main( 100 | [ 101 | Column( 102 | [ 103 | Label("Create Product"), 104 | Divider(), 105 | Row([label_name, form["name"]], {"flex": 1}), 106 | Row([label_brand, form["brand"]], {"flex": 1}), 107 | Row([label_reference, form["reference"]], {"flex": 1}), 108 | Row([label_price, form["price"]], {"flex": 1}), 109 | Row([label_quantity, form["quantity"]], {"flex": 1}), 110 | Divider(), 111 | Row([create_product_btn, cancel_action_btn], {"flex": 1}), 112 | ], 113 | {"flex": 1}, 114 | ) 115 | ] 116 | ) 117 | 118 | 119 | view_controller.register_model(Model()) 120 | -------------------------------------------------------------------------------- /ssiql/components/table.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | import toga 4 | 5 | from .common import Box, Column, Label, Row, TextInput 6 | from .style import Style, StyleDocument 7 | 8 | 9 | class Table(Box): 10 | """ 11 | Represents a table widget. 12 | """ 13 | 14 | def __init__( 15 | self, 16 | headings: list, 17 | data_source: Callable, 18 | filter_source: Callable, 19 | title="", 20 | style=StyleDocument(), 21 | ): 22 | """ 23 | Initializes an instance of the class. 24 | 25 | Parameters: 26 | headings (list): A list of column headings for the table. 27 | 28 | data_source (Callable): A callable that returns the data for the table. 29 | 30 | filter_source (Callable): A callable that filters the data for the table. 31 | 32 | title (str, optional): The title of the table. Defaults to an empty string. 33 | 34 | style (StyleDocument, optional): The style document for the table. 35 | Defaults to StyleDocument(). 36 | 37 | Returns: 38 | None 39 | """ 40 | super().__init__(style=style) 41 | 42 | self.__selected = None 43 | self.__data_source = data_source 44 | self.__filter_source = filter_source 45 | 46 | self.__table = toga.Table( 47 | headings=headings, 48 | data=data_source(), 49 | missing_value="", 50 | on_select=self.__on_select_handler, 51 | ) 52 | Style(self.__table, {"flex": 1}) 53 | 54 | self.__search_input = TextInput( 55 | value="", on_change=self.__on_search_handler, style={"flex": 1} 56 | ) 57 | 58 | search_input_row = Row( 59 | style={"flex": 1}, children=[Label("Search"), self.__search_input] 60 | ) 61 | 62 | table_and_search_row = Row( 63 | children=[Label(style={"flex": 3}, text=title), search_input_row] 64 | ) 65 | 66 | self.add( 67 | Column(style={"flex": 1}, children=[table_and_search_row, self.__table]) 68 | ) 69 | 70 | def __on_select_handler(self, _, row): 71 | """ 72 | Set the value of the __selected attribute to the given row. 73 | """ 74 | self.__selected = row 75 | 76 | def __on_search_handler(self, _): 77 | """ 78 | Handles the search event. 79 | """ 80 | if self.__search_input.value == "": 81 | self.__table.data = self.__data_source() 82 | return 83 | 84 | m_search: str = self.__search_input.value.lower() 85 | self.__table.data = self.__filter_source(m_search) 86 | 87 | def update(self): 88 | """ 89 | Update the state of the object. 90 | 91 | This function resets the selected item and clears the data in the table. 92 | It also triggers the search handler to maintain the search term after the update. 93 | """ 94 | self.__selected = None 95 | self.__table.data = [] 96 | 97 | self.__on_search_handler(None) 98 | 99 | def selected(self): 100 | """ 101 | Returns the selected item as a dictionary, excluding the "_attrs" and "_source" attributes. 102 | """ 103 | if self.__selected is None: 104 | return None 105 | 106 | m_item: dict = self.__selected.__dict__ 107 | 108 | try: 109 | del m_item["_attrs"] 110 | del m_item["_source"] 111 | except KeyError: 112 | pass 113 | 114 | return m_item 115 | 116 | def table(self): 117 | """ 118 | Returns the value of the private attribute __table. 119 | """ 120 | return self.__table 121 | 122 | def set_selected(self, row): 123 | """ 124 | Set the selected row. 125 | """ 126 | self.__selected = row 127 | -------------------------------------------------------------------------------- /ssiql/views/product_update.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | from ..components import Button, Column, Divider, Label, Row, TextInput 4 | from ..database import Product 5 | from ..views import ViewModel, view_controller 6 | 7 | 8 | class ProductForm(TypedDict): 9 | """ 10 | Represents the form for updating a product. 11 | """ 12 | 13 | name: TextInput 14 | brand: TextInput 15 | reference: TextInput 16 | price: TextInput 17 | quantity: TextInput 18 | 19 | 20 | def update_handle(product_id: int, form: ProductForm): 21 | """ 22 | Update a product in the database. 23 | """ 24 | if ( 25 | form["name"].value == "" 26 | or form["brand"].value == "" 27 | or form["reference"].value == "" 28 | or form["price"].value == "" 29 | or form["quantity"].value == "" 30 | ): 31 | return 32 | 33 | # Try to convert price 34 | price: float 35 | try: 36 | price = float(form["price"].value.replace(",", ".")) 37 | except ValueError: 38 | return 39 | 40 | # Try to convert quantity 41 | quantity: int 42 | if not form["quantity"].value.isdigit(): 43 | return 44 | else: 45 | quantity = int(form["quantity"].value) 46 | 47 | view_controller.database.product.update( 48 | product_id, 49 | Product( 50 | name=form["name"].value, 51 | brand=form["brand"].value, 52 | reference=form["reference"].value, 53 | price=price, 54 | quantity=quantity, 55 | ), 56 | ) 57 | view_controller.redirect_to_previous() 58 | 59 | 60 | class Model(ViewModel): 61 | """ 62 | Represents the model for the product update view. 63 | """ 64 | 65 | def __init__(self) -> None: 66 | self.id = "product_update" 67 | 68 | def update(self, product: Product): 69 | """ 70 | Update the View 71 | """ 72 | if "id" not in product: 73 | product["id"] = 0 74 | view_controller.redirect_to_previous() 75 | 76 | form = ProductForm( 77 | name=TextInput(style={"flex": 2}), 78 | brand=TextInput(style={"flex": 2}), 79 | reference=TextInput(style={"flex": 2}), 80 | price=TextInput(style={"flex": 2}), 81 | quantity=TextInput(style={"flex": 2}), 82 | ) 83 | 84 | form["name"].value = product["name"] 85 | form["brand"].value = product["brand"] 86 | form["price"].value = str(product["price"]) 87 | form["quantity"].value = str(product["quantity"]) 88 | if "reference" in product: 89 | form["reference"].value = product["reference"] 90 | 91 | label_name = Label("Name", {"flex": 1}) 92 | label_brand = Label("Brand", {"flex": 1}) 93 | label_reference = Label("Reference", {"flex": 1}) 94 | label_price = Label("Price", {"flex": 1}) 95 | label_quantity = Label("Quantity", {"flex": 1}) 96 | 97 | update_product_btn = Button( 98 | "Update Product", 99 | lambda _: update_handle(product["id"], form), 100 | {"flex": 1}, 101 | ) 102 | 103 | cancel_action_btn = Button( 104 | "Cancel", 105 | lambda _: view_controller.redirect_to_previous(), 106 | {"flex": 1}, 107 | ) 108 | 109 | view_controller.update_main( 110 | [ 111 | Column( 112 | [ 113 | Label("Update Product"), 114 | Divider(), 115 | Row([label_name, form["name"]], {"flex": 1}), 116 | Row([label_brand, form["brand"]], {"flex": 1}), 117 | Row([label_reference, form["reference"]], {"flex": 1}), 118 | Row([label_price, form["price"]], {"flex": 1}), 119 | Row([label_quantity, form["quantity"]], {"flex": 1}), 120 | Divider(), 121 | Row([update_product_btn, cancel_action_btn], {"flex": 1}), 122 | ], 123 | {"flex": 1}, 124 | ) 125 | ] 126 | ) 127 | 128 | 129 | view_controller.register_model(Model()) 130 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pyright] 2 | exclude = ["build", "dist", "logs", ".venv"] 3 | 4 | [tool.ruff] 5 | ignore = ["E501", "F401"] 6 | 7 | [tool.briefcase] 8 | project_name = "ssiql" 9 | bundle = "com.diegiwg" 10 | version = "2.0.0" 11 | url = "https://github.com/Diegiwg/ssiql" 12 | license = "MIT license" 13 | author = "Diegiwg (Diego Queiroz)" 14 | author_email = "diegiwg@gmail.com" 15 | 16 | [tool.briefcase.app.ssiql] 17 | formal_name = "ssiql" 18 | description = "Sales and Inventory System" 19 | long_description = "This project is a system with a simplified interface that covers product and inventory control, including operations for product registration, listing, modification, and deletion. The project also includes a sales system where you can name the customer, select the payment method, and complete the purchase. Finally, there is an option to view sales that occurred on a specific date." 20 | icon = "ssiql/resources/ssiql" 21 | sources = ["ssiql"] 22 | test_sources = ["tests"] 23 | 24 | requires = ["tinydb==4.7.1"] 25 | 26 | test_requires = ["pytest"] 27 | 28 | [tool.briefcase.app.ssiql.macOS] 29 | requires = ["toga-cocoa~=0.3.1", "std-nslog~=1.0.0"] 30 | 31 | [tool.briefcase.app.ssiql.linux] 32 | requires = ["toga-gtk~=0.3.1"] 33 | 34 | [tool.briefcase.app.ssiql.linux.system.debian] 35 | system_requires = [ 36 | # Needed to compile pycairo wheel 37 | "libcairo2-dev", 38 | # Needed to compile PyGObject wheel 39 | "libgirepository1.0-dev", 40 | ] 41 | 42 | system_runtime_requires = [ 43 | # Needed to provide GTK 44 | "libgtk-3-0", 45 | # Needed to provide GI bindings to GTK 46 | "libgirepository-1.0-1", 47 | "gir1.2-gtk-3.0", 48 | # Needed to provide WebKit2 at runtime 49 | # "libwebkit2gtk-4.0-37", 50 | # "gir1.2-webkit2-4.0", 51 | ] 52 | 53 | [tool.briefcase.app.ssiql.linux.system.rhel] 54 | system_requires = [ 55 | # Needed to compile pycairo wheel 56 | "cairo-gobject-devel", 57 | # Needed to compile PyGObject wheel 58 | "gobject-introspection-devel", 59 | ] 60 | 61 | system_runtime_requires = [ 62 | # Needed to support Python bindings to GTK 63 | "gobject-introspection", 64 | # Needed to provide GTK 65 | "gtk3", 66 | # Needed to provide WebKit2 at runtime 67 | # "webkit2gtk3", 68 | ] 69 | 70 | [tool.briefcase.app.ssiql.linux.system.arch] 71 | system_requires = [ 72 | # Needed to compile pycairo wheel 73 | "cairo", 74 | # Needed to compile PyGObject wheel 75 | "gobject-introspection", 76 | # Runtime dependencies that need to exist so that the 77 | # Arch package passes final validation. 78 | # Needed to provide GTK 79 | "gtk3", 80 | # Dependencies that GTK looks for at runtime 81 | "libcanberra", 82 | # Needed to provide WebKit2 83 | # "webkit2gtk", 84 | ] 85 | 86 | system_runtime_requires = [ 87 | # Needed to provide GTK 88 | "gtk3", 89 | # Needed to provide PyGObject bindings 90 | "gobject-introspection-runtime", 91 | # Dependencies that GTK looks for at runtime 92 | "libcanberra", 93 | # Needed to provide WebKit2 at runtime 94 | # "webkit2gtk", 95 | ] 96 | 97 | [tool.briefcase.app.ssiql.linux.appimage] 98 | manylinux = "manylinux2014" 99 | 100 | system_requires = [ 101 | # Needed to compile pycairo wheel 102 | "cairo-gobject-devel", 103 | # Needed to compile PyGObject wheel 104 | "gobject-introspection-devel", 105 | # Needed to provide GTK 106 | "gtk3-devel", 107 | # Dependencies that GTK looks for at runtime, that need to be 108 | # in the build environment to be picked up by linuxdeploy 109 | "libcanberra-gtk3", 110 | "PackageKit-gtk3-module", 111 | "gvfs-client", 112 | # Needed to provide WebKit2 at runtime 113 | # "webkit2gtk3", 114 | ] 115 | linuxdeploy_plugins = ["DEPLOY_GTK_VERSION=3 gtk"] 116 | 117 | [tool.briefcase.app.ssiql.linux.flatpak] 118 | flatpak_runtime = "org.gnome.Platform" 119 | flatpak_runtime_version = "44" 120 | flatpak_sdk = "org.gnome.Sdk" 121 | 122 | [tool.briefcase.app.ssiql.windows] 123 | requires = ["toga-winforms~=0.3.1"] 124 | 125 | # Mobile deployments 126 | [tool.briefcase.app.ssiql.iOS] 127 | requires = ["toga-iOS~=0.3.1", "std-nslog~=1.0.0"] 128 | 129 | [tool.briefcase.app.ssiql.android] 130 | requires = ["toga-android~=0.3.1"] 131 | 132 | # Web deployments 133 | [tool.briefcase.app.ssiql.web] 134 | requires = ["toga-web~=0.3.1"] 135 | style_framework = "Shoelace v2.3" 136 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | diegiwg@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /ssiql/views/reports.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from ..components import Box, Column, Divider, Label, Row, Selection, Table 4 | from ..views import ViewModel, view_controller 5 | 6 | 7 | def search_handler(data, search): 8 | """ 9 | This function takes in a list of data and a search term, and 10 | filters the data based on the search term. 11 | 12 | Parameters: 13 | - data (list): A list of dictionaries representing the data to be filtered. 14 | - search (str): A string representing the search term. 15 | 16 | Returns: 17 | - filtered_data (list): A list of dictionaries representing the filtered data. 18 | """ 19 | filtered_data = [] 20 | for item in data: 21 | temp = {**item} 22 | del temp["id"] 23 | for search_term in search.split(): 24 | if ( 25 | search_term.lower() in " ".join(temp.values()).lower() 26 | and item not in filtered_data 27 | ): 28 | filtered_data.append(item) 29 | return filtered_data 30 | 31 | 32 | def sales_dates_datasource(): 33 | """ 34 | Retrieve a list of dates from the sales data source. 35 | 36 | Returns: 37 | list: A list of dates in descending order. 38 | 39 | Raises: 40 | None 41 | """ 42 | sales = view_controller.database.sale.list() 43 | dates = [str(datetime.now()).split()[0]] 44 | for sale in sales: 45 | sale_date = sale["occurred_at"].split()[0] 46 | if sale_date not in dates: 47 | dates.append(sale_date) 48 | dates.sort(reverse=True) 49 | return dates 50 | 51 | 52 | class ReportsViewModel(ViewModel): 53 | """ 54 | Represents the view model for the Reports view. 55 | """ 56 | 57 | def __init__(self) -> None: 58 | self.id = "reports" 59 | 60 | self.select_sales_date: Selection 61 | self.table_sales_in_date: Table 62 | self.table_products_in_sale: Table 63 | 64 | def update(self): 65 | """ 66 | Update the view. 67 | """ 68 | 69 | self.select_sales_date = Selection( 70 | items=sales_dates_datasource(), 71 | style={"flex": 1}, 72 | on_select=lambda _: self.on_select_sales_date(), 73 | ) 74 | 75 | self.table_sales_in_date = Table( 76 | title="Sales", 77 | headings=["Total Price", "Payment Method", "Customer Name"], 78 | data_source=self.sales_in_date_datasource, 79 | filter_source=self.sales_in_date_filter_source, 80 | style={"flex": 1}, 81 | ) 82 | 83 | # Custom on_select event 84 | self.table_sales_in_date.table().on_select = self.on_select_sale 85 | 86 | self.table_products_in_sale = Table( 87 | title="Products in Sale", 88 | headings=["Name", "Brand", "Reference", "Price", "Quantity"], 89 | data_source=self.products_in_sale_datasource, 90 | filter_source=self.products_in_sale_filter_source, 91 | style={"flex": 1}, 92 | ) 93 | 94 | sales_by_date_node = Row( 95 | [ 96 | Box(style={"flex": 3}), 97 | Row( 98 | [Label("Sales by Date"), self.select_sales_date], 99 | {"flex": 1}, 100 | ), 101 | ] 102 | ) 103 | 104 | view_controller.update_main( 105 | [ 106 | Column( 107 | [ 108 | Label("Reports"), 109 | Divider(), 110 | sales_by_date_node, 111 | Divider(), 112 | self.table_sales_in_date, 113 | Divider(), 114 | self.table_products_in_sale, 115 | ], 116 | {"flex": 1}, 117 | ) 118 | ] 119 | ) 120 | 121 | def on_select_sales_date(self): 122 | """ 123 | Updates the sales and products tables based on the selected sales date. 124 | 125 | Parameters: 126 | self (object): The instance of the class. 127 | 128 | Returns: 129 | None 130 | """ 131 | self.table_sales_in_date.update() 132 | self.table_products_in_sale.update() 133 | 134 | def on_select_sale(self, _, row): 135 | """ 136 | Sets the selected row in the sales table and updates the products in the sale table. 137 | 138 | Parameters: 139 | _ (Table): The table object representing the sales table. 140 | row (int): The index of the selected row. 141 | 142 | Returns: 143 | None 144 | """ 145 | self.table_sales_in_date.set_selected(row) 146 | self.table_products_in_sale.update() 147 | 148 | def sales_in_date_datasource(self): 149 | """ 150 | Retrieves the sales data from the database for a given date. 151 | 152 | :return: A list of sales data for the selected date. 153 | """ 154 | selected_date = self.select_sales_date.value 155 | return view_controller.database.sale.search_by_attribute( 156 | selected_date, "occurred_at" 157 | ) 158 | 159 | def sales_in_date_filter_source(self, search): 160 | """ 161 | Retrieves the sales data from the date filter data source and applies a search filter. 162 | 163 | Parameters: 164 | search (str): The search term to filter the sales data. 165 | 166 | Returns: 167 | list: A list of sales data matching the search term. 168 | """ 169 | data = self.sales_in_date_datasource() 170 | if data is None: 171 | return [] 172 | return search_handler(data, search) 173 | 174 | def products_in_sale_datasource(self): 175 | """ 176 | Retrieves the products in the selected sale from the data source. 177 | 178 | Returns: 179 | A list of products in the selected sale. 180 | """ 181 | selected_sale = self.table_sales_in_date.selected() 182 | if selected_sale is None: 183 | return [] 184 | return selected_sale["products"] 185 | 186 | def products_in_sale_filter_source(self, search): 187 | """ 188 | Generates a filtered list of products based on the given search string. 189 | 190 | Parameters: 191 | search (str): The search string to filter the products. 192 | 193 | Returns: 194 | list: A list of products that match the search string. 195 | """ 196 | data = self.products_in_sale_datasource() 197 | if data is None: 198 | return [] 199 | return search_handler(data, search) 200 | 201 | 202 | view_controller.register_model(ReportsViewModel()) 203 | -------------------------------------------------------------------------------- /tests/database_test.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from tinydb import TinyDB 4 | 5 | from ssiql.database import ( 6 | DatabaseManager, 7 | PaymentMethod, 8 | Product, 9 | Sale, 10 | database_files, 11 | db_manager, 12 | ) 13 | 14 | 15 | def dummy_product( 16 | name="dummy", brand="dummy", reference="dummy", price=0.00, quantity=0 17 | ): 18 | """ 19 | Create a dummy product with the given parameters. 20 | """ 21 | return Product( 22 | name=name, 23 | brand=brand, 24 | reference=reference, 25 | price=price, 26 | quantity=quantity, 27 | ) 28 | 29 | 30 | def dummy_product_registration(api: DatabaseManager): 31 | """ 32 | Registers dummy products in the database. 33 | """ 34 | api.product.create(dummy_product(name="Fine Wood")) 35 | api.product.create(dummy_product(brand="Wood")) 36 | api.product.create(dummy_product(reference="Coarse Wood")) 37 | api.product.create(dummy_product(price=10.00)) 38 | api.product.create(dummy_product(quantity=10)) 39 | 40 | 41 | def dummy_sale(product_difference: int = 0): 42 | """ 43 | Create a dummy sale transaction. 44 | """ 45 | return Sale( 46 | payment_method=PaymentMethod.ON_CREDIT, 47 | total_price=10.00, 48 | occurred_at=str(datetime.now()), 49 | customer_name="dummy", 50 | products=[ 51 | dummy_product(quantity=product_difference), 52 | ], 53 | ) 54 | 55 | 56 | def test_if_db_files_were_created(): 57 | """ 58 | Check if the database files for products and sales were created. 59 | """ 60 | assert isinstance(database_files.products_db("Memory"), TinyDB) 61 | assert isinstance(database_files.sales_db("Memory"), TinyDB) 62 | 63 | 64 | def test_if_product_can_be_created(): 65 | """ 66 | Check if a product can be created. 67 | """ 68 | api = db_manager("Memory") 69 | assert api.product.create(dummy_product()) is True 70 | 71 | 72 | def test_if_duplicate_product_cannot_be_created(): 73 | """ 74 | Test to check if a duplicate product cannot be created. 75 | """ 76 | api = db_manager("Memory") 77 | 78 | product = dummy_product() 79 | api.product.create(product) # Create the first product 80 | assert api.product.create(product) is False 81 | 82 | 83 | def test_if_product_can_be_updated(): 84 | """ 85 | Check if a product can be updated successfully. 86 | """ 87 | api = db_manager("Memory") 88 | 89 | api.product.create(dummy_product()) 90 | api.product.update(1, dummy_product(name="New Name")) 91 | assert api.product.search_by_id(1) == {"id": 1, **dummy_product(name="New Name")} 92 | 93 | 94 | def test_if_product_can_be_deleted(): 95 | """ 96 | Test if a product can be deleted. 97 | """ 98 | api = db_manager("Memory") 99 | 100 | dummy_product_registration(api) 101 | assert api.product.delete(1) is True 102 | 103 | 104 | def test_if_products_can_be_listed(): 105 | """ 106 | Test if products can be listed. 107 | """ 108 | api = db_manager("Memory") 109 | 110 | for index in range(10): 111 | api.product.create(dummy_product(quantity=index)) 112 | assert len(api.product.list()) == 10 113 | 114 | 115 | def test_if_getting_product_by_id_works(): 116 | """ 117 | Test if getting a product by ID works. 118 | """ 119 | api = db_manager("Memory") 120 | 121 | for index in range(10): 122 | api.product.create(dummy_product(quantity=index)) 123 | assert api.product.search_by_id(3) is not None 124 | 125 | 126 | def test_if_searching_products_by_all_attributes_works(): 127 | """ 128 | Test if searching products by all attributes works. 129 | """ 130 | api = db_manager("Memory") 131 | 132 | dummy_product_registration(api) 133 | assert len(api.product.search("Wood")) == 3 134 | 135 | 136 | def test_if_searching_products_by_nonexistent_attribute_fails(): 137 | """ 138 | Test if searching products by a nonexistent attribute fails. 139 | """ 140 | api = db_manager("Memory") 141 | 142 | api.product.create(dummy_product()) 143 | assert api.product.search_by_attribute(attribute="dummy", terms="dummy") is None 144 | 145 | 146 | def test_if_searching_products_by_name_works(): 147 | """ 148 | Tests if searching products by name works. 149 | """ 150 | api = db_manager("Memory") 151 | 152 | dummy_product_registration(api) 153 | products = api.product.search_by_attribute(attribute="name", terms="Wood") 154 | assert products is not None and len(products) == 1 155 | 156 | 157 | def test_if_searching_products_by_brand_works(): 158 | """ 159 | Test if searching products by brand works. 160 | """ 161 | api = db_manager("Memory") 162 | 163 | dummy_product_registration(api) 164 | products = api.product.search_by_attribute(attribute="brand", terms="Wood") 165 | assert products is not None and len(products) == 1 166 | 167 | 168 | def test_if_searching_products_by_reference_works(): 169 | """ 170 | Tests if searching products by reference works. 171 | """ 172 | api = db_manager("Memory") 173 | 174 | dummy_product_registration(api) 175 | products = api.product.search_by_attribute(attribute="reference", terms="Wood") 176 | assert products is not None and len(products) == 1 177 | 178 | 179 | def test_if_searching_products_by_price_works(): 180 | """ 181 | Test if searching products by price works. 182 | """ 183 | api = db_manager("Memory") 184 | 185 | dummy_product_registration(api) 186 | products = api.product.search_by_attribute(attribute="price", terms="10.00") 187 | assert products is not None and len(products) == 0 188 | 189 | 190 | def test_if_searching_products_by_quantity_works(): 191 | """ 192 | Test if searching products by quantity works. 193 | """ 194 | api = db_manager("Memory") 195 | 196 | dummy_product_registration(api) 197 | products = api.product.search_by_attribute(attribute="quantity", terms="10") 198 | assert products is not None and len(products) == 0 199 | 200 | 201 | def test_if_sale_can_be_created(): 202 | """ 203 | Test if a sale can be created. 204 | """ 205 | api = db_manager("Memory") 206 | 207 | assert api.sale.create(dummy_sale()) is True 208 | 209 | 210 | def test_if_duplicate_sale_cannot_be_created(): 211 | """ 212 | Tests if a duplicate sale cannot be created. 213 | """ 214 | api = db_manager("Memory") 215 | 216 | sale = dummy_sale() 217 | api.sale.create(sale) # Create the first sale 218 | assert api.sale.create(sale) is False 219 | 220 | 221 | def test_if_sales_can_be_listed(): 222 | """ 223 | Test if sales can be listed. 224 | """ 225 | api = db_manager("Memory") 226 | 227 | for index in range(10): 228 | api.sale.create(dummy_sale(index)) 229 | assert len(api.sale.list()) == 10 230 | -------------------------------------------------------------------------------- /ssiql/components/common.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Literal, Optional, Union 2 | 3 | import toga 4 | 5 | from .style import Style, StyleDocument 6 | 7 | 8 | class Box(toga.Box): 9 | """ 10 | A box widget. 11 | """ 12 | 13 | def __init__( 14 | self, 15 | children: list[toga.Widget] = [], 16 | style: StyleDocument = StyleDocument(), 17 | ): 18 | """ 19 | Initializes the class with the given parameters. 20 | 21 | Args: 22 | children (list[toga.Widget], optional): The list of child widgets. 23 | Defaults to an empty list. 24 | 25 | style (StyleDocument, optional): The style document to apply to 26 | the widget. Defaults to an empty StyleDocument object. 27 | 28 | Returns: 29 | None 30 | """ 31 | super().__init__(children=children) 32 | Style(self, style) 33 | 34 | 35 | class Column(Box): 36 | """ 37 | A column widget. 38 | """ 39 | 40 | def __init__( 41 | self, children: list[toga.Widget] = [], style: StyleDocument = StyleDocument() 42 | ): 43 | """ 44 | Initializes a new instance of the class. 45 | 46 | Parameters: 47 | children (list[toga.Widget], optional): A list of child widgets. 48 | Defaults to an empty list. 49 | 50 | style (StyleDocument, optional): The style document for the widget. 51 | Defaults to an empty StyleDocument object. 52 | 53 | Returns: 54 | None 55 | """ 56 | style["direction"] = "column" 57 | super().__init__(children=children, style=style) 58 | 59 | 60 | class Row(Box): 61 | """ 62 | A row widget. 63 | """ 64 | 65 | def __init__( 66 | self, 67 | children: list[toga.Widget] = [], 68 | style: StyleDocument = StyleDocument(), 69 | ): 70 | """ 71 | Initializes an instance of the class. 72 | 73 | Args: 74 | children (list[toga.Widget], optional): A list of child widgets. 75 | Defaults to []. 76 | 77 | style (StyleDocument, optional): The style document for the widget. 78 | Defaults to StyleDocument(). 79 | 80 | Returns: 81 | None 82 | """ 83 | style["direction"] = "row" 84 | super().__init__(children=children, style=style) 85 | 86 | 87 | class Divider(toga.Divider): 88 | """ 89 | A divider widget. 90 | """ 91 | 92 | def __init__( 93 | self, 94 | direction: Literal["h", "v"] = "h", 95 | style: StyleDocument = StyleDocument(padding_top=10, padding_bottom=10), 96 | ): 97 | """ 98 | Initializes a new instance of the class. 99 | 100 | Args: 101 | direction (Literal["h", "v"], optional): The direction of the instance. 102 | Defaults to "h". 103 | 104 | style (StyleDocument, optional): The style document of the instance. 105 | Defaults to StyleDocument(padding_top=10, padding_bottom=10). 106 | """ 107 | m_direction = 0 if direction == "h" else 1 108 | super().__init__(direction=m_direction) 109 | Style(self, style) 110 | 111 | 112 | class Label(toga.Label): 113 | """ 114 | A label widget. 115 | """ 116 | 117 | def __init__( 118 | self, 119 | text: str, 120 | style: StyleDocument = StyleDocument(), 121 | ): 122 | """ 123 | Initializes a new instance of the class. 124 | 125 | Args: 126 | text (str): The text to be initialized with. 127 | 128 | style (StyleDocument, optional): The style document to be applied. 129 | Defaults to an instance of StyleDocument. 130 | 131 | Returns: 132 | None 133 | """ 134 | super().__init__(text=text) 135 | Style(self, style) 136 | 137 | 138 | class NumberInput(toga.NumberInput): 139 | """ 140 | A number input widget. 141 | """ 142 | 143 | def __init__( 144 | self, 145 | value: Union[int, None] = None, 146 | style: StyleDocument = StyleDocument(), 147 | ): 148 | """ 149 | Initializes a new instance of the class. 150 | 151 | Parameters: 152 | value (Union[int, None], optional): The initial value. Defaults to None. 153 | 154 | style (StyleDocument, optional): The style document. Defaults to StyleDocument(). 155 | 156 | Returns: 157 | None 158 | """ 159 | super().__init__(value=value) 160 | Style(self, style) 161 | 162 | 163 | class TextInput(toga.TextInput): 164 | """ 165 | A text input widget. 166 | """ 167 | 168 | def __init__( 169 | self, 170 | value: Union[str, None] = None, 171 | on_change=None, 172 | style: StyleDocument = StyleDocument(), 173 | ): 174 | """ 175 | Initializes an instance of the class. 176 | 177 | Args: 178 | value (Union[str, None], optional): The initial value of the instance. 179 | Defaults to None. 180 | 181 | on_change (callable, optional): A callback function to be called when the 182 | value changes. Defaults to None. 183 | 184 | style (StyleDocument, optional): The style document to be applied to the 185 | instance. Defaults to StyleDocument(). 186 | 187 | Returns: 188 | None 189 | """ 190 | super().__init__(value=value, on_change=on_change) 191 | Style(self, style) 192 | 193 | 194 | class Button(toga.Button): 195 | """ 196 | A class representing a button widget. 197 | """ 198 | 199 | def __init__( 200 | self, text: str, on_press=None, style: StyleDocument = StyleDocument() 201 | ): 202 | """ 203 | Initializes a new instance of the class. 204 | 205 | Parameters: 206 | text (str): The text to display on the button. 207 | 208 | on_press (function, optional): A callback function to execute when the button 209 | is pressed. Defaults to None. 210 | 211 | style (StyleDocument, optional): An instance of the StyleDocument class to customize 212 | the button's appearance. Defaults to an empty StyleDocument. 213 | 214 | Returns: 215 | None 216 | """ 217 | super().__init__(text=text, on_press=on_press) 218 | Style(self, style) 219 | 220 | 221 | class Selection(toga.Selection): 222 | """ 223 | A class representing a selection widget. 224 | """ 225 | 226 | def __init__( 227 | self, 228 | items: list[str] = [], 229 | style: StyleDocument = StyleDocument(), 230 | on_select: Optional[Callable] = None, 231 | ): 232 | """ 233 | Initializes a new instance of the class. 234 | 235 | Args: 236 | items (list[str], optional): The list of items. Defaults to []. 237 | 238 | style (StyleDocument, optional): The style of the document. Defaults to StyleDocument(). 239 | 240 | on_select (Optional[Callable], optional): The callback function to be executed when 241 | an item is selected. Defaults to None. 242 | """ 243 | super().__init__(items=items, on_select=on_select) 244 | Style(self, style) 245 | 246 | 247 | class MultilineTextInput(toga.MultilineTextInput): 248 | """ 249 | A class representing a multiline text input widget. 250 | """ 251 | 252 | def __init__( 253 | self, 254 | value: Optional[str] = None, 255 | on_change=None, 256 | style: StyleDocument = StyleDocument(), 257 | ): 258 | """ 259 | Initializes a new instance of the class. 260 | 261 | Parameters: 262 | value (Optional[str]): The initial value for the instance. 263 | Defaults to None. 264 | 265 | on_change (Optional[Callable]): A callback function to be called when the value changes. 266 | 267 | style (StyleDocument): The style document to be applied to the instance. 268 | Defaults to an empty StyleDocument. 269 | 270 | Returns: 271 | None 272 | """ 273 | super().__init__(value=value, on_change=on_change) 274 | Style(self, style) 275 | -------------------------------------------------------------------------------- /ssiql/database/database_api.py: -------------------------------------------------------------------------------- 1 | from re import IGNORECASE 2 | from typing import Literal, Union 3 | 4 | from tinydb import TinyDB, where 5 | from tinydb.table import Document 6 | 7 | from .database_files import products_db, sales_db 8 | from .database_models import PaymentMethod, Product, Sale 9 | 10 | 11 | def check_if_document_exists( 12 | document: Union[Product, Sale], 13 | database: TinyDB, 14 | ): 15 | """ 16 | Checks if a document exists in the specified database. 17 | 18 | Parameters: 19 | document (Union[Product, Sale]): The document to check for existence. 20 | 21 | database (TinyDB): The database to search in. 22 | 23 | Returns: 24 | bool: True if the document exists in the database, False otherwise. 25 | """ 26 | if "id" in document: 27 | del document["id"] 28 | 29 | for item in database.all(): 30 | attribute_existence_result = [] 31 | 32 | for attribute in document.keys(): 33 | if item[attribute] == document[attribute]: 34 | attribute_existence_result.append(True) 35 | else: 36 | attribute_existence_result.append(False) 37 | 38 | if all(attribute_existence_result): 39 | return True 40 | 41 | return False 42 | 43 | 44 | def add_id_to_document(document: Document): 45 | """ 46 | Adds an "id" field to the given document and returns the modified document. 47 | 48 | Args: 49 | document (Document): The document object to which the "id" field will be added. 50 | 51 | Returns: 52 | dict: The modified document with the "id" field added. 53 | """ 54 | return {"id": document.doc_id, **document} 55 | 56 | 57 | class ProductManager: 58 | """ 59 | The ProductManager class provides methods for managing products in a database. 60 | """ 61 | 62 | def __init__(self, db) -> None: 63 | self.db = db 64 | 65 | def __dummy__(self): 66 | return Product( 67 | id=0, 68 | name="dummy", 69 | brand="dummy", 70 | reference="dummy", 71 | price=0.00, 72 | quantity=0, 73 | ) 74 | 75 | def list(self) -> list[Product]: 76 | """ 77 | Returns a list of `Product` objects. 78 | 79 | :return: A list of `Product` objects. 80 | :type: list[Product] 81 | """ 82 | return [Product(**add_id_to_document(product)) for product in self.db.all()] 83 | 84 | def create(self, document: Product) -> bool: 85 | """ 86 | Create a new document in the database. 87 | 88 | Args: 89 | document (Product): The document to be created. 90 | 91 | Returns: 92 | bool: True if the document was successfully created, False otherwise. 93 | """ 94 | if check_if_document_exists(document, self.db): 95 | return False 96 | self.db.insert(document) 97 | return True 98 | 99 | def delete(self, id: int) -> Literal[True]: 100 | """ 101 | Delete a record from the database. 102 | 103 | Args: 104 | id (int): The ID of the record to be deleted. 105 | 106 | Returns: 107 | Literal[True]: Returns True if the record was successfully deleted. 108 | """ 109 | self.db.remove(doc_ids=[id]) 110 | return True 111 | 112 | def update(self, id: int, document: Product) -> Literal[True]: 113 | """ 114 | Update a document in the database. 115 | 116 | Args: 117 | id (int): The ID of the document to be updated. 118 | document (Product): The updated document. 119 | 120 | Returns: 121 | Literal[True]: True if the update was successful. 122 | """ 123 | self.db.update(doc_ids=[id], fields=document) 124 | return True 125 | 126 | def search(self, terms: str): 127 | """ 128 | Search for products based on the given terms. 129 | 130 | Args: 131 | terms (str): The terms used to filter the products. 132 | 133 | Returns: 134 | list: A list of filtered products. 135 | """ 136 | products = self.list() 137 | filtered_products = [] 138 | 139 | for product in products: 140 | product_info = {**product} 141 | del product_info["id"] 142 | del product_info["price"] 143 | del product_info["quantity"] 144 | 145 | for term in terms.split(): 146 | if ( 147 | term.lower() in " ".join(product_info.values()).lower() 148 | and product not in filtered_products 149 | ): 150 | filtered_products.append(product) 151 | 152 | return filtered_products 153 | 154 | def search_by_id(self, product_id: int): 155 | """ 156 | Retrieves a product from the database based on its ID. 157 | 158 | Args: 159 | product_id (int): The ID of the product to search for. 160 | 161 | Returns: 162 | Product or None: The retrieved product as a `Product` instance if found, or `None` if not found. 163 | """ 164 | product = self.db.get(doc_id=product_id) 165 | if product is None: 166 | return None 167 | 168 | return Product(**add_id_to_document(product)) 169 | 170 | def search_by_attribute(self, terms: str, attribute: str): 171 | """ 172 | Search for products in the database by a given attribute. 173 | 174 | Args: 175 | terms (str): The search terms to be used. 176 | attribute (str): The attribute to search by. 177 | 178 | Returns: 179 | list: A list of filtered products matching the search criteria. 180 | """ 181 | if attribute.lower() not in self.__dummy__().keys(): 182 | return 183 | 184 | filtered_products = [] 185 | for term in terms.split(): 186 | products = self.db.search( 187 | where(attribute.lower()).search(r"{}".format(term), IGNORECASE) 188 | ) 189 | 190 | for product in products: 191 | if product not in filtered_products: 192 | filtered_products.append(Product(**add_id_to_document(product))) 193 | 194 | return filtered_products 195 | 196 | 197 | class SaleManager: 198 | """ 199 | The SaleManager class provides methods for managing sales in a database. 200 | """ 201 | 202 | def __init__(self, db): 203 | self.db = db 204 | 205 | def __dummy__(self): 206 | return Sale( 207 | id=0, 208 | payment_method=PaymentMethod.ON_CREDIT, 209 | products=[], 210 | customer_name="dummy", 211 | occurred_at="dummy", 212 | total_price=0.00, 213 | ) 214 | 215 | def list(self) -> list[Sale]: 216 | """ 217 | Returns a list of Sale objects. 218 | 219 | :return: A list of Sale objects. 220 | :rtype: list[Sale] 221 | """ 222 | return [Sale(**add_id_to_document(sale)) for sale in self.db.all()] 223 | 224 | def create(self, document: Sale): 225 | """ 226 | Creates a new sale document in the database. 227 | 228 | Parameters: 229 | document (Sale): The sale document to be created. 230 | 231 | Returns: 232 | bool: True if the document was successfully created, False otherwise. 233 | """ 234 | if check_if_document_exists(document, self.db): 235 | return False 236 | 237 | self.db.insert(document) 238 | return True 239 | 240 | def delete(self, id: int): 241 | """ 242 | Deletes a record from the database with the given ID. 243 | 244 | Args: 245 | id (int): The ID of the record to be deleted. 246 | 247 | Returns: 248 | bool: True if the record was successfully deleted, False otherwise. 249 | """ 250 | self.db.remove(doc_ids=[id]) 251 | return True 252 | 253 | def update(self, id: int, document: Sale): 254 | self.db.update(doc_ids=[id], fields=document) 255 | return True 256 | 257 | def search(self, terms: str): 258 | sales = self.list() 259 | 260 | filtered_sales = [] 261 | for sale in sales: 262 | sale_info = {**sale} 263 | del sale_info["id"] 264 | 265 | for term in terms.split(): 266 | if ( 267 | term.lower() in " ".join(sale_info.values()).lower() 268 | and sale not in filtered_sales 269 | ): 270 | filtered_sales.append(sale) 271 | 272 | return filtered_sales 273 | 274 | def search_by_id(self, id: int): 275 | """ 276 | Search the database for a document with the given id and return the corresponding Sale object. 277 | 278 | Args: 279 | id (int): The id of the document to search for. 280 | 281 | Returns: 282 | Sale or None: The Sale object corresponding to the document if found, None otherwise. 283 | """ 284 | sale = self.db.get(doc_id=id) 285 | if sale is None: 286 | return None 287 | 288 | return Sale(**add_id_to_document(sale)) 289 | 290 | def search_by_attribute(self, terms: str, attribute: str): 291 | """ 292 | Search for sales by a given attribute and terms. 293 | 294 | Args: 295 | terms (str): The search terms to look for. 296 | attribute (str): The attribute to search for. 297 | 298 | Returns: 299 | List[dict]: A list of filtered sales. 300 | 301 | """ 302 | if attribute.lower() not in self.__dummy__().keys(): 303 | return 304 | 305 | filtered_sales = [] 306 | for term in terms.split(): 307 | sales = self.db.search( 308 | where(attribute.lower()).search(r"{}".format(term), IGNORECASE) 309 | ) 310 | 311 | for sale in sales: 312 | if sale not in filtered_sales: 313 | filtered_sales.append(Sale(**add_id_to_document(sale))) 314 | 315 | return filtered_sales 316 | 317 | 318 | class DatabaseManager: 319 | def __init__(self, storage: Literal["Memory", None] = None) -> None: 320 | self.product = ProductManager(products_db(storage)) 321 | self.sale = SaleManager(sales_db(storage)) 322 | -------------------------------------------------------------------------------- /ssiql/views/sales.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from ..components import ( 4 | Box, 5 | Button, 6 | Column, 7 | Divider, 8 | Label, 9 | NumberInput, 10 | Row, 11 | Selection, 12 | Table, 13 | TextInput, 14 | ) 15 | from ..database import Product, Sale 16 | from ..views import ViewModel, view_controller 17 | 18 | 19 | def search_handler(data, search, *del_keys): 20 | """ 21 | Search through the given data based on the provided search 22 | terms and filter out any unwanted keys. 23 | """ 24 | filtered_data = [] 25 | for item in data: 26 | temp = {**item} 27 | del temp["id"] 28 | 29 | for key in del_keys: 30 | del temp[key] 31 | 32 | for search_term in search.split(): 33 | if ( 34 | search_term.lower() in " ".join(temp.values()).lower() 35 | and item not in filtered_data 36 | ): 37 | filtered_data.append(item) 38 | return filtered_data 39 | 40 | 41 | class SalesViewModel(ViewModel): 42 | """ 43 | Represents the view model for the sales view. 44 | """ 45 | 46 | def __init__(self) -> None: 47 | self.id = "sales" 48 | 49 | self.shopping_cart: list[Product] 50 | self.table_available_products: Table 51 | self.input_quantity_product_add_cart: NumberInput 52 | self.table_products_in_shopping_cart: Table 53 | self.select_payment_method: Selection 54 | self.input_client_name: TextInput 55 | self.button_add_to_cart: Button 56 | self.label_total_value_in_shopping_cart: Label 57 | 58 | def update(self): 59 | """ 60 | Update the View 61 | """ 62 | 63 | self.shopping_cart: list[Product] = [] 64 | 65 | self.table_available_products = Table( 66 | title="Available Products", 67 | headings=["Name", "Brand", "Reference", "Price", "Quantity"], 68 | data_source=self.available_products_datasource, 69 | filter_source=self.available_products_filter_source, 70 | style={"flex": 2}, 71 | ) 72 | 73 | self.input_quantity_product_add_cart = NumberInput( 74 | 1, {"flex": 1, "padding_right": 10} 75 | ) 76 | 77 | self.table_products_in_shopping_cart = Table( 78 | title="Products in Shopping Cart", 79 | headings=["Name", "Brand", "Reference", "Price", "Quantity"], 80 | data_source=self.products_in_shopping_cart_datasource, 81 | filter_source=self.products_in_shopping_cart_filter_source, 82 | style={"flex": 2}, 83 | ) 84 | 85 | self.input_client_name = TextInput(style={"flex": 1, "padding_right": 10}) 86 | 87 | self.select_payment_method = Selection( 88 | ["CASH", "CARD", "PIX", "CREDIT"], 89 | style={"flex": 1, "padding_right": 10}, 90 | ) 91 | 92 | self.label_total_value_in_shopping_cart = Label( 93 | "Total Purchase Value: $0.00", style={"flex": 1} 94 | ) 95 | 96 | row_add_to_cart = Row( 97 | [ 98 | Box(style={"flex": 1}), 99 | Row( 100 | [ 101 | Label("Quantity"), 102 | self.input_quantity_product_add_cart, 103 | ], 104 | {"flex": 1}, 105 | ), 106 | Button( 107 | "Add to Cart", 108 | lambda _: self.add_product_to_shopping_cart_handler(), 109 | {"flex": 1}, 110 | ), 111 | ], 112 | {"padding_top": 10}, 113 | ) 114 | 115 | row_1 = Row( 116 | [ 117 | self.label_total_value_in_shopping_cart, 118 | Button( 119 | "Remove Product", 120 | lambda _: self.remove_product_from_shopping_cart_handler(), 121 | {"flex": 1, "padding_right": 10}, 122 | ), 123 | Button( 124 | "Clear Cart", 125 | lambda _: self.clear_shopping_cart_handler(), 126 | {"flex": 1}, 127 | ), 128 | ], 129 | {"padding_top": 10}, 130 | ) 131 | 132 | row_2 = Row( 133 | [ 134 | Row( 135 | [ 136 | Label("Customer Name"), 137 | self.input_client_name, 138 | ], 139 | {"flex": 1}, 140 | ), 141 | Row( 142 | [ 143 | Label("Payment Method"), 144 | self.select_payment_method, 145 | ], 146 | {"flex": 1}, 147 | ), 148 | Button( 149 | "Complete Sale", 150 | lambda _: self.finalize_sale_handler(), 151 | {"flex": 1}, 152 | ), 153 | ], 154 | {"padding_top": 10}, 155 | ) 156 | 157 | view_controller.update_main( 158 | [ 159 | Column( 160 | [ 161 | Label("Sales"), 162 | Divider(), 163 | Column( 164 | [self.table_available_products, row_add_to_cart], 165 | {"flex": 1}, 166 | ), 167 | Divider(), 168 | Column( 169 | [self.table_products_in_shopping_cart, row_1, row_2], 170 | {"flex": 1}, 171 | ), 172 | ], 173 | {"flex": 1}, 174 | ) 175 | ] 176 | ) 177 | 178 | def tables_update(self): 179 | """ 180 | Updates the tables for available products and products in the shopping cart. 181 | """ 182 | self.table_available_products.update() 183 | self.table_products_in_shopping_cart.update() 184 | 185 | # Update the total purchase value 186 | if self.shopping_cart: 187 | total_value = float( 188 | sum( 189 | [ 190 | product["price"] * product["quantity"] 191 | for product in self.shopping_cart 192 | ] 193 | ) 194 | ) 195 | else: 196 | total_value = 0.00 197 | self.label_total_value_in_shopping_cart.text = ( 198 | f"Total Purchase Value: ${total_value:.2f}" 199 | ) 200 | 201 | def available_products_datasource(self): 202 | """ 203 | Returns a list of available products from the datasource. 204 | """ 205 | products = view_controller.database.product.list() 206 | return [ 207 | product 208 | for product in products 209 | if product["quantity"] > 0 210 | and "id" in product 211 | and f"'id': {product['id']}" not in str(self.shopping_cart) 212 | ] 213 | 214 | def available_products_filter_source(self, search: str): 215 | """ 216 | Filter the available products based on the search query. 217 | """ 218 | products = search_handler( 219 | self.available_products_datasource(), 220 | search, 221 | "reference", 222 | "price", 223 | "quantity", 224 | ) 225 | return products 226 | 227 | def products_in_shopping_cart_datasource(self): 228 | """ 229 | Returns the shopping cart datasource. 230 | """ 231 | return self.shopping_cart 232 | 233 | def products_in_shopping_cart_filter_source(self, search: str): 234 | """ 235 | Filters the products in the shopping cart based on the provided search term. 236 | """ 237 | products = search_handler( 238 | self.shopping_cart, search, "reference", "price", "quantity" 239 | ) 240 | return products 241 | 242 | def clear_shopping_cart_handler(self): 243 | """ 244 | Clears the shopping cart by resetting the self.shopping_cart attribute to an empty list. 245 | """ 246 | self.shopping_cart = [] 247 | self.tables_update() 248 | 249 | def add_product_to_shopping_cart_handler(self): 250 | """ 251 | Adds a selected product to the shopping cart. 252 | """ 253 | product = self.table_available_products.selected() 254 | quantity = self.input_quantity_product_add_cart.value 255 | if ( 256 | not product 257 | or quantity is None 258 | or quantity <= 0 259 | or quantity > product["quantity"] 260 | ): 261 | return 262 | 263 | product["quantity"] = int(quantity) 264 | 265 | self.shopping_cart.append(Product(**product)) 266 | self.input_quantity_product_add_cart.value = 1 267 | self.tables_update() 268 | 269 | def remove_product_from_shopping_cart_handler(self): 270 | """ 271 | Removes a product from the shopping cart. 272 | """ 273 | product = self.table_products_in_shopping_cart.selected() 274 | if not product: 275 | return 276 | 277 | self.shopping_cart.remove(Product(**product)) 278 | self.tables_update() 279 | 280 | def finalize_sale_handler(self): 281 | """ 282 | Finalizes a sale by updating the product quantities, creating a sale record, 283 | and resetting the shopping cart. 284 | """ 285 | if not self.shopping_cart: 286 | return 287 | 288 | for product in self.shopping_cart: 289 | if "id" not in product: 290 | continue 291 | 292 | stored_product = view_controller.database.product.search_by_id( 293 | product["id"] 294 | ) 295 | if not stored_product: 296 | continue 297 | 298 | stored_product["quantity"] -= product["quantity"] 299 | 300 | view_controller.database.product.update(product["id"], stored_product) 301 | 302 | total_price = [ 303 | product["price"] * product["quantity"] for product in self.shopping_cart 304 | ] 305 | 306 | sale = Sale( 307 | payment_method=self.select_payment_method.value, 308 | occurred_at=str(datetime.now()), 309 | total_price=float(sum(total_price)), 310 | customer_name=self.input_client_name.value, 311 | products=self.shopping_cart, 312 | ) 313 | view_controller.database.sale.create(sale) 314 | 315 | self.input_client_name.value = "" 316 | self.shopping_cart = [] 317 | self.tables_update() 318 | 319 | 320 | view_controller.register_model(SalesViewModel()) 321 | --------------------------------------------------------------------------------