diff --git a/.gitignore b/.gitignore index 9a52e5b..07ddefb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ +*~ +*.pyc +dist/ pyCOREPOS.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cfab1d1..2c26baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,161 @@ All notable changes to pyCOREPOS will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.5.1 (2025-02-20) + +### Fix + +- add `Product.default_vendor_item` convenience property + +## v0.5.0 (2025-02-01) + +### Feat + +- use true column names for transaction data models + +### Fix + +- define common base schema for Product model +- add `Parameter` model for lane_op +- add model for lane_trans `LocalTrans` + +## v0.4.0 (2025-01-24) + +### Feat + +- add common base class for `dtransactions` and similar models + +### Fix + +- add `Employee` model for lane_op +- fix ordering of name columns for MemberInfo + +## v0.3.5 (2025-01-15) + +### Fix + +- add workaround to avoid missing schema columns + +## v0.3.4 (2025-01-15) + +### Fix + +- misc. cleanup for sales batch models +- add more enums for batch discount type, editor UI + +## v0.3.3 (2025-01-13) + +### Fix + +- remove `autoincrement` option for composite PK fields + +## v0.3.2 (2025-01-11) + +### Fix + +- add base class for all transaction tables, views +- add `MemberType.ignore_sales` column +- add model for `MasterSuperDepartment` + +## v0.3.1 (2024-12-17) + +### Fix + +- add `wicable`, `active` columns for Department model + +## v0.3.0 (2024-08-06) + +### Feat + +- add model for `MemberContactPreference` (`op.memContactPrefs`) +- add model for `CustomReceiptLine` (`op.customReceipt`) + +## v0.2.1 (2024-07-04) + +### Fix + +- add API methods, `get_employees()` and `get_employee()` +- remove `Change` data model +- remove dependency for `six` package + +## v0.2.0 (2024-06-10) + +### Feat + +- switch from setup.cfg to pyproject.toml + hatchling + +## [0.1.20] - 2024-05-29 +### Changed +- Add enum for CORE (Office) DB types. + +## [0.1.19] - 2023-11-01 +### Changed +- Fix data types for tax, voided in `dtransactions`. +- Fix synonym for `dtransactions.tax`. + +## [0.1.18] - 2023-10-12 +### Changed +- Fix the `Department.tax_rate` relationship. +- Let `MemberInfo.dates` be an object, not a list. + +## [0.1.17] - 2023-10-07 +### Changed +- Rename module to `corepos.db.office_arch`. + +## [0.1.16] - 2023-09-15 +### Changed +- Add model for `office_op.Tender`. + +## [0.1.15] - 2023-09-13 +### Changed +- Add model for `CustomerNotifications` table. + +## [0.1.14] - 2023-09-07 +### Changed +- Tweak primary key for StockPurchase model. + +## [0.1.13] - 2023-09-02 +### Changed +- Add models for StockPurchase and EquityLiveBalance. + +## [0.1.12] - 2023-06-12 +### Changed +- Add `get_member_types()` method for CORE API. +- Rename model for `custdata` to `CustomerClassic`. +- Add note about `meminfo.email_2` field, aka. "alt. phone". + +## [0.1.11] - 2023-06-02 +### Changed +- Add support for htdigest auth when using CORE webservices API. + +## [0.1.10] - 2023-05-17 +### Changed +- Replace `setup.py` contents with `setup.cfg`. + +## [0.1.9] - 2023-05-01 +### Changed +- Require SQLAlchemy 1.4.x. + +## [0.1.8] - 2023-01-02 +### Changed +- Add basic `TransactionDetail` for trans archive model. +- Delete `productUser` record when `products` record is deleted. + +## [0.1.7] - 2022-03-02 +### Changed +- Remove deprecation warning for `corepos.db`. +- Add model for `UserGroup`. + +## [0.1.6] - 2021-11-04 +### Changed +- Add `User` model for office_op. +- Add proper support for `str(Suspension)`. +- Add the `custdata` model for lane_op DB. + +## [0.1.5] - 2021-08-31 +### Changed +- Add lane_op model for Department. + ## [0.1.4] - 2021-08-02 ### Changed - Add schema for `TableSyncRules`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd69797 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ + +# pyCOREPOS + +A Python interface to the [CORE POS](https://github.com/CORE-POS) +system. diff --git a/README.rst b/README.rst deleted file mode 100644 index 0b34d2f..0000000 --- a/README.rst +++ /dev/null @@ -1,7 +0,0 @@ - -pyCOREPOS -========= - -A Python interface to the `CORE POS`_ system. - -.. _CORE POS: https://github.com/CORE-POS diff --git a/corepos/_version.py b/corepos/_version.py index 483f94f..555fee3 100644 --- a/corepos/_version.py +++ b/corepos/_version.py @@ -1,3 +1,6 @@ # -*- coding: utf-8; -*- -__version__ = '0.1.4' +from importlib.metadata import version + + +__version__ = version('pyCOREPOS') diff --git a/corepos/api.py b/corepos/api.py index e7310a0..a24b906 100644 --- a/corepos/api.py +++ b/corepos/api.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2021 Lance Edgar +# Copyright © 2018-2024 Lance Edgar # # This file is part of pyCOREPOS. # @@ -28,6 +28,7 @@ import json import logging import requests +from requests.auth import HTTPDigestAuth log = logging.getLogger(__name__) @@ -48,27 +49,42 @@ class CoreAPIError(Exception): class CoreWebAPI(object): """ Client implementation for the CORE webservices API. + + :param str url: URL to the CORE webservices API, + e.g. ``'http://localhost/fannie/ws/'`` + + :param bool verify: How to handle certificate validation for HTTPS + URLs. This value is passed as-is to the ``requests`` library, + so see those docs for more info. The default value for this is + ``True`` because the assumption is that security should be on + by default. Set it to ``False`` in order to disable validation + entirely, e.g. for self-signed certs. (This may also be needed + for basic HTTP URLs?) Other values may be possible also; again + see the ``requests`` docs for more info. + + :param htdigest_username: Username for htdigest authentication, if + applicable. + + :param htdigest_password: Password for htdigest authentication, if + applicable. """ - def __init__(self, url, verify=True): - """ - Constructor for the API client. - - :param str url: URL to the CORE webservices API, - e.g. ``'http://localhost/fannie/ws/'`` - - :param bool verify: How to handle certificate validation for HTTPS - URLs. This value is passed as-is to the ``requests`` library, so - see those docs for more info. The default value for this is - ``True`` because the assumption is that security should be on by - default. Set it to ``False`` in order to disable validation - entirely, e.g. for self-signed certs. (This may also be needed for - basic HTTP URLs?) Other values may be possible also; again see the - ``requests`` docs for more info. - """ + def __init__( + self, + url, + verify=True, + htdigest_username=None, + htdigest_password=None, + ): self.url = url self.verify = verify + self.session = requests.Session() + + if htdigest_username and htdigest_password: + self.session.auth = HTTPDigestAuth(htdigest_username, + htdigest_password) + def post(self, params, method=None): """ Issue a POST request to the API, with the given ``params``. If not @@ -88,8 +104,8 @@ class CoreWebAPI(object): 'id': 1, } - response = requests.post(self.url, data=json.dumps(payload), - verify=self.verify) + response = self.session.post(self.url, data=json.dumps(payload), + verify=self.verify) response.raise_for_status() return response @@ -116,6 +132,22 @@ class CoreWebAPI(object): assert set(js['result'].keys()) == set(['result']) return js['result']['result'] + def get_member_types(self): + """ + Fetch all Member Type records from CORE. + + :returns: A (potentially empty) list of member type dict records. + """ + params = { + 'entity': 'Memtype', + 'submethod': 'get', + 'columns': {}, + } + + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + def get_members(self): """ Fetch all Member records from CORE. @@ -166,6 +198,40 @@ class CoreWebAPI(object): if result: return result + def get_employees(self, **columns): + """ + Fetch some or all of Employee records from CORE. + + :returns: A (potentially empty) list of employee dict records. + """ + params = { + 'entity': 'Employees', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + + def get_employee(self, emp_no, **columns): + """ + Fetch an existing Employee record from CORE. + + :returns: Either a employee dict record, or ``None``. + """ + columns['emp_no'] = emp_no + params = { + 'entity': 'Employees', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + if result: + if len(result) > 1: + log.warning("CORE API returned %s employee results", len(result)) + return json.loads(result[0]) + def get_stores(self, **columns): """ Fetch some or all of Store records from CORE. diff --git a/corepos/db/__init__.py b/corepos/db/__init__.py index 174fee4..db5c837 100644 --- a/corepos/db/__init__.py +++ b/corepos/db/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2020 Lance Edgar +# Copyright © 2018-2021 Lance Edgar # # This file is part of pyCOREPOS. # @@ -23,12 +23,3 @@ """ Database Interface """ - -from __future__ import unicode_literals, absolute_import - -import warnings -warnings.warn("The `corepos.db` module is deprecated! " - "Please use `corepos.db.office_op` instead.", - DeprecationWarning) - -from corepos.db.office_op import * diff --git a/corepos/db/common/__init__.py b/corepos/db/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/corepos/db/common/op.py b/corepos/db/common/op.py new file mode 100644 index 0000000..30ecc1c --- /dev/null +++ b/corepos/db/common/op.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +Common schema for operational data models +""" + +import sqlalchemy as sa + + +class ParameterBase: + """ + Base class for Parameter models, shared by Office + Lane. + """ + store_id = sa.Column(sa.SmallInteger(), primary_key=True, nullable=False) + + lane_id = sa.Column(sa.SmallInteger(), primary_key=True, nullable=False) + + param_key = sa.Column(sa.String(length=100), primary_key=True, nullable=False) + + param_value = sa.Column(sa.String(length=255), nullable=True) + + is_array = sa.Column(sa.Boolean(), nullable=True) + + def __str__(self): + return f"{self.store_id}-{self.lane_id} {self.param_key}" + + +class EmployeeBase: + """ + Base class for Employee models, shared by Office + Lane. + """ + number = sa.Column('emp_no', sa.SmallInteger(), nullable=False, + primary_key=True, autoincrement=False) + + cashier_password = sa.Column('CashierPassword', sa.String(length=50), nullable=True) + + admin_password = sa.Column('AdminPassword', sa.String(length=50), nullable=True) + + first_name = sa.Column('FirstName', sa.String(length=255), nullable=True) + + last_name = sa.Column('LastName', sa.String(length=255), nullable=True) + + job_title = sa.Column('JobTitle', sa.String(length=255), nullable=True) + + active = sa.Column('EmpActive', sa.Boolean(), nullable=True) + + frontend_security = sa.Column('frontendsecurity', sa.SmallInteger(), nullable=True) + + backend_security = sa.Column('backendsecurity', sa.SmallInteger(), nullable=True) + + birth_date = sa.Column('birthdate', sa.DateTime(), nullable=True) + + def __str__(self): + return ' '.join([self.first_name or '', self.last_name or '']).strip() + + +class ProductBase: + """ + Base class for Product models, shared by Office + Lane. + """ + id = sa.Column(sa.Integer(), nullable=False, primary_key=True, autoincrement=True) + + upc = sa.Column(sa.String(length=13), nullable=True) + + description = sa.Column(sa.String(length=30), nullable=True) + + brand = sa.Column(sa.String(length=30), nullable=True) + + formatted_name = sa.Column(sa.String(length=30), nullable=True) + + normal_price = sa.Column(sa.Float(), nullable=True) + + price_method = sa.Column('pricemethod', sa.SmallInteger(), nullable=True) + + group_price = sa.Column('groupprice', sa.Float(), nullable=True) + + quantity = sa.Column(sa.SmallInteger(), nullable=True) + + special_price = sa.Column(sa.Float(), nullable=True) + + special_price_method = sa.Column('specialpricemethod', sa.SmallInteger(), nullable=True) + + special_group_price = sa.Column('specialgroupprice', sa.Float(), nullable=True) + + special_quantity = sa.Column('specialquantity', sa.SmallInteger(), nullable=True) + + special_limit = sa.Column(sa.SmallInteger(), nullable=True) + + start_date = sa.Column(sa.DateTime(), nullable=True) + + end_date = sa.Column(sa.DateTime(), nullable=True) + + department_number = sa.Column('department', sa.SmallInteger(), nullable=True) + + size = sa.Column(sa.String(length=9), nullable=True) + + tax_rate_id = sa.Column('tax', sa.SmallInteger(), nullable=True) + + foodstamp = sa.Column(sa.Boolean(), nullable=True) + + scale = sa.Column(sa.Boolean(), nullable=True) + + scale_price = sa.Column('scaleprice', sa.Float(), nullable=True) + + mix_match_code = sa.Column('mixmatchcode', sa.String(length=13), nullable=True) + + created = sa.Column(sa.DateTime(), nullable=True) + + modified = sa.Column(sa.DateTime(), nullable=True) + + tare_weight = sa.Column('tareweight', sa.Float(), nullable=True) + + discount = sa.Column(sa.SmallInteger(), nullable=True) + + discount_type = sa.Column('discounttype', sa.SmallInteger(), nullable=True) + + line_item_discountable = sa.Column(sa.Boolean(), nullable=True) + + unit_of_measure = sa.Column('unitofmeasure', sa.String(length=15), nullable=True) + + wicable = sa.Column(sa.SmallInteger(), nullable=True) + + quantity_enforced = sa.Column('qttyEnforced', sa.Boolean(), nullable=True) + + id_enforced = sa.Column('idEnforced', sa.SmallInteger(), nullable=True) + + cost = sa.Column(sa.Float(), nullable=True) + + special_cost = sa.Column(sa.Float(), nullable=True) + + received_cost = sa.Column(sa.Float(), nullable=True) + + in_use = sa.Column('inUse', sa.Boolean(), nullable=True) + + numflag = sa.Column(sa.Integer(), nullable=True) + + subdepartment_number = sa.Column('subdept', sa.SmallInteger(), nullable=True) + + deposit = sa.Column(sa.Float(), nullable=True) + + local = sa.Column(sa.Integer(), nullable=True, default=0) + + store_id = sa.Column(sa.SmallInteger(), nullable=True) + + default_vendor_id = sa.Column(sa.Integer(), nullable=True) + + current_origin_id = sa.Column(sa.Integer(), nullable=True) + + auto_par = sa.Column(sa.Float(), nullable=True, default=0) + + price_rule_id = sa.Column(sa.Integer(), nullable=True, default=0) + + last_sold = sa.Column(sa.DateTime(), nullable=True) diff --git a/corepos/db/common/trans.py b/corepos/db/common/trans.py new file mode 100644 index 0000000..9ec5601 --- /dev/null +++ b/corepos/db/common/trans.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +Common schema for transaction data models +""" + +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr + + +class TransactionDetailBase: + """ + Base class for POS transaction detail models, shared by Office + + Lane. + """ + + # register + register_no = sa.Column(sa.Integer(), nullable=True) + + # txn + trans_id = sa.Column(sa.Integer(), nullable=True) + trans_no = sa.Column(sa.Integer(), nullable=True) + trans_type = sa.Column(sa.String(length=1), nullable=True) + trans_subtype = sa.Column(sa.String(length=2), nullable=True) + trans_status = sa.Column(sa.String(length=1), nullable=True) + + # cashier + emp_no = sa.Column(sa.Integer(), nullable=True) + + # customer + card_no = sa.Column(sa.Integer(), nullable=True) + memType = sa.Column(sa.Integer(), nullable=True) + staff = sa.Column(sa.Boolean(), nullable=True) + + ############################## + # remainder is "line item" ... + ############################## + + upc = sa.Column(sa.String(length=13), nullable=True) + + department = sa.Column(sa.Integer(), nullable=True) + + description = sa.Column(sa.String(length=30), nullable=True) + + quantity = sa.Column(sa.Float(), nullable=True) + + scale = sa.Column(sa.Boolean(), nullable=True, default=False) + + cost = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + unitPrice = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + total = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + regPrice = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + tax = sa.Column(sa.SmallInteger(), nullable=True) + + foodstamp = sa.Column(sa.Boolean(), nullable=True) + + discount = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + memDiscount = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + discountable = sa.Column(sa.Boolean(), nullable=True) + + discounttype = sa.Column(sa.Integer(), nullable=True) + + voided = sa.Column(sa.Integer(), nullable=True) + + percentDiscount = sa.Column(sa.Integer(), nullable=True) + + ItemQtty = sa.Column(sa.Float(), nullable=True) + + volDiscType = sa.Column(sa.Integer(), nullable=True) + + volume = sa.Column(sa.Integer(), nullable=True) + + VolSpecial = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + mixMatch = sa.Column(sa.String(length=13), nullable=True) + + matched = sa.Column(sa.Boolean(), nullable=True) + + numflag = sa.Column(sa.Integer(), nullable=True, default=0) + + charflag = sa.Column(sa.String(length=2), nullable=True) + + def __str__(self): + txnid = '-'.join([str(val) for val in [self.register_no, + self.trans_no, + self.trans_id]]) + return f"{txnid} {self.description or ''}" diff --git a/corepos/db/lane_op/model.py b/corepos/db/lane_op/model.py index 2faa87b..456b1b8 100644 --- a/corepos/db/lane_op/model.py +++ b/corepos/db/lane_op/model.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2021 Lance Edgar +# Copyright © 2018-2025 Lance Edgar # # This file is part of pyCOREPOS. # @@ -25,145 +25,143 @@ Data model for CORE POS "lane_op" DB """ import sqlalchemy as sa -# from sqlalchemy import orm -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import orm + +from corepos.db.common import op as common -Base = declarative_base() +Base = orm.declarative_base() -class Product(Base): +class Parameter(common.ParameterBase, Base): """ - Represents a product, purchased and/or sold by the organization. + Data model for ``parameters`` table. """ - __tablename__ = 'products' - # __table_args__ = ( - # sa.ForeignKeyConstraint(['department'], ['departments.dept_no']), - # sa.ForeignKeyConstraint(['subdept'], ['subdepts.subdept_no']), - # sa.ForeignKeyConstraint(['tax'], ['taxrates.id']), - # ) + __tablename__ = 'parameters' - id = sa.Column(sa.Integer(), nullable=False, - primary_key=True, autoincrement=True) - upc = sa.Column(sa.String(length=13), nullable=True) +class Employee(common.EmployeeBase, Base): + """ + Data model for ``employees`` table. + """ + __tablename__ = 'employees' - description = sa.Column(sa.String(length=30), nullable=True) - brand = sa.Column(sa.String(length=30), nullable=True) +class Department(Base): + """ + Represents a department within the organization. + """ + __tablename__ = 'departments' - formatted_name = sa.Column(sa.String(length=30), nullable=True) + number = sa.Column('dept_no', sa.SmallInteger(), nullable=False, + primary_key=True, autoincrement=False) - normal_price = sa.Column(sa.Float(), nullable=True) + name = sa.Column('dept_name', sa.String(length=30), nullable=True) - price_method = sa.Column('pricemethod', sa.SmallInteger(), nullable=True) + tax = sa.Column('dept_tax', sa.Boolean(), nullable=True) - group_price = sa.Column('groupprice', sa.Float(), nullable=True) + food_stampable = sa.Column('dept_fs', sa.Boolean(), nullable=True) - quantity = sa.Column(sa.SmallInteger(), nullable=True) + limit = sa.Column('dept_limit', sa.Float(), nullable=True) - special_price = sa.Column(sa.Float(), nullable=True) + minimum = sa.Column('dept_minimum', sa.Float(), nullable=True) - special_price_method = sa.Column('specialpricemethod', sa.SmallInteger(), nullable=True) + discount = sa.Column('dept_discount', sa.Boolean(), nullable=True) - special_group_price = sa.Column('specialgroupprice', sa.Float(), nullable=True) - - special_quantity = sa.Column('specialquantity', sa.SmallInteger(), nullable=True) - - special_limit = sa.Column(sa.SmallInteger(), nullable=True) - - start_date = sa.Column(sa.DateTime(), nullable=True) - - end_date = sa.Column(sa.DateTime(), nullable=True) - - department_number = sa.Column('department', sa.SmallInteger(), nullable=True) - # department = orm.relationship( - # Department, - # primaryjoin=Department.number == department_number, - # foreign_keys=[department_number], - # doc=""" - # Reference to the :class:`Department` to which the product belongs. - # """) - - size = sa.Column(sa.String(length=9), nullable=True) - - tax_rate_id = sa.Column('tax', sa.SmallInteger(), nullable=True) - # tax_rate = orm.relationship(TaxRate) - - foodstamp = sa.Column(sa.Boolean(), nullable=True) - - scale = sa.Column(sa.Boolean(), nullable=True) - - scale_price = sa.Column('scaleprice', sa.Float(), nullable=True) - - mix_match_code = sa.Column('mixmatchcode', sa.String(length=13), nullable=True) - - created = sa.Column(sa.DateTime(), nullable=True) + see_id = sa.Column('dept_see_id', sa.SmallInteger(), nullable=True) modified = sa.Column(sa.DateTime(), nullable=True) - # TODO: what to do about this 'replaces' thing? - # 'batchID'=>array('type'=>'TINYINT', 'replaces'=>'advertised'), - # batch_id = sa.Column('batchID', sa.SmallInteger(), nullable=True) - # advertised = sa.Column(sa.Boolean(), nullable=True) + modified_by_id = sa.Column('modifiedby', sa.Integer(), nullable=True) - tare_weight = sa.Column('tareweight', sa.Float(), nullable=True) + margin = sa.Column(sa.Float(), nullable=False) - discount = sa.Column(sa.SmallInteger(), nullable=True) + sales_code = sa.Column('salesCode', sa.Integer(), nullable=False) - discount_type = sa.Column('discounttype', sa.SmallInteger(), nullable=True) + member_only = sa.Column('memberOnly', sa.SmallInteger(), nullable=False) - line_item_discountable = sa.Column(sa.Boolean(), nullable=True) + line_item_discount = sa.Column(sa.Boolean(), nullable=True) - unit_of_measure = sa.Column('unitofmeasure', sa.String(length=15), nullable=True) + wicable = sa.Column('dept_wicable', sa.Boolean(), nullable=True) - wicable = sa.Column(sa.SmallInteger(), nullable=True) + def __str__(self): + return self.name or "" - quantity_enforced = sa.Column('qttyEnforced', sa.Boolean(), nullable=True) - id_enforced = sa.Column('idEnforced', sa.SmallInteger(), nullable=True) +class Product(common.ProductBase, Base): + """ + Data model for ``products`` table. + """ + __tablename__ = 'products' - cost = sa.Column(sa.Float(), nullable=True) - special_cost = sa.Column(sa.Float(), nullable=True) +class CustomerClassic(Base): + """ + Represents a customer of the organization. - received_cost = sa.Column(sa.Float(), nullable=True) + https://github.com/CORE-POS/IS4C/blob/master/pos/is4c-nf/lib/models/op/CustdataModel.php + """ + __tablename__ = 'custdata' + # __table_args__ = ( + # sa.ForeignKeyConstraint(['memType'], ['memtype.memtype']), + # ) - in_use = sa.Column('inUse', sa.Boolean(), nullable=True) + id = sa.Column(sa.Integer(), nullable=False, primary_key=True, autoincrement=True) - flags = sa.Column('numflag', sa.Integer(), nullable=True) + card_number = sa.Column('CardNo', sa.Integer(), nullable=True) - subdepartment_number = sa.Column('subdept', sa.SmallInteger(), nullable=True) - # subdepartment = orm.relationship( - # Subdepartment, - # primaryjoin=Subdepartment.number == subdepartment_number, - # foreign_keys=[subdepartment_number], + person_number = sa.Column('personNum', sa.SmallInteger(), nullable=True) + + first_name = sa.Column('FirstName', sa.String(length=30), nullable=True) + + last_name = sa.Column('LastName', sa.String(length=30), nullable=True) + + cash_back = sa.Column('CashBack', sa.Numeric(precision=10, scale=2), nullable=True) + + balance = sa.Column('Balance', sa.Numeric(precision=10, scale=2), nullable=True) + + discount = sa.Column('Discount', sa.SmallInteger(), nullable=True) + + member_discount_limit = sa.Column('MemDiscountLimit', sa.Numeric(precision=10, scale=2), nullable=True) + + charge_limit = sa.Column('ChargeLimit', sa.Numeric(precision=10, scale=2), nullable=True) + + charge_ok = sa.Column('ChargeOk', sa.Boolean(), nullable=True, default=True) + + write_checks = sa.Column('WriteChecks', sa.Boolean(), nullable=True, default=True) + + store_coupons = sa.Column('StoreCoupons', sa.Boolean(), nullable=True, default=True) + + type = sa.Column('Type', sa.String(length=10), nullable=True, default='PC') + + member_type_id = sa.Column('memType', sa.SmallInteger(), nullable=True) + # member_type = orm.relationship( + # MemberType, + # primaryjoin=MemberType.id == member_type_id, + # foreign_keys=[member_type_id], # doc=""" - # Reference to the :class:`Subdepartment` to which the product belongs. + # Reference to the :class:`MemberType` to which this member belongs. # """) - deposit = sa.Column(sa.Float(), nullable=True) + staff = sa.Column(sa.Boolean(), nullable=True, default=False) - local = sa.Column(sa.Integer(), nullable=True, - default=0) # TODO: do we want a default here? + ssi = sa.Column('SSI', sa.Boolean(), nullable=True, default=False) - store_id = sa.Column(sa.SmallInteger(), nullable=True) + purchases = sa.Column('Purchases', sa.Numeric(precision=10, scale=2), nullable=True, default=0) - default_vendor_id = sa.Column(sa.Integer(), nullable=True) - # default_vendor = orm.relationship( - # Vendor, - # primaryjoin=Vendor.id == default_vendor_id, - # foreign_keys=[default_vendor_id], - # doc=""" - # Reference to the default :class:`Vendor` from which the product is obtained. - # """) + number_of_checks = sa.Column('NumberOfChecks', sa.SmallInteger(), nullable=True, default=0) - current_origin_id = sa.Column(sa.Integer(), nullable=True) + member_coupons = sa.Column('memCoupons', sa.Integer(), nullable=True, default=1) - auto_par = sa.Column(sa.Float(), nullable=True, - default=0) # TODO: do we want a default here? + blue_line = sa.Column('blueLine', sa.String(length=50), nullable=True) - price_rule_id = sa.Column(sa.Integer(), nullable=True) + shown = sa.Column('Shown', sa.Boolean(), nullable=True, default=True) - # TODO: some older DB's might not have this? guess we'll see - last_sold = sa.Column(sa.DateTime(), nullable=True) + last_change = sa.Column('LastChange', sa.DateTime(), nullable=True) + + def __str__(self): + return "{} {}".format(self.first_name or '', self.last_name or '').strip() + + +# TODO: deprecate / remove this +CustData = CustomerClassic diff --git a/corepos/db/lane_trans/__init__.py b/corepos/db/lane_trans/__init__.py new file mode 100644 index 0000000..8e8c706 --- /dev/null +++ b/corepos/db/lane_trans/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +Lane Transaction Database +""" + +from sqlalchemy import orm + + +Session = orm.sessionmaker() diff --git a/corepos/db/lane_trans/model.py b/corepos/db/lane_trans/model.py new file mode 100644 index 0000000..f2245f5 --- /dev/null +++ b/corepos/db/lane_trans/model.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +Data model for CORE POS "lane_trans" DB +""" + +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr + +from corepos.db.common import trans as common + + +Base = orm.declarative_base() + + +class DTransactionBase(common.TransactionDetailBase): + """ + Base class for ``dtransactions`` and similar models. + """ + pos_row_id = sa.Column(sa.Integer(), primary_key=True, nullable=False) + + store_id = sa.Column(sa.Integer(), nullable=True, default=0) + date_time = sa.Column('datetime', sa.DateTime(), nullable=True) + + +class DTransaction(DTransactionBase, Base): + """ + Data model for ``dtransactions`` table. + """ + __tablename__ = 'dtransactions' + + +class LocalTransBase(common.TransactionDetailBase): + """ + Base class for ``localtrans`` and similar models. + """ + + @declared_attr + def __table_args__(self): + return ( + sa.PrimaryKeyConstraint('trans_id'), + ) + + date_time = sa.Column('datetime', sa.DateTime(), nullable=True) + + +class LocalTrans(LocalTransBase, Base): + """ + Data model for ``localtrans`` table. + """ + __tablename__ = 'localtrans' + + +class LocalTempTrans(LocalTransBase, Base): + """ + Data model for ``localtemptrans`` table. + """ + __tablename__ = 'localtemptrans' diff --git a/corepos/db/office_arch/__init__.py b/corepos/db/office_arch/__init__.py new file mode 100644 index 0000000..70292e9 --- /dev/null +++ b/corepos/db/office_arch/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2023 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +"Archive" Transaction Database Interface +""" + +from sqlalchemy import orm + + +Session = orm.sessionmaker() diff --git a/corepos/db/office_arch/model.py b/corepos/db/office_arch/model.py new file mode 100644 index 0000000..bc5838f --- /dev/null +++ b/corepos/db/office_arch/model.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +CORE Office "arch" data model +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from corepos.db.common import trans as common +from corepos.db.office_trans.model import DTransactionBase + + +Base = orm.declarative_base() + + +class BigArchive(DTransactionBase, Base): + """ + Data model for ``bigArchive`` table. + """ + __tablename__ = 'bigArchive' + + +# TODO: deprecate / remove this +TransactionDetail = BigArchive + + +class DLogBase(common.TransactionDetailBase): + """ + Base class for ``dlogBig`` and similar models. + """ + store_row_id = sa.Column(sa.Integer(), primary_key=True, nullable=False) + + store_id = sa.Column(sa.Integer(), nullable=True, default=0) + pos_row_id = sa.Column(sa.Integer(), nullable=True) + date_time = sa.Column('tdate', sa.DateTime(), nullable=True) + + +class DLogBig(DLogBase, Base): + """ + Data model for ``dlogBig`` view. + """ + __tablename__ = 'dlogBig' diff --git a/corepos/db/office_op/model.py b/corepos/db/office_op/model.py index 182f3fe..60ad478 100644 --- a/corepos/db/office_op/model.py +++ b/corepos/db/office_op/model.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2021 Lance Edgar +# Copyright © 2018-2025 Lance Edgar # # This file is part of pyCOREPOS. # @@ -29,13 +29,14 @@ import logging import sqlalchemy as sa from sqlalchemy import orm -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.associationproxy import association_proxy +from corepos.db.common import op as common + log = logging.getLogger(__name__) -Base = declarative_base() +Base = orm.declarative_base() class StringableDateTime(sa.TypeDecorator): @@ -55,42 +56,12 @@ class StringableDateTime(sa.TypeDecorator): raise NotImplementedError -class Change(Base): +class Parameter(common.ParameterBase, Base): """ - Represents a changed (or deleted) record, which is pending synchronization - to another system(s). - - .. note:: - This table may or may not be installed to a given CORE Office Op DB. Its - presence is required if Rattail datasync needs to "watch" the DB. - """ - __tablename__ = 'datasync_changes' - - id = sa.Column(sa.Integer(), nullable=False, primary_key=True) - object_type = sa.Column(sa.String(length=255), nullable=False) - object_key = sa.Column(sa.String(length=255), nullable=False) - deleted = sa.Column(sa.Boolean(), nullable=False, default=False) - - -class Parameter(Base): - """ - Represents a "parameter" value. + Data model for ``parameters`` table. """ __tablename__ = 'parameters' - store_id = sa.Column(sa.SmallInteger(), primary_key=True, nullable=False) - - lane_id = sa.Column(sa.SmallInteger(), primary_key=True, nullable=False) - - param_key = sa.Column(sa.String(length=100), primary_key=True, nullable=False) - - param_value = sa.Column(sa.String(length=255), nullable=True) - - is_array = sa.Column(sa.Boolean(), nullable=True) - - def __str__(self): - return "{}-{} {}".format(self.store_id, self.lane_id, self.param_key) - class TableSyncRule(Base): """ @@ -98,7 +69,9 @@ class TableSyncRule(Base): """ __tablename__ = 'TableSyncRules' - id = sa.Column('tableSyncRuleID', sa.Integer(), nullable=False, primary_key=True, autoincrement=True) + # nb. this should be autoincrement, but we can't do that + # automatically via sqlalchemy when PK is composite + id = sa.Column('tableSyncRuleID', sa.Integer(), nullable=False, primary_key=True) table_name = sa.Column('tableName', sa.String(length=255), nullable=False, primary_key=True) @@ -108,6 +81,50 @@ class TableSyncRule(Base): return "{}: {}".format(self.table_name, self.rule) +class UserGroup(Base): + """ + Represents a user/group assignment. + """ + __tablename__ = 'userGroups' + + group_id = sa.Column('gid', sa.Integer(), nullable=False, + primary_key=True, autoincrement=False) + + name = sa.Column(sa.String(length=50), nullable=True) + + username = sa.Column(sa.String(length=50), nullable=False, + primary_key=True) + + def __str__(self): + return self.name or "" + + +class User(Base): + """ + Represents a user within CORE Office. + """ + __tablename__ = 'Users' + + name = sa.Column(sa.String(length=50), nullable=False, primary_key=True) + + password = sa.Column(sa.String(length=255), nullable=True) + + salt = sa.Column(sa.String(length=10), nullable=True) + + uid = sa.Column(sa.String(length=4), nullable=True) + + session_id = sa.Column(sa.String(length=50), nullable=True) + + real_name = sa.Column(sa.String(length=75), nullable=True) + + email = sa.Column(sa.String(length=75), nullable=True) + + totp_url = sa.Column('totpURL', sa.String(length=255), nullable=True) + + def __str__(self): + return self.name or "" + + class Store(Base): """ Represents a known store. @@ -143,6 +160,25 @@ class Store(Base): return self.description or "" +class MasterSuperDepartment(Base): + """ + A department may belong to more than one superdepartment, but has + one "master" superdepartment. This avoids duplicating rows in + some reports. By convention, a department's "master" + superdepartment is the one with the lowest superID. + """ + __tablename__ = 'MasterSuperDepts' + + super_id = sa.Column('superID', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + + department_id = sa.Column('dept_ID', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + + super_name = sa.Column(sa.String(length=50), nullable=True) + + def __str__(self): + return self.super_name or "" + + class SuperDepartment(Base): """ Represents a "super" (parent/child) department mapping. @@ -182,15 +218,25 @@ class Department(Base): Represents a department within the organization. """ __tablename__ = 'departments' + __table_args__ = ( + sa.ForeignKeyConstraint(['dept_tax'], ['taxrates.id']), + ) number = sa.Column('dept_no', sa.SmallInteger(), primary_key=True, autoincrement=False, nullable=False) name = sa.Column('dept_name', sa.String(length=30), nullable=True) - tax = sa.Column('dept_tax', sa.Boolean(), nullable=True) + tax_rate_id = sa.Column('dept_tax', sa.SmallInteger(), nullable=True) + tax_rate = orm.relationship('TaxRate') + # TODO: deprecate / remove this + tax = orm.synonym('tax_rate_id') food_stampable = sa.Column('dept_fs', sa.Boolean(), nullable=True) + wicable = sa.Column('dept_wicable', sa.SmallInteger(), nullable=True) + + active = sa.Column(sa.Boolean(), default=True) + limit = sa.Column('dept_limit', sa.Float(), nullable=True) minimum = sa.Column('dept_minimum', sa.Float(), nullable=True) @@ -490,7 +536,7 @@ class Origin(Base): return self.name or self.short_name or "" -class Product(Base): +class Product(common.ProductBase, Base): """ Represents a product, purchased and/or sold by the organization. """ @@ -501,125 +547,68 @@ class Product(Base): sa.ForeignKeyConstraint(['tax'], ['taxrates.id']), ) - id = sa.Column(sa.Integer(), primary_key=True, autoincrement=True, nullable=False) - - upc = sa.Column(sa.String(length=13), nullable=True) - - description = sa.Column(sa.String(length=30), nullable=True) - - brand = sa.Column(sa.String(length=30), nullable=True) - - formatted_name = sa.Column(sa.String(length=30), nullable=True) - - normal_price = sa.Column(sa.Float(), nullable=True) - - price_method = sa.Column('pricemethod', sa.SmallInteger(), nullable=True) - - group_price = sa.Column('groupprice', sa.Float(), nullable=True) - - quantity = sa.Column(sa.SmallInteger(), nullable=True) - - special_price = sa.Column(sa.Float(), nullable=True) - - special_price_method = sa.Column('specialpricemethod', sa.SmallInteger(), nullable=True) - - special_group_price = sa.Column('specialgroupprice', sa.Float(), nullable=True) - - special_quantity = sa.Column('specialquantity', sa.SmallInteger(), nullable=True) - - start_date = sa.Column(sa.DateTime(), nullable=True) - - end_date = sa.Column(sa.DateTime(), nullable=True) - - department_number = sa.Column('department', sa.SmallInteger(), nullable=True) department = orm.relationship( Department, - primaryjoin=Department.number == department_number, - foreign_keys=[department_number], + primaryjoin='Department.number == Product.department_number', + foreign_keys='Product.department_number', doc=""" Reference to the :class:`Department` to which the product belongs. """) - size = sa.Column(sa.String(length=9), nullable=True) - - tax_rate_id = sa.Column('tax', sa.SmallInteger(), nullable=True) tax_rate = orm.relationship(TaxRate) - foodstamp = sa.Column(sa.Boolean(), nullable=True) - - scale = sa.Column(sa.Boolean(), nullable=True) - - # TODO: yikes, did i just code this all wrong the first time? pretty sure - # this needs to change to a decimal column... - scale_price = sa.Column('scaleprice', sa.Boolean(), nullable=True) - # scale_price = sa.Column('scaleprice', sa.Numeric(precision=10, scale=2), nullable=True) - - mix_match_code = sa.Column('mixmatchcode', sa.String(length=13), nullable=True) - - created = sa.Column(StringableDateTime(), nullable=True) - - modified = sa.Column(sa.DateTime(), nullable=True) - # advertised = sa.Column(sa.Boolean(), nullable=True) - tare_weight = sa.Column('tareweight', sa.Float(), nullable=True) - - discount = sa.Column(sa.SmallInteger(), nullable=True) - - discount_type = sa.Column('discounttype', sa.SmallInteger(), nullable=True) - - line_item_discountable = sa.Column(sa.Boolean(), nullable=True) - - unit_of_measure = sa.Column('unitofmeasure', sa.String(length=15), nullable=True) - - wicable = sa.Column(sa.SmallInteger(), nullable=True) - - quantity_enforced = sa.Column('qttyEnforced', sa.Boolean(), nullable=True) - - id_enforced = sa.Column('idEnforced', sa.SmallInteger(), nullable=True) - - cost = sa.Column(sa.Float(), nullable=True) - - in_use = sa.Column('inUse', sa.Boolean(), nullable=True) - - flags = sa.Column('numflag', sa.Integer(), nullable=True) - - subdepartment_number = sa.Column('subdept', sa.SmallInteger(), nullable=True) subdepartment = orm.relationship( Subdepartment, - primaryjoin=Subdepartment.number == subdepartment_number, - foreign_keys=[subdepartment_number], + primaryjoin='Subdepartment.number == Product.subdepartment_number', + foreign_keys='Product.subdepartment_number', doc=""" Reference to the :class:`Subdepartment` to which the product belongs. """) - deposit = sa.Column(sa.Float(), nullable=True) - - local = sa.Column(sa.Integer(), nullable=True) - - store_id = sa.Column(sa.SmallInteger(), nullable=True) - - default_vendor_id = sa.Column(sa.Integer(), nullable=True) default_vendor = orm.relationship( Vendor, - primaryjoin=Vendor.id == default_vendor_id, - foreign_keys=[default_vendor_id], + primaryjoin='Vendor.id == Product.default_vendor_id', + foreign_keys='Product.default_vendor_id', doc=""" Reference to the default :class:`Vendor` from which the product is obtained. """) + # TODO: deprecate / remove this? vendor = orm.synonym('default_vendor') - current_origin_id = sa.Column(sa.Integer(), nullable=True) - - # TODO: some older DB's might not have this? guess we'll see - last_sold = sa.Column(sa.DateTime(), nullable=True) - like_code = association_proxy( '_like_code', 'like_code', creator=lambda lc: ProductLikeCode(like_code=lc), ) + vendor_items = orm.relationship( + 'VendorItem', + back_populates='product', + primaryjoin='VendorItem.upc == Product.upc', + foreign_keys='VendorItem.upc', + order_by='VendorItem.vendor_item_id', + doc=""" + List of :class:`VendorItem` records for this product. + """) + + @property + def default_vendor_item(self): + """ + Returns the "default" vendor item record. This will + correspond to the :attr:`default_vendor` if possible. + + :rtype: :class:`VendorItem` or ``None`` + """ + if self.default_vendor: + for item in self.vendor_items: + if item.vendor_id == self.default_vendor.id: + return item + + if self.vendor_items: + return self.vendor_items[0] + @property def full_description(self): fields = ['brand', 'description', 'size'] @@ -713,6 +702,7 @@ class ProductUser(Base): backref=orm.backref( 'user_info', uselist=False, + cascade='all, delete-orphan', doc=""" Reference to the :class:`ProductUser` record for this product, if any. """)) @@ -768,17 +758,12 @@ class VendorItem(Base): upc = sa.Column(sa.String(length=13), nullable=False) product = orm.relationship( Product, + back_populates='vendor_items', primaryjoin=Product.upc == upc, foreign_keys=[upc], doc=""" Reference to the :class:`Product` to which this record applies. - """, - backref=orm.backref( - 'vendor_items', - order_by=vendor_item_id, - doc=""" - List of :class:`VendorItem` records for this product. - """)) + """) brand = sa.Column(sa.String(length=50), nullable=True) @@ -933,35 +918,12 @@ class ProductPhysicalLocation(Base): location = sa.Column(sa.SmallInteger(), nullable=True, default=0) -class Employee(Base): +class Employee(common.EmployeeBase, Base): """ - Represents an employee within the organization. + Data model for ``employees`` table. """ __tablename__ = 'employees' - number = sa.Column('emp_no', sa.SmallInteger(), primary_key=True, autoincrement=False, nullable=False) - - cashier_password = sa.Column('CashierPassword', sa.String(length=50), nullable=True) - - admin_password = sa.Column('AdminPassword', sa.String(length=50), nullable=True) - - first_name = sa.Column('FirstName', sa.String(length=255), nullable=True) - - last_name = sa.Column('LastName', sa.String(length=255), nullable=True) - - job_title = sa.Column('JobTitle', sa.String(length=255), nullable=True) - - active = sa.Column('EmpActive', sa.Boolean(), nullable=True) - - frontend_security = sa.Column('frontendsecurity', sa.SmallInteger(), nullable=True) - - backend_security = sa.Column('backendsecurity', sa.SmallInteger(), nullable=True) - - birth_date = sa.Column('birthdate', sa.DateTime(), nullable=True) - - def __str__(self): - return ' '.join([self.first_name or '', self.last_name or '']).strip() - class MemberType(Base): """ @@ -981,9 +943,11 @@ class MemberType(Base): ssi = sa.Column(sa.Boolean(), nullable=True) - # TODO: this was apparently added "recently" - isn't present in all DBs - # (need to figure out how to conditionally include it in model?) - # sales_code = sa.Column('salesCode', sa.Integer(), nullable=True) + # nb. this must be added explicitly if DB is new enough + #ignore_sales = sa.Column('ignoreSales', sa.Boolean(), nullable=True, default=False) + + # nb. this must be added explicitly if DB is new enough + #sales_code = sa.Column('salesCode', sa.Integer(), nullable=True) def __str__(self): return self.description or "" @@ -1109,7 +1073,7 @@ class Customer(Base): return "{} {}".format(self.first_name or '', self.last_name or '').strip() -class CustData(Base): +class CustomerClassic(Base): """ Represents a customer of the organization. @@ -1130,15 +1094,15 @@ class CustData(Base): last_name = sa.Column('LastName', sa.String(length=30), nullable=True) - cash_back = sa.Column('CashBack', sa.Float(), nullable=False, default=60) + cash_back = sa.Column('CashBack', sa.Numeric(precision=10, scale=2), nullable=False, default=60) - balance = sa.Column('Balance', sa.Float(), nullable=False, default=0) + balance = sa.Column('Balance', sa.Numeric(precision=10, scale=2), nullable=False, default=0) discount = sa.Column('Discount', sa.SmallInteger(), nullable=True) - member_discount_limit = sa.Column('MemDiscountLimit', sa.Float(), nullable=False, default=0) + member_discount_limit = sa.Column('MemDiscountLimit', sa.Numeric(precision=10, scale=2), nullable=False, default=0) - charge_limit = sa.Column('ChargeLimit', sa.Float(), nullable=False, default=0) + charge_limit = sa.Column('ChargeLimit', sa.Numeric(precision=10, scale=2), nullable=False, default=0) charge_ok = sa.Column('ChargeOk', sa.Boolean(), nullable=False, default=False) @@ -1161,7 +1125,7 @@ class CustData(Base): ssi = sa.Column('SSI', sa.Boolean(), nullable=False, default=False) - purchases = sa.Column('Purchases', sa.Float(), nullable=False, default=0) + purchases = sa.Column('Purchases', sa.Numeric(precision=10, scale=2), nullable=False, default=0) number_of_checks = sa.Column('NumberOfChecks', sa.SmallInteger(), nullable=False, default=0) @@ -1175,7 +1139,7 @@ class CustData(Base): member_info = orm.relationship( 'MemberInfo', - primaryjoin='MemberInfo.card_number == CustData.card_number', + primaryjoin='MemberInfo.card_number == CustomerClassic.card_number', foreign_keys=[card_number], uselist=False, back_populates='customers', @@ -1187,6 +1151,10 @@ class CustData(Base): return "{} {}".format(self.first_name or '', self.last_name or '').strip() +# TODO: deprecate / remove this +CustData = CustomerClassic + + class MemberInfo(Base): """ Contact info regarding a member of the organization. @@ -1195,14 +1163,14 @@ class MemberInfo(Base): card_number = sa.Column('card_no', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) - last_name = sa.Column(sa.String(length=30), nullable=True) - first_name = sa.Column(sa.String(length=30), nullable=True) - other_last_name = sa.Column('othlast_name', sa.String(length=30), nullable=True) + last_name = sa.Column(sa.String(length=30), nullable=True) other_first_name = sa.Column('othfirst_name', sa.String(length=30), nullable=True) + other_last_name = sa.Column('othlast_name', sa.String(length=30), nullable=True) + street = sa.Column(sa.String(length=255), nullable=True) city = sa.Column(sa.String(length=20), nullable=True) @@ -1215,19 +1183,22 @@ class MemberInfo(Base): email = sa.Column('email_1', sa.String(length=50), nullable=True) - email2 = sa.Column('email_2', sa.String(length=50), nullable=True) + email2 = sa.Column('email_2', sa.String(length=50), nullable=True, doc=""" + NB. this is labeled "Alt. Phone" in CORE Office member view, and + is named `altPhone` when dealing with CORE Office webservices API. + """) ads_ok = sa.Column('ads_OK', sa.Boolean(), nullable=True, default=True) customers = orm.relationship( - CustData, - primaryjoin=CustData.card_number == card_number, - order_by=CustData.person_number, - foreign_keys=[CustData.card_number], + CustomerClassic, + primaryjoin=CustomerClassic.card_number == card_number, + order_by=CustomerClassic.person_number, + foreign_keys=[CustomerClassic.card_number], back_populates='member_info', - remote_side=CustData.card_number, + remote_side=CustomerClassic.card_number, doc=""" - List of :class:`CustData` instances which are associated with this member info. + List of :class:`CustomerClassic` instances which are associated with this member info. """) dates = orm.relationship( @@ -1235,6 +1206,7 @@ class MemberInfo(Base): primaryjoin='MemberDate.card_number == MemberInfo.card_number', foreign_keys='MemberDate.card_number', cascade='all, delete-orphan', + uselist=False, doc=""" List of date records for the member. """, @@ -1337,7 +1309,7 @@ class MemberDate(Base): class MemberContact(Base): """ - Contact preferences for members + Member contacts """ __tablename__ = 'memContact' @@ -1364,6 +1336,19 @@ class MemberContact(Base): return str(self.preference) +class MemberContactPreference(Base): + """ + Member contact preferences + """ + __tablename__ = 'memContactPrefs' + + id = sa.Column('pref_id', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + description = sa.Column('pref_description', sa.String(length=50), nullable=True) + + def __str__(self): + return self.description or "" + + class MemberBarcode(Base): """ Additional barcode for a member. @@ -1399,6 +1384,26 @@ class MemberNote(Base): return self.note or "" +class CustomerNotification(Base): + """ + Represents a customer notification for display at the lane. + + https://github.com/CORE-POS/IS4C/blob/master/fannie/classlib2.0/data/models/op/CustomerNotificationsModel.php + """ + __tablename__ = 'CustomerNotifications' + + id = sa.Column('customerNotificationID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + card_number = sa.Column('cardNo', sa.Integer(), nullable=True) + customer_id = sa.Column('customerID', sa.Integer(), nullable=True) + source = sa.Column(sa.String(length=50), nullable=True) + type = sa.Column(sa.String(length=50), nullable=True) + message = sa.Column(sa.String(length=255), nullable=True) + modifier_module = sa.Column('modifierModule', sa.String(length=50), nullable=True) + + def __str__(self): + return self.message or '' + + class ReasonCode(Base): """ Reason codes for legacy account suspensions. @@ -1443,6 +1448,10 @@ class Suspension(Base): reason_code = sa.Column('reasoncode', sa.Integer(), nullable=True) reason_object = orm.relationship(ReasonCode) + def __str__(self): + return "#{} on {}".format(self.card_number, + self.suspension_date) + class HouseCoupon(Base): """ @@ -1485,13 +1494,54 @@ class HouseCoupon(Base): return self.description or '' +class Tender(Base): + """ + Represents a tender for payment at POS + """ + __tablename__ = 'tenders' + + tender_id = sa.Column('TenderID', sa.Integer(), primary_key=True, nullable=False) + + tender_code = sa.Column('TenderCode', sa.String(length=2), nullable=True) + tender_name = sa.Column('TenderName', sa.String(length=25), nullable=True) + tender_type = sa.Column('TenderType', sa.String(length=2), nullable=True) + change_message = sa.Column('ChangeMessage', sa.String(length=25), nullable=True) + min_amount = sa.Column('MinAmount', sa.Numeric(precision=10, scale=2), nullable=True) + max_amount = sa.Column('MaxAmount', sa.Numeric(precision=10, scale=2), nullable=True) + max_refund = sa.Column('MaxRefund', sa.Numeric(precision=10, scale=2), nullable=True) + tender_module = sa.Column('TenderModule', sa.String(length=50), nullable=True) + sales_code = sa.Column('SalesCode', sa.Integer(), nullable=True) + + def __str__(self): + return self.tender_name or '' + + +class CustomReceiptLine(Base): + """ + Represents a "text string" line for a custom receipt. + """ + __tablename__ = 'customReceipt' + + # nb. this should be autoincrement, but we can't do that + # automatically via sqlalchemy when PK is composite + sequence = sa.Column('seq', sa.Integer(), primary_key=True, nullable=False) + + type = sa.Column(sa.String(length=20), primary_key=True, nullable=False) + text = sa.Column(sa.String(length=80), nullable=True) + + def __str__(self): + return self.text or "" + + class BatchType(Base): """ Represents the definition of a batch type. """ __tablename__ = 'batchType' - id = sa.Column('batchTypeID', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + # nb. this is *not* autoincrement for some reason; must + # calculate new ID manually based on max existing + id = sa.Column('batchTypeID', sa.Integer(), nullable=False, primary_key=True, autoincrement=False) description = sa.Column('typeDesc', sa.String(length=50), nullable=True) @@ -1501,7 +1551,7 @@ class BatchType(Base): special_order_eligible = sa.Column('specialOrderEligible', sa.Boolean(), nullable=True, default=True) - editor_ui = sa.Column('editorUI', sa.Boolean(), nullable=True, default=True) + editor_ui = sa.Column('editorUI', sa.SmallInteger(), nullable=True, default=True) allow_single_store = sa.Column('allowSingleStore', sa.Boolean(), nullable=True, default=False) @@ -1516,9 +1566,6 @@ class Batch(Base): Represents a batch. """ __tablename__ = 'batches' - __table_args__ = ( - sa.ForeignKeyConstraint(['batchType'], ['batchType.batchTypeID']), - ) id = sa.Column('batchID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) @@ -1528,7 +1575,8 @@ class Batch(Base): name = sa.Column('batchName', sa.String(length=80), nullable=True) - batch_type_id = sa.Column('batchType', sa.Integer(), nullable=True) + batch_type_id = sa.Column('batchType', sa.Integer(), + sa.ForeignKey('batchType.batchTypeID'), nullable=True) batch_type = orm.relationship(BatchType) discount_type = sa.Column('discountType', sa.Integer(), nullable=True) @@ -1550,16 +1598,18 @@ class BatchItem(Base): Represents a batch "list" item. """ __tablename__ = 'batchList' - __table_args__ = ( - sa.ForeignKeyConstraint(['batchID'], ['batches.batchID']), - ) id = sa.Column('listID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) - batch_id = sa.Column('batchID', sa.Integer(), nullable=True) + batch_id = sa.Column('batchID', sa.Integer(), + sa.ForeignKey('batches.batchID'), nullable=True) batch = orm.relationship(Batch, backref=orm.backref('items')) upc = sa.Column(sa.String(length=13), nullable=True) + product = orm.relationship( + Product, + primaryjoin=Product.upc == upc, + foreign_keys=[upc]) sale_price = sa.Column('salePrice', sa.Numeric(precision=9, scale=3), nullable=True) @@ -1695,3 +1745,22 @@ class PurchaseOrderNote(Base): def __str__(self): return self.notes or "" + + +# the rest of this is a workaround to deal with the fact that some +# CORE databases have columns which others do not. i had assumed that +# all would be more or less the same but not so in practice. so if +# your DB *does* have these columns, you must invoke the function +# below in order to merge them into your schema. you should do this +# on app startup and they'll be available normally from then on. + +RUNTIME = {'added_latest_columns': False} + +def use_latest_columns(): + if RUNTIME['added_latest_columns']: + return + + MemberType.ignore_sales = sa.Column('ignoreSales', sa.Boolean(), nullable=True, default=False) + MemberType.sales_code = sa.Column('salesCode', sa.Integer(), nullable=True) + + RUNTIME['added_latest_columns'] = True diff --git a/corepos/db/office_trans/model.py b/corepos/db/office_trans/model.py index 55aff86..c2b0959 100644 --- a/corepos/db/office_trans/model.py +++ b/corepos/db/office_trans/model.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2020 Lance Edgar +# Copyright © 2018-2025 Lance Edgar # # This file is part of pyCOREPOS. # @@ -24,102 +24,68 @@ CORE POS Transaction Data Model """ -from __future__ import unicode_literals, absolute_import - -import six import sqlalchemy as sa -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import orm + +from corepos.db.common import trans as common -Base = declarative_base() +Base = orm.declarative_base() -@six.python_2_unicode_compatible -class TransactionDetail(Base): +# TODO: not sure what primary key should be for this? am trying a +# composite one so far, we'll see...cf. also andy's comments in +# https://github.com/CORE-POS/IS4C/pull/1189#issuecomment-1597481138 +class StockPurchase(Base): """ - Represents a POS transaction detail record. + Represents a member equity payment. + """ + __tablename__ = 'stockpurchases' + + card_number = sa.Column('card_no', sa.Integer(), nullable=False, primary_key=True, autoincrement=False) + + amount = sa.Column('stockPurchase', sa.Numeric(precision=10, scale=2), nullable=True) + + datetime = sa.Column('tdate', sa.DateTime(), nullable=True, primary_key=True, autoincrement=False) + + transaction_number = sa.Column('trans_num', sa.String(length=50), nullable=True, primary_key=True) + + transaction_id = sa.Column('trans_id', sa.Integer(), nullable=True) + + department_number = sa.Column('dept', sa.Integer(), nullable=True, primary_key=True, autoincrement=False) + + def __str__(self): + return f"#{self.card_number} for ${self.amount}" + + +class EquityLiveBalance(Base): + + __tablename__ = 'equity_live_balance' + + member_number = sa.Column('memnum', sa.Integer(), nullable=False, primary_key=True, autoincrement=False) + + payments = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + start_date = sa.Column('startdate', sa.DateTime(), nullable=True) + + +class DTransactionBase(common.TransactionDetailBase): + """ + Base class for ``dtransactions`` and similar models. + """ + store_row_id = sa.Column(sa.Integer(), primary_key=True, nullable=False) + + pos_row_id = sa.Column(sa.Integer(), nullable=True) + store_id = sa.Column(sa.Integer(), nullable=True, default=0) + date_time = sa.Column('datetime', sa.DateTime(), nullable=True) + + +class DTransaction(DTransactionBase, Base): + """ + Data model for ``dtransactions`` table. """ __tablename__ = 'dtransactions' - # store - store_row_id = sa.Column(sa.Integer(), primary_key=True, nullable=False) - store_id = sa.Column(sa.Integer(), nullable=True, default=0) - # register - register_number = sa.Column('register_no', sa.Integer(), nullable=True) - pos_row_id = sa.Column(sa.Integer(), nullable=True) - - # txn - transaction_id = sa.Column('trans_id', sa.Integer(), nullable=True) - transaction_number = sa.Column('trans_no', sa.Integer(), nullable=True) - transaction_type = sa.Column('trans_type', sa.String(length=1), nullable=True) - transaction_subtype = sa.Column('trans_subtype', sa.String(length=2), nullable=True) - transaction_status = sa.Column('trans_status', sa.String(length=1), nullable=True) - - # timestamps - date_time = sa.Column('datetime', sa.DateTime(), nullable=True) - - # cashier - employee_number = sa.Column('emp_no', sa.Integer(), nullable=True) - - # customer - card_number = sa.Column('card_no', sa.Integer(), nullable=True) - member_type = sa.Column('memType', sa.Integer(), nullable=True) - staff = sa.Column(sa.Boolean(), nullable=True) - - ############################## - # remainder is "line item" ... - ############################## - - upc = sa.Column(sa.String(length=13), nullable=True) - - department_number = sa.Column('department', sa.Integer(), nullable=True) - - description = sa.Column(sa.String(length=30), nullable=True) - - quantity = sa.Column(sa.Float(), nullable=True) - - scale = sa.Column(sa.Boolean(), nullable=True, default=False) - - cost = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) - - unit_price = sa.Column('unitPrice', sa.Numeric(precision=10, scale=2), nullable=True) - - total = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) - - reg_price = sa.Column('regPrice', sa.Numeric(precision=10, scale=2), nullable=True) - - tax = sa.Column(sa.Boolean(), nullable=True) - - food_stamp = sa.Column('foodstamp', sa.Boolean(), nullable=True) - - discount = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) - - member_discount = sa.Column('memDiscount', sa.Numeric(precision=10, scale=2), nullable=True) - - discountable = sa.Column(sa.Boolean(), nullable=True) - - discount_type = sa.Column('discounttype', sa.Integer(), nullable=True) - - voided = sa.Column(sa.Boolean(), nullable=True) - - percent_discount = sa.Column('percentDiscount', sa.Integer(), nullable=True) - - item_quantity = sa.Column('ItemQtty', sa.Float(), nullable=True) - - volume_discount_type = sa.Column('volDiscType', sa.Integer(), nullable=True) - - volume = sa.Column(sa.Integer(), nullable=True) - - volume_special = sa.Column('VolSpecial', sa.Numeric(precision=10, scale=2), nullable=True) - - mix_match = sa.Column('mixMatch', sa.String(length=13), nullable=True) - - matched = sa.Column(sa.Boolean(), nullable=True) - - num_flag = sa.Column('numflag', sa.Integer(), nullable=True, default=0) - - char_flag = sa.Column('charflag', sa.String(length=2), nullable=True) - - def __str__(self): - return self.description or '' +# TODO: deprecate / remove this +TransactionDetail = DTransaction diff --git a/corepos/db/office_trans_archive/__init__.py b/corepos/db/office_trans_archive/__init__.py new file mode 100644 index 0000000..6ddb31d --- /dev/null +++ b/corepos/db/office_trans_archive/__init__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2023 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +"Archive" Transaction Database Interface +""" + +import warnings +warnings.warn("The `corepos.db.office_trans_archive` module is deprecated! " + "Please use `corepos.db.office_arch` instead.", + DeprecationWarning, stacklevel=2) + +from corepos.db.office_arch import * diff --git a/corepos/db/office_trans_archive/model.py b/corepos/db/office_trans_archive/model.py new file mode 100644 index 0000000..e57d071 --- /dev/null +++ b/corepos/db/office_trans_archive/model.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2023 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +CORE POS Transaction Data Model +""" + +import warnings +warnings.warn("The `corepos.db.office_trans_archive.model` module is deprecated! " + "Please use `corepos.db.office_arch.model` instead.", + DeprecationWarning, stacklevel=2) + +from corepos.db.office_arch.model import * diff --git a/corepos/enum.py b/corepos/enum.py index 55619d8..93780c3 100644 --- a/corepos/enum.py +++ b/corepos/enum.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2019 Lance Edgar +# Copyright © 2018-2024 Lance Edgar # # This file is part of pyCOREPOS. # @@ -24,22 +24,43 @@ CORE POS enumeration constants """ -from __future__ import unicode_literals, absolute_import - -try: - from collections import OrderedDict -except ImportError: - from ordereddict import OrderedDict +from collections import OrderedDict +from enum import Enum +class CoreDbType(str, Enum): + office_op = 'office_op' + office_trans = 'office_trans' + office_arch = 'office_arch' + + +BATCH_DISCOUNT_TYPE_TRACKING = -1 BATCH_DISCOUNT_TYPE_PRICE_CHANGE = 0 BATCH_DISCOUNT_TYPE_SALE_EVERYONE = 1 BATCH_DISCOUNT_TYPE_SALE_RESTRICTED = 2 +BATCH_DISCOUNT_TYPE_SLIDING_PERCENT = 3 +BATCH_DISCOUNT_TYPE_SLIDING_AMOUNT = 5 BATCH_DISCOUNT_TYPE = OrderedDict([ - (BATCH_DISCOUNT_TYPE_PRICE_CHANGE, "Price Change"), + (BATCH_DISCOUNT_TYPE_PRICE_CHANGE, "None (Change regular price)"), (BATCH_DISCOUNT_TYPE_SALE_EVERYONE, "Sale for everyone"), - (BATCH_DISCOUNT_TYPE_SALE_RESTRICTED, "Member/Owner only sale"), + (BATCH_DISCOUNT_TYPE_SALE_RESTRICTED, "Sale for Members"), + (BATCH_DISCOUNT_TYPE_SLIDING_PERCENT, "Sliding % Off for Members"), + (BATCH_DISCOUNT_TYPE_SLIDING_AMOUNT, "Sliding $ Off for Members"), + (BATCH_DISCOUNT_TYPE_TRACKING, "Tracking (does not change any prices)"), +]) + + +BATCH_EDITOR_UI_STANDARD = 1 +BATCH_EDITOR_UI_PAIRED_SALE = 2 +BATCH_EDITOR_UI_PARTIAL = 3 +BATCH_EDITOR_UI_TRACKING = 4 + +BATCH_EDITOR_UI = OrderedDict([ + (BATCH_EDITOR_UI_STANDARD, "Standard"), + (BATCH_EDITOR_UI_PAIRED_SALE, "Paired Sale"), + (BATCH_EDITOR_UI_PARTIAL, "Partial"), + (BATCH_EDITOR_UI_TRACKING, "Tracking"), ]) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cf50f41 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "pyCOREPOS" +version = "0.5.1" +description = "Python Interface to CORE POS" +readme = "README.md" +authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] +license = {text = "GNU GPL v3+"} +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Topic :: Office/Business", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +dependencies = [ + "mysql-connector-python", + "requests", + "SQLAlchemy>=1.4", +] + + +[project.urls] +Homepage = "https://forgejo.wuttaproject.org/rattail/pycorepos" +Repository = "https://forgejo.wuttaproject.org/rattail/pycorepos" +Issues = "https://forgejo.wuttaproject.org/rattail/pycorepos/issues" +Changelog = "https://forgejo.wuttaproject.org/rattail/pycorepos/src/branch/master/CHANGELOG.md" + + +[tool.commitizen] +version_provider = "pep621" +tag_format = "v$version" +update_changelog_on_bump = true + + +[tool.hatch.build.targets.wheel] +packages = ["corepos"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 098d0fc..0000000 --- a/setup.py +++ /dev/null @@ -1,97 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2020 Lance Edgar -# -# This file is part of pyCOREPOS. -# -# pyCOREPOS is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# pyCOREPOS. If not, see . -# -################################################################################ - -import os -import sys -from setuptools import setup, find_packages - - -here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'corepos', '_version.py')).read()) -README = open(os.path.join(here, 'README.rst')).read() - - -requires = [ - # - # Version numbers within comments below have specific meanings. - # Basically the 'low' value is a "soft low," and 'high' a "soft high." - # In other words: - # - # If either a 'low' or 'high' value exists, the primary point to be - # made about the value is that it represents the most current (stable) - # version available for the package (assuming typical public access - # methods) whenever this project was started and/or documented. - # Therefore: - # - # If a 'low' version is present, you should know that attempts to use - # versions of the package significantly older than the 'low' version - # may not yield happy results. (A "hard" high limit may or may not be - # indicated by a true version requirement.) - # - # Similarly, if a 'high' version is present, and especially if this - # project has laid dormant for a while, you may need to refactor a bit - # when attempting to support a more recent version of the package. (A - # "hard" low limit should be indicated by a true version requirement - # when a 'high' version is present.) - # - # In any case, developers and other users are encouraged to play - # outside the lines with regard to these soft limits. If bugs are - # encountered then they should be filed as such. - # - # package # low high - - 'mysql-connector-python', # 8.0.6 - 'requests', # 2.23.0 - 'six', # 1.12.0 - 'SQLAlchemy', # 0.9.8 -] - - -setup( - name = "pyCOREPOS", - version = __version__, - author = "Lance Edgar", - author_email = "lance@edbob.org", - url = "https://rattailproject.org/", - license = "GNU GPL v3", - description = "Python Interface to CORE POS", - long_description = README, - - classifiers = [ - 'Development Status :: 3 - Alpha', - 'Environment :: Console', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Topic :: Office/Business', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - - install_requires = requires, - packages = find_packages(), -) diff --git a/tasks.py b/tasks.py index a9b9fc3..6946a7f 100644 --- a/tasks.py +++ b/tasks.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2020 Lance Edgar +# Copyright © 2018-2024 Lance Edgar # # This file is part of pyCOREPOS. # @@ -25,20 +25,32 @@ Tasks for 'pyCOREPOS' package """ import os +import re import shutil from invoke import task here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'corepos', '_version.py')).read()) +__version__ = None +pattern = re.compile(r'^version = "(\d+\.\d+\.\d+)"$') +with open(os.path.join(here, 'pyproject.toml'), 'rt') as f: + for line in f: + line = line.rstrip('\n') + match = pattern.match(line) + if match: + __version__ = match.group(1) + break +if not __version__: + raise RuntimeError("could not parse version!") @task -def release(ctx): +def release(c): """ Release a new version of 'pyCOREPOS'. """ - shutil.rmtree('pyCOREPOS.egg-info') - ctx.run('python setup.py sdist --formats=gztar') - ctx.run('twine upload dist/pyCOREPOS-{}.tar.gz'.format(__version__)) + if os.path.exists('pyCOREPOS.egg-info'): + shutil.rmtree('pyCOREPOS.egg-info') + c.run('python -m build --sdist') + c.run('twine upload dist/pycorepos-{}.tar.gz'.format(__version__))