diff --git a/HISTORY.rst b/HISTORY.rst index 780d980..5dc0811 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,4 +3,4 @@ History ======= -* First release on PyPI. +* 0.2.1 version usada en produccion. diff --git a/facho/facho.py b/facho/facho.py index 0970c2c..b64a6d4 100644 --- a/facho/facho.py +++ b/facho/facho.py @@ -389,94 +389,12 @@ class FachoXML: def get_element(self, xpath, multiple=False): xpath = self.fragment_prefix + self._path_xpath_for(xpath) - return self.builder.xpath(self.root, xpath, multiple=multiple) - - def get_element_text(self, xpath, format_=str, multiple=False): - xpath = self.fragment_prefix + self._path_xpath_for(xpath) - # MACHETE(bit4bit) al usar ./ queda ../ - xpath = re.sub(r'^\.\.+', '.', xpath) - - elem = self.builder.xpath(self.root, xpath, multiple=multiple) - if multiple: - vals = [] - for e in elem: - text = self.builder.get_text(e) - if text is not None: - vals.append(format_(text)) - return vals - else: - text = self.builder.get_text(elem) - if text is None: - return None - return format_(text) - - def get_element_text_or_attribute( - self, xpath, default=None, multiple=False, raise_on_fail=False): - parts = xpath.split('/') - is_attribute = parts[-1].startswith('@') - if is_attribute: - attribute_name = parts.pop(-1).lstrip('@') - element_path = "/".join(parts) - try: - val = self.get_element_attribute( - element_path, attribute_name, multiple=multiple) - if val is None: - return default - return val - except KeyError as e: - if raise_on_fail: - raise e - return default - except ValueError as e: - if raise_on_fail: - raise e - return default - else: - try: - val = self.get_element_text(xpath, multiple=multiple) - if val is None: - return default - return val - except ValueError as e: - if raise_on_fail: - raise e - return default - - def get_elements_text_or_attributes(self, xpaths, raise_on_fail=True): - """ - returna el contenido o attributos de un conjunto de XPATHS - si algun XPATH es una tupla se retorna el primer elemento del mismo. - """ - vals = [] - for xpath in xpaths: - if isinstance(xpath, tuple): - val = xpath[0] - else: - val = self.get_element_text_or_attribute( - xpath, raise_on_fail=raise_on_fail) - vals.append(val) - return vals - - def exist_element(self, xpath): - elem = self.get_element(xpath) - - # no se encontro elemento + elem = self.builder.xpath(self.root, xpath) if elem is None: - return False + raise AttributeError('xpath %s invalid' % (xpath)) - # el placeholder no ha sido populado - if elem.get('facho_placeholder') == 'True': - return False - - # el valor opcional no ha sido populado - if elem.get('facho_optional') == 'True': - return False - - return True - - def _remove_facho_attributes(self, elem): - self.builder.remove_attributes( - elem, ['facho_optional', 'facho_placeholder']) + text = self.builder.get_text(elem) + return str(text) def tostring(self, **kw): return self.builder.tostring(self.root, **kw) diff --git a/facho/fe/form/__init__.py b/facho/fe/form/__init__.py index 026eef8..7659e16 100644 --- a/facho/fe/form/__init__.py +++ b/facho/fe/form/__init__.py @@ -60,9 +60,10 @@ class AmountCollection(Collection): class Amount: def __init__( - self, amount: typing.Union[int, float, str, "Amount"], - currency: Currency = Currency('COP')): - + self, amount: int or float or str, + currency: Currency = Currency('COP'), + precision=DECIMAL_PRECISION): + self.precision = precision # DIAN 1.7.-2020: 1.2.3.1 if isinstance(amount, Amount): if amount < Amount(0.0): @@ -76,9 +77,10 @@ class Amount: self.amount = Decimal( amount, decimal.Context( - prec=DECIMAL_PRECISION, + prec=self.precision, # DIAN 1.7.-2020: 1.2.1.1 - rounding=decimal.ROUND_HALF_EVEN)) + rounding=decimal.ROUND_HALF_EVEN) + ) self.currency = currency def fromNumber(self, val): @@ -96,20 +98,24 @@ class Amount: def __lt__(self, other): if not self.is_same_currency(other): raise AmountCurrencyError() - return round(self.amount, DECIMAL_PRECISION) < round(other, 2) + return round(self.amount, self.precision) < round(other, 2) def __eq__(self, other): if not self.is_same_currency(other): raise AmountCurrencyError() - return round(self.amount, DECIMAL_PRECISION) == round( - other.amount, DECIMAL_PRECISION) + return round(self.amount, self.precision) == round( + other.amount, self.precision) def _cast(self, val): if type(val) in [int, float]: return self.fromNumber(val) if isinstance(val, Amount): return val - raise TypeError("cant cast to amount") + + if isinstance(val, Decimal): + return self.fromNumber(float(val)) + + raise TypeError("cant cast %s to amount" % (type(val))) def __add__(self, rother): other = self._cast(rother) diff --git a/facho/fe/model/__init__.py b/facho/fe/model/__init__.py new file mode 100644 index 0000000..6b10421 --- /dev/null +++ b/facho/fe/model/__init__.py @@ -0,0 +1,367 @@ +import facho.model as model +import facho.model.fields as fields +import facho.fe.form as form +from facho import fe +from .common import * +from . import dian + +from datetime import date, datetime +from collections import defaultdict +from copy import copy +import hashlib + + + +class PhysicalLocation(model.Model): + __name__ = 'PhysicalLocation' + + address = fields.Many2One(Address, namespace='cac') + +class PartyTaxScheme(model.Model): + __name__ = 'PartyTaxScheme' + + registration_name = fields.Many2One(Name, name='RegistrationName', namespace='cbc') + company_id = fields.Many2One(ID, name='CompanyID', namespace='cbc') + tax_level_code = fields.Many2One(ID, name='TaxLevelCode', namespace='cbc', default='ZZ') + + +class Party(model.Model): + __name__ = 'Party' + + id = fields.Virtual(setter='_on_set_id') + name = fields.Many2One(PartyName, namespace='cac') + + tax_scheme = fields.Many2One(PartyTaxScheme, namespace='cac') + location = fields.Many2One(PhysicalLocation, namespace='cac') + contact = fields.Many2One(Contact, namespace='cac') + + def _on_set_id(self, name, value): + self.tax_scheme.company_id = value + return value + +class AccountingCustomerParty(model.Model): + __name__ = 'AccountingCustomerParty' + + party = fields.Many2One(Party, namespace='cac') + +class AccountingSupplierParty(model.Model): + __name__ = 'AccountingSupplierParty' + + party = fields.Many2One(Party, namespace='cac') + +class Quantity(model.Model): + __name__ = 'Quantity' + + code = fields.Attribute('unitCode', default='NAR') + + def __setup__(self): + self.value = 0 + + def __default_set__(self, value): + self.value = value + return value + + def __default_get__(self, name, value): + return self.value + +class Amount(model.Model): + __name__ = 'Amount' + + currency = fields.Attribute('currencyID', default='COP') + value = fields.Amount(name='amount', default=0.00, precision=2) + + def __default_set__(self, value): + self.value = value + return value + + def __default_get__(self, name, value): + return self.value + + def __str__(self): + return str(self.value) + +class Price(model.Model): + __name__ = 'Price' + + amount = fields.Many2One(Amount, name='PriceAmount', namespace='cbc') + + def __default_set__(self, value): + self.amount = value + return value + + def __default_get__(self, name, value): + return self.amount + +class Percent(model.Model): + __name__ = 'Percent' + +class TaxScheme(model.Model): + __name__ = 'TaxScheme' + + id = fields.Many2One(ID, namespace='cbc') + name= fields.Many2One(Name, namespace='cbc') + +class TaxCategory(model.Model): + __name__ = 'TaxCategory' + + percent = fields.Many2One(Percent, namespace='cbc') + tax_scheme = fields.Many2One(TaxScheme, namespace='cac') + +class TaxSubTotal(model.Model): + __name__ = 'TaxSubTotal' + + taxable_amount = fields.Many2One(Amount, name='TaxableAmount', namespace='cbc', default=0.00) + tax_amount = fields.Many2One(Amount, name='TaxAmount', namespace='cbc', default=0.00) + tax_percent = fields.Many2One(Percent, namespace='cbc') + tax_category = fields.Many2One(TaxCategory, namespace='cac') + + percent = fields.Virtual(setter='set_category', getter='get_category') + scheme = fields.Virtual(setter='set_category', getter='get_category') + + def set_category(self, name, value): + if name == 'percent': + self.tax_category.percent = value + # TODO(bit4bit) debe variar en conjunto? + self.tax_percent = value + elif name == 'scheme': + self.tax_category.tax_scheme.id = value + + return value + + def get_category(self, name, value): + if name == 'percent': + return value + elif name == 'scheme': + return self.tax_category.tax_scheme + +class TaxTotal(model.Model): + __name__ = 'TaxTotal' + + tax_amount = fields.Many2One(Amount, name='TaxAmount', namespace='cbc', default=0.00) + subtotals = fields.One2Many(TaxSubTotal, namespace='cac') + + +class AllowanceCharge(model.Model): + __name__ = 'AllowanceCharge' + + amount = fields.Many2One(Amount, namespace='cbc') + is_discount = fields.Virtual(default=False) + + def isCharge(self): + return self.is_discount == False + + def isDiscount(self): + return self.is_discount == True + +class Taxes: + class Scheme: + def __init__(self, scheme): + self.scheme = scheme + + class Iva(Scheme): + def __init__(self, percent): + super().__init__('01') + self.percent = percent + + def calculate(self, amount): + return form.Amount(amount) * form.Amount(self.percent / 100) + +class InvoiceLine(model.Model): + __name__ = 'InvoiceLine' + + id = fields.Many2One(ID, namespace='cbc') + quantity = fields.Many2One(Quantity, name='InvoicedQuantity', namespace='cbc') + taxtotal = fields.Many2One(TaxTotal, namespace='cac') + price = fields.Many2One(Price, namespace='cac') + amount = fields.Many2One(Amount, name='LineExtensionAmount', namespace='cbc') + allowance_charge = fields.One2Many(AllowanceCharge, 'cac') + tax_amount = fields.Virtual(getter='get_tax_amount') + + def __setup__(self): + self._taxs = defaultdict(list) + self._subtotals = {} + + def add_tax(self, tax): + if not isinstance(tax, Taxes.Scheme): + raise ValueError('tax expected TaxIva') + + # inicialiamos subtotal para impuesto + if not tax.scheme in self._subtotals: + subtotal = self.taxtotal.subtotals.create() + subtotal.scheme = tax.scheme + + self._subtotals[tax.scheme] = subtotal + + self._taxs[tax.scheme].append(tax) + + def get_tax_amount(self, name, value): + total = form.Amount(0) + for (scheme, subtotal) in self._subtotals.items(): + total += subtotal.tax_amount + + return total + + @fields.on_change(['price', 'quantity']) + def update_amount(self, name, value): + charge = form.AmountCollection(self.allowance_charge)\ + .filter(lambda charge: charge.isCharge())\ + .map(lambda charge: charge.amount)\ + .sum() + + discount = form.AmountCollection(self.allowance_charge)\ + .filter(lambda charge: charge.isDiscount())\ + .map(lambda charge: charge.amount)\ + .sum() + + total = form.Amount(self.quantity) * form.Amount(self.price) + self.amount = total + charge - discount + + for (scheme, subtotal) in self._subtotals.items(): + subtotal.tax_amount = 0 + + for (scheme, taxes) in self._taxs.items(): + for tax in taxes: + self._subtotals[scheme].tax_amount += tax.calculate(self.amount) + +class LegalMonetaryTotal(model.Model): + __name__ = 'LegalMonetaryTotal' + + line_extension_amount = fields.Many2One(Amount, name='LineExtensionAmount', namespace='cbc', default=0) + + tax_exclusive_amount = fields.Many2One(Amount, name='TaxExclusiveAmount', namespace='cbc', default=form.Amount(0)) + tax_inclusive_amount = fields.Many2One(Amount, name='TaxInclusiveAmount', namespace='cbc', default=form.Amount(0)) + charge_total_amount = fields.Many2One(Amount, name='ChargeTotalAmount', namespace='cbc', default=form.Amount(0)) + payable_amount = fields.Many2One(Amount, name='PayableAmount', namespace='cbc', default=form.Amount(0)) + + @fields.on_change(['tax_inclusive_amount', 'charge_total']) + def update_payable_amount(self, name, value): + self.payable_amount = self.tax_inclusive_amount + self.charge_total_amount + + +class DIANExtensionContent(model.Model): + __name__ = 'ExtensionContent' + + dian = fields.Many2One(dian.DianExtensions, name='DianExtensions', namespace='sts') + +class DIANExtension(model.Model): + __name__ = 'UBLExtension' + + content = fields.Many2One(DIANExtensionContent, namespace='ext') + + def __default_get__(self, name, value): + return self.content.dian + +class UBLExtension(model.Model): + __name__ = 'UBLExtension' + + content = fields.Many2One(Element, name='ExtensionContent', namespace='ext', default='') + +class UBLExtensions(model.Model): + __name__ = 'UBLExtensions' + + dian = fields.Many2One(DIANExtension, namespace='ext', create=True) + extension = fields.Many2One(UBLExtension, namespace='ext', create=True) + +class Invoice(model.Model): + __name__ = 'Invoice' + __namespace__ = fe.NAMESPACES + + _ubl_extensions = fields.Many2One(UBLExtensions, namespace='ext') + # nos interesa el acceso solo los atributos de la DIAN + dian = fields.Virtual(getter='get_dian_extension') + + profile_id = fields.Many2One(Element, name='ProfileID', namespace='cbc', default='DIAN 2.1') + profile_execute_id = fields.Many2One(Element, name='ProfileExecuteID', namespace='cbc', default='2') + + id = fields.Many2One(ID, namespace='cbc') + issue = fields.Virtual(setter='set_issue') + issue_date = fields.Many2One(Date, name='IssueDate', namespace='cbc') + issue_time = fields.Many2One(Time, name='IssueTime', namespace='cbc') + + period = fields.Many2One(Period, name='InvoicePeriod', namespace='cac') + + supplier = fields.Many2One(AccountingSupplierParty, namespace='cac') + customer = fields.Many2One(AccountingCustomerParty, namespace='cac') + legal_monetary_total = fields.Many2One(LegalMonetaryTotal, namespace='cac') + lines = fields.One2Many(InvoiceLine, namespace='cac') + + taxtotal_01 = fields.Many2One(TaxTotal) + taxtotal_04 = fields.Many2One(TaxTotal) + taxtotal_03 = fields.Many2One(TaxTotal) + + def __setup__(self): + self._namespace_prefix = 'fe' + # Se requieren minimo estos impuestos para + # validar el cufe + self._subtotal_01 = self.taxtotal_01.subtotals.create() + self._subtotal_01.scheme = '01' + self._subtotal_01.percent = 19.0 + + self._subtotal_04 = self.taxtotal_04.subtotals.create() + self._subtotal_04.scheme = '04' + + self._subtotal_03 = self.taxtotal_03.subtotals.create() + self._subtotal_03.scheme = '03' + + def cufe(self, token, environment): + + valor_bruto = self.legal_monetary_total.line_extension_amount + valor_total_pagar = self.legal_monetary_total.payable_amount + + valor_impuesto_01 = form.Amount(0.0) + valor_impuesto_04 = form.Amount(0.0) + valor_impuesto_03 = form.Amount(0.0) + + for line in self.lines: + for subtotal in line.taxtotal.subtotals: + if subtotal.scheme.id == '01': + valor_impuesto_01 += subtotal.tax_amount + elif subtotal.scheme.id == '04': + valor_impuesto_04 += subtotal.tax_amount + elif subtotal.scheme.id == '03': + valor_impuesto_03 += subtotal.tax_amount + + pattern = [ + '%s' % str(self.id), + '%s' % str(self.issue_date), + '%s' % str(self.issue_time), + valor_bruto.truncate_as_string(2), + '01', valor_impuesto_01.truncate_as_string(2), + '04', valor_impuesto_04.truncate_as_string(2), + '03', valor_impuesto_03.truncate_as_string(2), + valor_total_pagar.truncate_as_string(2), + str(self.supplier.party.id), + str(self.customer.party.id), + str(token), + str(environment) + ] + + cufe = "".join(pattern) + h = hashlib.sha384() + h.update(cufe.encode('utf-8')) + return h.hexdigest() + + @fields.on_change(['lines']) + def update_legal_monetary_total(self, name, value): + self.legal_monetary_total.line_extension_amount = 0 + self.legal_monetary_total.tax_inclusive_amount = 0 + + for line in self.lines: + self.legal_monetary_total.line_extension_amount += line.amount + self.legal_monetary_total.tax_inclusive_amount += line.amount + line.tax_amount + + def set_issue(self, name, value): + if not isinstance(value, datetime): + raise ValueError('expected type datetime') + self.issue_date = value.date() + self.issue_time = value + + def get_dian_extension(self, name, _value): + return self._ubl_extensions.dian + + def to_xml(self, **kw): + # al generar documento el namespace + # se hace respecto a la raiz + return super().to_xml(**kw)\ + .replace("fe:", "")\ + .replace("xmlns:fe", "xmlns") diff --git a/facho/fe/model/common.py b/facho/fe/model/common.py new file mode 100644 index 0000000..fa875ee --- /dev/null +++ b/facho/fe/model/common.py @@ -0,0 +1,90 @@ +import facho.model as model +import facho.model.fields as fields + +from datetime import date, datetime + +__all__ = ['Element', 'PartyName', 'Name', 'Date', 'Time', 'Period', 'ID', 'Address', 'Country', 'Contact'] + +class Element(model.Model): + """ + Lo usuamos para elementos que solo manejan contenido + """ + __name__ = 'Element' + +class Name(model.Model): + __name__ = 'Name' + +class Date(model.Model): + __name__ = 'Date' + + def __default_set__(self, value): + if isinstance(value, str): + return value + if isinstance(value, date): + return value.isoformat() + + def __str__(self): + return str(self._value) + +class Time(model.Model): + __name__ = 'Time' + + def __default_set__(self, value): + if isinstance(value, str): + return value + if isinstance(value, date): + return value.strftime('%H:%M:%S-05:00') + + def __str__(self): + return str(self._value) + +class Period(model.Model): + __name__ = 'Period' + + start_date = fields.Many2One(Date, name='StartDate', namespace='cbc') + + end_date = fields.Many2One(Date, name='EndDate', namespace='cbc') + +class ID(model.Model): + __name__ = 'ID' + + def __default_get__(self, name, value): + return self._value + + def __str__(self): + return str(self._value) + + +class Country(model.Model): + __name__ = 'Country' + + name = fields.Many2One(Element, name='Name', namespace='cbc') + +class Address(model.Model): + __name__ = 'Address' + + #DIAN 1.7.-2020: FAJ08 + #DIAN 1.7.-2020: CAJ09 + id = fields.Many2One(Element, name='ID', namespace='cbc') + + #DIAN 1.7.-2020: FAJ09 + #DIAN 1.7.-2020: CAJ10 + city = fields.Many2One(Element, name='CityName', namespace='cbc') + + +class PartyName(model.Model): + __name__ = 'PartyName' + + name = fields.Many2One(Name, namespace='cbc') + + def __default_set__(self, value): + self.name = value + return value + + def __default_get__(self, name, value): + return self.name + +class Contact(model.Model): + __name__ = 'Contact' + + email = fields.Many2One(Name, name='ElectronicEmail', namespace='cbc') diff --git a/facho/fe/model/dian.py b/facho/fe/model/dian.py new file mode 100644 index 0000000..dbbe3c2 --- /dev/null +++ b/facho/fe/model/dian.py @@ -0,0 +1,58 @@ +import facho.model as model +import facho.model.fields as fields +from .common import * + +class DIANElement(Element): + """ + Elemento que contiene atributos por defecto. + + Puede extender esta clase y modificar los atributos nuevamente + """ + __name__ = 'DIANElement' + + scheme_id = fields.Attribute('schemeID', default='4') + scheme_name = fields.Attribute('schemeName', default='31') + scheme_agency_name = fields.Attribute('schemeAgencyName', default='CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)') + scheme_agency_id = fields.Attribute('schemeAgencyID', default='195') + +class SoftwareProvider(model.Model): + __name__ = 'SoftwareProvider' + + provider_id = fields.Many2One(Element, name='ProviderID', namespace='sts') + software_id = fields.Many2One(Element, name='SoftwareID', namespace='sts') + +class InvoiceSource(model.Model): + __name__ = 'InvoiceSource' + + identification_code = fields.Many2One(Element, name='IdentificationCode', namespace='sts', default='CO') + +class AuthorizedInvoices(model.Model): + __name__ = 'AuthorizedInvoices' + + prefix = fields.Many2One(Element, name='Prefix', namespace='sts') + from_range = fields.Many2One(Element, name='From', namespace='sts') + to_range = fields.Many2One(Element, name='To', namespace='sts') + +class InvoiceControl(model.Model): + __name__ = 'InvoiceControl' + + authorization = fields.Many2One(Element, name='InvoiceAuthorization', namespace='sts') + period = fields.Many2One(Period, name='AuthorizationPeriod', namespace='sts') + invoices = fields.Many2One(AuthorizedInvoices, namespace='sts') + +class AuthorizationProvider(model.Model): + __name__ = 'AuthorizationProvider' + + + id = fields.Many2One(DIANElement, name='AuthorizationProviderID', namespace='sts', default='800197268') + +class DianExtensions(model.Model): + __name__ = 'DianExtensions' + + authorization_provider = fields.Many2One(AuthorizationProvider, namespace='sts', create=True) + + software_security_code = fields.Many2One(Element, name='SoftwareSecurityCode', namespace='sts') + software_provider = fields.Many2One(SoftwareProvider, namespace='sts') + source = fields.Many2One(InvoiceSource, namespace='sts') + control = fields.Many2One(InvoiceControl, namespace='sts') + diff --git a/facho/model/__init__.py b/facho/model/__init__.py new file mode 100644 index 0000000..dc3bfaf --- /dev/null +++ b/facho/model/__init__.py @@ -0,0 +1,175 @@ +from .fields import Field +from collections import defaultdict + +class ModelMeta(type): + def __new__(cls, name, bases, ns): + new = type.__new__(cls, name, bases, ns) + + # mapeamos asignacion en declaracion de clase + # a attributo de objeto + if '__name__' in ns: + new.__name__ = ns['__name__'] + if '__namespace__' in ns: + new.__namespace__ = ns['__namespace__'] + else: + new.__namespace__ = {} + + return new + +class ModelBase(object, metaclass=ModelMeta): + + def __new__(cls, *args, **kwargs): + obj = super().__new__(cls, *args, **kwargs) + obj._xml_attributes = {} + obj._fields = {} + obj._value = None + obj._namespace_prefix = None + obj._on_change_fields = defaultdict(list) + obj._order_fields = [] + + def on_change_fields_for_function(): + # se recorre arbol de herencia buscando attributo on_changes + for parent_cls in type(obj).__mro__: + for parent_attr in dir(parent_cls): + parent_meth = getattr(parent_cls, parent_attr, None) + if not callable(parent_meth): + continue + on_changes = getattr(parent_meth, 'on_changes', None) + if on_changes: + return (parent_meth, on_changes) + return (None, []) + + # forzamos registros de campos al modelo + # al instanciar + for (key, v) in type(obj).__dict__.items(): + if isinstance(v, fields.Field): + obj._order_fields.append(key) + + if isinstance(v, fields.Attribute) or isinstance(v, fields.Many2One) or isinstance(v, fields.Function) or isinstance(v, fields.Amount): + if hasattr(v, 'default') and v.default is not None: + setattr(obj, key, v.default) + if hasattr(v, 'create') and v.create == True: + setattr(obj, key, '') + + # register callbacks for changes + (fun, on_change_fields) = on_change_fields_for_function() + for field in on_change_fields: + obj._on_change_fields[field].append(fun) + + + # post inicializacion del objeto + obj.__setup__() + return obj + + def _set_attribute(self, field, name, value): + self._xml_attributes[field] = (name, value) + + def __setitem__(self, key, val): + self._xml_attributes[key] = val + + def __getitem__(self, key): + return self._xml_attributes[key] + + def _get_field(self, name): + return self._fields[name] + + def _set_field(self, name, field): + field.name = name + self._fields[name] = field + + def _set_content(self, value): + default = self.__default_set__(value) + if default is not None: + self._value = default + + def to_xml(self): + """ + Genera xml del modelo y sus relaciones + """ + def _hook_before_xml(): + self.__before_xml__() + for field in self._fields.values(): + if hasattr(field, '__before_xml__'): + field.__before_xml__() + + _hook_before_xml() + + tag = self.__name__ + ns = '' + if self._namespace_prefix is not None: + ns = "%s:" % (self._namespace_prefix) + + pair_attributes = ["%s=\"%s\"" % (k, v) for (k, v) in self._xml_attributes.values()] + + for (prefix, url) in self.__namespace__.items(): + pair_attributes.append("xmlns:%s=\"%s\"" % (prefix, url)) + attributes = "" + if pair_attributes: + attributes = " " + " ".join(pair_attributes) + + content = "" + + ordered_fields = {} + for name in self._order_fields: + if name in self._fields: + ordered_fields[name] = True + else: + for key in self._fields.keys(): + if key.startswith(name): + ordered_fields[key] = True + + for name in ordered_fields.keys(): + value = self._fields[name] + # al ser virtual no adicinamos al arbol xml + if hasattr(value, 'virtual') and value.virtual: + continue + + if hasattr(value, 'to_xml'): + content += value.to_xml() + elif isinstance(value, str): + content += value + + if self._value is not None: + content += str(self._value) + + if content == "": + return "<%s%s%s/>" % (ns, tag, attributes) + else: + return "<%s%s%s>%s" % (ns, tag, attributes, content, ns, tag) + + def __str__(self): + return self.to_xml() + + +class Model(ModelBase): + """ + Model clase que representa el modelo + """ + + def __before_xml__(self): + """ + Ejecuta antes de generar el xml, este + metodo sirve para realizar actualizaciones + en los campos en el ultimo momento + """ + pass + + def __default_set__(self, value): + """ + Al asignar un valor al modelo atraves de una relacion (person.relation = '33') + se puede personalizar como hacer esta asignacion. + """ + return value + + def __default_get__(self, name, value): + """ + Al obtener el valor atraves de una relacion (age = person.age) + Retorno de valor por defecto + """ + return value + + def __setup__(self): + """ + Inicializar modelo + """ + diff --git a/facho/model/fields/__init__.py b/facho/model/fields/__init__.py new file mode 100644 index 0000000..081f195 --- /dev/null +++ b/facho/model/fields/__init__.py @@ -0,0 +1,21 @@ +from .attribute import Attribute +from .many2one import Many2One +from .one2many import One2Many +from .function import Function +from .virtual import Virtual +from .field import Field +from .amount import Amount + +__all__ = [Attribute, One2Many, Many2One, Virtual, Field, Amount] + +def on_change(fields): + from functools import wraps + + def decorator(func): + setattr(func, 'on_changes', fields) + + @wraps(func) + def wrapper(self, *arg, **kwargs): + return func(self, *arg, **kwargs) + return wrapper + return decorator diff --git a/facho/model/fields/amount.py b/facho/model/fields/amount.py new file mode 100644 index 0000000..6402d88 --- /dev/null +++ b/facho/model/fields/amount.py @@ -0,0 +1,35 @@ +from .field import Field +from collections import defaultdict +import facho.fe.form as form + +class Amount(Field): + """ + Amount representa un campo moneda usando form.Amount + """ + + def __init__(self, name=None, default=None, precision=6): + self.field_name = name + self.values = {} + self.default = default + self.precision = precision + + def __get__(self, model, cls): + if model is None: + return self + assert self.name is not None + + self.__init_value(model) + model._set_field(self.name, self) + return self.values[model] + + def __set__(self, model, value): + assert self.name is not None + self.__init_value(model) + model._set_field(self.name, self) + self.values[model] = form.Amount(value, precision=self.precision) + + self._changed_field(model, self.name, value) + + def __init_value(self, model): + if model not in self.values: + self.values[model] = form.Amount(self.default or 0) diff --git a/facho/model/fields/attribute.py b/facho/model/fields/attribute.py new file mode 100644 index 0000000..383fb2f --- /dev/null +++ b/facho/model/fields/attribute.py @@ -0,0 +1,29 @@ +from .field import Field + +class Attribute(Field): + """ + Attribute es un atributo del elemento actual. + """ + + def __init__(self, name, default=None): + """ + :param name: nombre del atribute + :param default: valor por defecto del attributo + """ + self.attribute = name + self.value = default + self.default = default + + def __get__(self, inst, cls): + if inst is None: + return self + + assert self.name is not None + return self.value + + def __set__(self, inst, value): + assert self.name is not None + self.value = value + + self._changed_field(inst, self.name, value) + inst._set_attribute(self.name, self.attribute, value) diff --git a/facho/model/fields/field.py b/facho/model/fields/field.py new file mode 100644 index 0000000..7deec60 --- /dev/null +++ b/facho/model/fields/field.py @@ -0,0 +1,60 @@ +import warnings + +class Field: + def __set_name__(self, owner, name, virtual=False): + self.name = name + self.virtual = virtual + + def __get__(self, inst, cls): + if inst is None: + return self + assert self.name is not None + return inst._fields[self.name] + + def __set__(self, inst, value): + assert self.name is not None + inst._fields[self.name] = value + + def _set_namespace(self, inst, name, namespaces): + if name is None: + return + + #TODO(bit4bit) aunque las pruebas confirmar + #que si se escribe el namespace que es + #no ahi confirmacion de declaracion previa del namespace + + inst._namespace_prefix = name + + def _call(self, inst, method, *args): + call = getattr(inst, method or '', None) + + if callable(call): + return call(*args) + + def _create_model(self, inst, name=None, model=None, attribute=None, namespace=None): + try: + return inst._fields[self.name] + except KeyError: + if model is not None: + obj = model() + else: + obj = self.model() + if name is not None: + obj.__name__ = name + + if namespace: + self._set_namespace(obj, namespace, inst.__namespace__) + else: + self._set_namespace(obj, self.namespace, inst.__namespace__) + + if attribute: + inst._fields[attribute] = obj + else: + inst._fields[self.name] = obj + + return obj + + def _changed_field(self, inst, name, value): + for fun in inst._on_change_fields[name]: + fun(inst, name, value) + diff --git a/facho/model/fields/function.py b/facho/model/fields/function.py new file mode 100644 index 0000000..707fd55 --- /dev/null +++ b/facho/model/fields/function.py @@ -0,0 +1,36 @@ +from .field import Field + +class Function(Field): + """ + Permite modificar el modelo cuando se intenta, + obtener el valor de este campo. + + DEPRECATED usar Virtual + """ + def __init__(self, field, getter=None, default=None): + self.field = field + self.getter = getter + self.default = default + + def __get__(self, inst, cls): + if inst is None: + return self + assert self.name is not None + + # si se indica `field` se adiciona + # como campo del modelo, esto es + # que se serializa a xml + inst._set_field(self.name, self.field) + + if self.getter is not None: + value = self._call(inst, self.getter, self.name, self.field) + + if value is not None: + self.field.__set__(inst, value) + + return self.field + + def __set__(self, inst, value): + inst._set_field(self.name, self.field) + self._changed_field(inst, self.name, value) + self.field.__set__(inst, value) diff --git a/facho/model/fields/many2one.py b/facho/model/fields/many2one.py new file mode 100644 index 0000000..966d4d2 --- /dev/null +++ b/facho/model/fields/many2one.py @@ -0,0 +1,62 @@ +from .field import Field +from collections import defaultdict + +class Many2One(Field): + """ + Many2One describe una relacion pertenece a. + """ + + def __init__(self, model, name=None, setter=None, namespace=None, default=None, virtual=False, create=False): + """ + :param model: nombre del modelo destino + :param name: nombre del elemento xml + :param setter: nombre de methodo usado cuando se asigna usa como asignacion ejemplo model.relation = 3 + :param namespace: sufijo del namespace al que pertenece el elemento + :param default: el valor o contenido por defecto + :param virtual: se crea la relacion por no se ve reflejada en el xml final + :param create: fuerza la creacion del elemento en el xml, ya que los elementos no son creados sino tienen contenido + """ + self.model = model + self.setter = setter + self.namespace = namespace + self.field_name = name + self.default = default + self.virtual = virtual + self.relations = defaultdict(dict) + self.create = create + + def __get__(self, inst, cls): + if inst is None: + return self + assert self.name is not None + + if self.name in self.relations: + value = self.relations[inst][self.name] + else: + value = self._create_model(inst, name=self.field_name) + self.relations[inst][self.name] = value + + # se puede obtener directamente un valor indicado por el modelo + if hasattr(value, '__default_get__'): + return value.__default_get__(self.name, value) + elif hasattr(inst, '__default_get__'): + return inst.__default_get__(self.name, value) + else: + return value + + def __set__(self, inst, value): + assert self.name is not None + inst_model = self._create_model(inst, name=self.field_name, model=self.model) + self.relations[inst][self.name] = inst_model + + # si hay setter manual se ejecuta + # de lo contrario se asigna como texto del elemento + setter = getattr(inst, self.setter or '', None) + if callable(setter): + setter(inst_model, value) + else: + inst_model._set_content(value) + + self._changed_field(inst, self.name, value) + + diff --git a/facho/model/fields/one2many.py b/facho/model/fields/one2many.py new file mode 100644 index 0000000..8f7f339 --- /dev/null +++ b/facho/model/fields/one2many.py @@ -0,0 +1,86 @@ +from .field import Field +from collections import defaultdict + +# TODO(bit4bit) lograr que isinstance se aplique +# al objeto envuelto +class _RelationProxy(): + def __init__(self, obj, inst, attribute): + self.__dict__['_obj'] = obj + self.__dict__['_inst'] = inst + self.__dict__['_attribute'] = attribute + + def __getattr__(self, name): + if (name in self.__dict__): + return self.__dict__[name] + + rel = getattr(self.__dict__['_obj'], name) + if hasattr(rel, '__default_get__'): + return rel.__default_get__(name, rel) + + return rel + + def __setattr__(self, attr, value): + # TODO(bit4bit) hacemos proxy al sistema de notificacion de cambios + # algo burdo, se usa __dict__ para saltarnos el __getattr__ y evitar un fallo por recursion + rel = getattr(self.__dict__['_obj'], attr) + if hasattr(rel, '__default_set__'): + response = setattr(self._obj, attr, rel.__default_set__(value)) + else: + response = setattr(self._obj, attr, value) + + for fun in self.__dict__['_inst']._on_change_fields[self.__dict__['_attribute']]: + fun(self.__dict__['_inst'], self.__dict__['_attribute'], value) + return response + +class _Relation(): + def __init__(self, creator, inst, attribute): + self.creator = creator + self.inst = inst + self.attribute = attribute + self.relations = [] + + def create(self): + n_relations = len(self.relations) + attribute = '%s_%d' % (self.attribute, n_relations) + relation = self.creator(attribute) + proxy = _RelationProxy(relation, self.inst, self.attribute) + + self.relations.append(relation) + return proxy + + def __len__(self): + return len(self.relations) + + def __iter__(self): + for relation in self.relations: + yield relation + +class One2Many(Field): + """ + One2Many describe una relacion tiene muchos. + """ + + def __init__(self, model, name=None, namespace=None, default=None): + """ + :param model: nombre del modelo destino + :param name: nombre del elemento xml cuando se crea hijo + :param namespace: sufijo del namespace al que pertenece el elemento + :param default: el valor o contenido por defecto + """ + self.model = model + self.field_name = name + self.namespace = namespace + self.default = default + self.relation = {} + + def __get__(self, inst, cls): + assert self.name is not None + + def creator(attribute): + return self._create_model(inst, name=self.field_name, model=self.model, attribute=attribute, namespace=self.namespace) + + if inst in self.relation: + return self.relation[inst] + else: + self.relation[inst] = _Relation(creator, inst, self.name) + return self.relation[inst] diff --git a/facho/model/fields/virtual.py b/facho/model/fields/virtual.py new file mode 100644 index 0000000..e39b9dd --- /dev/null +++ b/facho/model/fields/virtual.py @@ -0,0 +1,54 @@ +from .field import Field + +# Un campo virtual +# no participa del renderizado +# pero puede interactura con este +class Virtual(Field): + """ + Virtual es un campo que no es renderizado en el xml final + """ + def __init__(self, + setter=None, + getter='', + default=None, + update_internal=False): + """ + :param setter: nombre de methodo usado cuando se asigna usa como asignacion ejemplo model.relation = 3 + :param getter: nombre del metodo usando cuando se obtiene, ejemplo: valor = mode.relation + :param default: valor por defecto + :param update_internal: indica que cuando se asigne algun valor este se almacena localmente + """ + self.default = default + self.setter = setter + self.getter = getter + self.values = {} + self.update_internal = update_internal + self.virtual = True + + def __get__(self, inst, cls): + if inst is None: + return self + assert self.name is not None + + value = self.default + try: + value = self.values[inst] + except KeyError: + pass + + try: + self.values[inst] = getattr(inst, self.getter)(self.name, value) + except AttributeError: + self.values[inst] = value + + return self.values[inst] + + def __set__(self, inst, value): + if self.update_internal: + inst._value = value + + if self.setter is None: + self.values[inst] = value + else: + self.values[inst] = self._call(inst, self.setter, self.name, value) + self._changed_field(inst, self.name, value) diff --git a/setup.py b/setup.py index 226c7f8..185a93a 100644 --- a/setup.py +++ b/setup.py @@ -84,6 +84,10 @@ setup( test_suite='tests', tests_require=test_requirements, url='https://github.com/bit4bit/facho', +<<<<<<< HEAD version='0.2.0', +======= + version='0.2.1', +>>>>>>> morfo zip_safe=False, ) diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..831460e --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,601 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# This file is part of facho. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +"""Tests for `facho` package.""" + +import pytest + +import facho.model +import facho.model.fields as fields + +def test_model_to_element(): + class Person(facho.model.Model): + __name__ = 'Person' + + person = Person() + + assert "" == person.to_xml() + +def test_model_to_element_with_attribute(): + class Person(facho.model.Model): + __name__ = 'Person' + id = fields.Attribute('id') + + person = Person() + person.id = 33 + + personb = Person() + personb.id = 44 + + assert "" == person.to_xml() + assert "" == personb.to_xml() + +def test_model_to_element_with_attribute_as_element(): + class ID(facho.model.Model): + __name__ = 'ID' + + class Person(facho.model.Model): + __name__ = 'Person' + + id = fields.Many2One(ID) + + person = Person() + person.id = 33 + assert "33" == person.to_xml() + +def test_many2one_with_custom_attributes(): + class TaxAmount(facho.model.Model): + __name__ = 'TaxAmount' + + currencyID = fields.Attribute('currencyID') + + class TaxTotal(facho.model.Model): + __name__ = 'TaxTotal' + + amount = fields.Many2One(TaxAmount) + + tax_total = TaxTotal() + tax_total.amount = 3333 + tax_total.amount.currencyID = 'COP' + assert '3333' == tax_total.to_xml() + +def test_many2one_with_custom_setter(): + + class PhysicalLocation(facho.model.Model): + __name__ = 'PhysicalLocation' + + id = fields.Attribute('ID') + + class Party(facho.model.Model): + __name__ = 'Party' + + location = fields.Many2One(PhysicalLocation, setter='location_setter') + + def location_setter(self, field, value): + field.id = value + + party = Party() + party.location = 99 + assert '' == party.to_xml() + +def test_many2one_always_create(): + class Name(facho.model.Model): + __name__ = 'Name' + + class Person(facho.model.Model): + __name__ = 'Person' + + name = fields.Many2One(Name, default='facho') + + person = Person() + assert 'facho' == person.to_xml() + +def test_many2one_nested_always_create(): + class Name(facho.model.Model): + __name__ = 'Name' + + class Contact(facho.model.Model): + __name__ = 'Contact' + + name = fields.Many2One(Name, default='facho') + + class Person(facho.model.Model): + __name__ = 'Person' + + contact = fields.Many2One(Contact, create=True) + + person = Person() + assert 'facho' == person.to_xml() + +def test_many2one_auto_create(): + class TaxAmount(facho.model.Model): + __name__ = 'TaxAmount' + + currencyID = fields.Attribute('currencyID') + + class TaxTotal(facho.model.Model): + __name__ = 'TaxTotal' + + amount = fields.Many2One(TaxAmount) + + tax_total = TaxTotal() + tax_total.amount.currencyID = 'COP' + tax_total.amount = 3333 + assert '3333' == tax_total.to_xml() + +def test_field_model(): + class ID(facho.model.Model): + __name__ = 'ID' + + class Person(facho.model.Model): + __name__ = 'Person' + + id = fields.Many2One(ID) + + person = Person() + person.id = ID() + person.id = 33 + assert "33" == person.to_xml() + +def test_field_multiple_model(): + class ID(facho.model.Model): + __name__ = 'ID' + + class Person(facho.model.Model): + __name__ = 'Person' + + id = fields.Many2One(ID) + id2 = fields.Many2One(ID) + + person = Person() + person.id = 33 + person.id2 = 44 + assert "3344" == person.to_xml() + +def test_field_model_failed_initialization(): + class ID(facho.model.Model): + __name__ = 'ID' + + class Person(facho.model.Model): + __name__ = 'Person' + + id = fields.Many2One(ID) + + + person = Person() + person.id = 33 + assert "33" == person.to_xml() + +def test_field_model_with_custom_name(): + class ID(facho.model.Model): + __name__ = 'ID' + + class Person(facho.model.Model): + __name__ = 'Person' + + id = fields.Many2One(ID, name='DID') + + + person = Person() + person.id = 33 + assert "33" == person.to_xml() + +def test_field_model_default_initialization_with_attributes(): + class ID(facho.model.Model): + __name__ = 'ID' + + reference = fields.Attribute('REFERENCE') + + class Person(facho.model.Model): + __name__ = 'Person' + + id = fields.Many2One(ID) + + person = Person() + person.id = 33 + person.id.reference = 'haber' + assert '33' == person.to_xml() + +def test_model_with_xml_namespace(): + class Person(facho.model.Model): + __name__ = 'Person' + __namespace__ = { + 'facho': 'http://lib.facho.cyou' + } + + person = Person() + assert '' + +def test_model_with_xml_namespace_nested(): + class ID(facho.model.Model): + __name__ = 'ID' + + class Person(facho.model.Model): + __name__ = 'Person' + __namespace__ = { + 'facho': 'http://lib.facho.cyou' + } + + id = fields.Many2One(ID, namespace='facho') + + person = Person() + person.id = 33 + assert '33' == person.to_xml() + +def test_model_with_xml_namespace_nested_nested(): + class ID(facho.model.Model): + __name__ = 'ID' + + class Party(facho.model.Model): + __name__ = 'Party' + + id = fields.Many2One(ID, namespace='party') + + def __default_set__(self, value): + self.id = value + + class Person(facho.model.Model): + __name__ = 'Person' + __namespace__ = { + 'person': 'http://lib.facho.cyou', + 'party': 'http://lib.facho.cyou' + } + + id = fields.Many2One(Party, namespace='person') + + person = Person() + person.id = 33 + assert '33' == person.to_xml() + +def test_model_with_xml_namespace_nested_one_many(): + class Name(facho.model.Model): + __name__ = 'Name' + + class Contact(facho.model.Model): + __name__ = 'Contact' + + name = fields.Many2One(Name, namespace='contact') + + class Person(facho.model.Model): + __name__ = 'Person' + __namespace__ = { + 'facho': 'http://lib.facho.cyou', + 'contact': 'http://lib.facho.cyou' + } + + contacts = fields.One2Many(Contact, namespace='facho') + + person = Person() + contact = person.contacts.create() + contact.name = 'contact1' + + contact = person.contacts.create() + contact.name = 'contact2' + + assert 'contact1contact2' == person.to_xml() + +def test_field_model_with_namespace(): + class ID(facho.model.Model): + __name__ = 'ID' + + class Person(facho.model.Model): + __name__ = 'Person' + __namespace__ = { + "facho": "http://lib.facho.cyou" + } + id = fields.Many2One(ID, namespace="facho") + + + person = Person() + person.id = 33 + assert '33' == person.to_xml() + +def test_field_hook_before_xml(): + class Hash(facho.model.Model): + __name__ = 'Hash' + + class Person(facho.model.Model): + __name__ = 'Person' + + hash = fields.Many2One(Hash) + + def __before_xml__(self): + self.hash = "calculate" + + person = Person() + assert "calculate" == person.to_xml() + + +def test_field_function_with_attribute(): + class Person(facho.model.Model): + __name__ = 'Person' + + hash = fields.Function(fields.Attribute('hash'), getter='get_hash') + + def get_hash(self, name, field): + return 'calculate' + + person = Person() + assert '' + +def test_field_function_with_model(): + class Hash(facho.model.Model): + __name__ = 'Hash' + + id = fields.Attribute('id') + + class Person(facho.model.Model): + __name__ = 'Person' + + hash = fields.Function(fields.Many2One(Hash), getter='get_hash') + + def get_hash(self, name, field): + field.id = 'calculate' + + + person = Person() + assert person.hash.id == 'calculate' + assert '' + + +def test_field_function_setter(): + class Person(facho.model.Model): + __name__ = 'Person' + + hash = fields.Attribute('hash') + password = fields.Virtual(setter='set_hash') + + def set_hash(self, name, value): + self.hash = "%s+2" % (value) + + person = Person() + person.password = 'calculate' + assert '' == person.to_xml() + +def test_field_function_only_setter(): + class Person(facho.model.Model): + __name__ = 'Person' + + hash = fields.Attribute('hash') + password = fields.Virtual(setter='set_hash') + + def set_hash(self, name, value): + self.hash = "%s+2" % (value) + + person = Person() + person.password = 'calculate' + assert '' == person.to_xml() + +def test_model_set_default_setter(): + class Hash(facho.model.Model): + __name__ = 'Hash' + + def __default_set__(self, value): + return "%s+3" % (value) + + class Person(facho.model.Model): + __name__ = 'Person' + + hash = fields.Many2One(Hash) + + person = Person() + person.hash = 'hola' + assert 'hola+3' == person.to_xml() + + +def test_field_virtual(): + class Person(facho.model.Model): + __name__ = 'Person' + + age = fields.Virtual() + + person = Person() + person.age = 55 + assert person.age == 55 + assert "" == person.to_xml() + + +def test_field_inserted_default_attribute(): + class Person(facho.model.Model): + __name__ = 'Person' + + hash = fields.Attribute('hash', default='calculate') + + + person = Person() + assert '' == person.to_xml() + +def test_field_function_inserted_default_attribute(): + class Person(facho.model.Model): + __name__ = 'Person' + + hash = fields.Function(fields.Attribute('hash'), default='calculate') + + person = Person() + assert '' == person.to_xml() + +def test_field_inserted_default_many2one(): + class ID(facho.model.Model): + __name__ = 'ID' + + key = fields.Attribute('key') + + def __default_set__(self, value): + self.key = value + + class Person(facho.model.Model): + __name__ = 'Person' + + id = fields.Many2One(ID, default="oe") + + person = Person() + assert '' == person.to_xml() + +def test_field_inserted_default_nested_many2one(): + class ID(facho.model.Model): + __name__ = 'ID' + + class Person(facho.model.Model): + __name__ = 'Person' + + id = fields.Many2One(ID, default="ole") + + person = Person() + assert 'ole' == person.to_xml() + +def test_model_on_change_field(): + class Hash(facho.model.Model): + __name__ = 'Hash' + + class Person(facho.model.Model): + __name__ = 'Person' + + react = fields.Attribute('react') + hash = fields.Many2One(Hash) + + @fields.on_change(['hash']) + def on_change_react(self, name, value): + assert name == 'hash' + self.react = "%s+4" % (value) + + person = Person() + person.hash = 'hola' + assert 'hola' == person.to_xml() + +def test_model_on_change_field_attribute(): + class Person(facho.model.Model): + __name__ = 'Person' + + react = fields.Attribute('react') + hash = fields.Attribute('Hash') + + @fields.on_change(['hash']) + def on_react(self, name, value): + assert name == 'hash' + self.react = "%s+4" % (value) + + person = Person() + person.hash = 'hola' + assert '' == person.to_xml() + +def test_model_one2many(): + class Line(facho.model.Model): + __name__ = 'Line' + + quantity = fields.Attribute('quantity') + + class Invoice(facho.model.Model): + __name__ = 'Invoice' + + lines = fields.One2Many(Line) + + invoice = Invoice() + line = invoice.lines.create() + line.quantity = 3 + line = invoice.lines.create() + line.quantity = 5 + assert '' == invoice.to_xml() + + +def test_model_one2many_with_on_changes(): + class Line(facho.model.Model): + __name__ = 'Line' + + quantity = fields.Attribute('quantity') + + class Invoice(facho.model.Model): + __name__ = 'Invoice' + + lines = fields.One2Many(Line) + count = fields.Attribute('count', default=0) + + @fields.on_change(['lines']) + def refresh_count(self, name, value): + self.count = len(self.lines) + + invoice = Invoice() + line = invoice.lines.create() + line.quantity = 3 + line = invoice.lines.create() + line.quantity = 5 + + assert len(invoice.lines) == 2 + assert '' == invoice.to_xml() + +def test_model_one2many_as_list(): + class Line(facho.model.Model): + __name__ = 'Line' + + quantity = fields.Attribute('quantity') + + class Invoice(facho.model.Model): + __name__ = 'Invoice' + + lines = fields.One2Many(Line) + + invoice = Invoice() + line = invoice.lines.create() + line.quantity = 3 + line = invoice.lines.create() + line.quantity = 5 + + lines = list(invoice.lines) + assert len(list(invoice.lines)) == 2 + + for line in lines: + assert isinstance(line, Line) + assert '' == invoice.to_xml() + + +def test_model_attributes_order(): + class Line(facho.model.Model): + __name__ = 'Line' + + quantity = fields.Attribute('quantity') + + class Invoice(facho.model.Model): + __name__ = 'Invoice' + + line1 = fields.Many2One(Line, name='Line1') + line2 = fields.Many2One(Line, name='Line2') + line3 = fields.Many2One(Line, name='Line3') + + + invoice = Invoice() + invoice.line2.quantity = 2 + invoice.line3.quantity = 3 + invoice.line1.quantity = 1 + + assert '' == invoice.to_xml() + + +def test_field_amount(): + class Line(facho.model.Model): + __name__ = 'Line' + + amount = fields.Amount(name='Amount', precision=1) + amount_as_attribute = fields.Attribute('amount') + + @fields.on_change(['amount']) + def on_amount(self, name, value): + self.amount_as_attribute = self.amount + + line = Line() + line.amount = 33 + + assert '' == line.to_xml() + + +def test_model_setup(): + class Line(facho.model.Model): + __name__ = 'Line' + + amount = fields.Attribute(name='amount') + + def __setup__(self): + self.amount = 23 + + line = Line() + assert '' == line.to_xml() diff --git a/tests/test_model_invoice.py b/tests/test_model_invoice.py new file mode 100644 index 0000000..f4b98df --- /dev/null +++ b/tests/test_model_invoice.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# This file is part of facho. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +"""Nuevo esquema para modelar segun decreto""" + +from datetime import datetime + +import pytest +from lxml import etree +import facho.fe.model as model +import facho.fe.form as form +from facho import fe +import helpers + +def simple_invoice(): + invoice = model.Invoice() + invoice.dian.software_security_code = '12345' + invoice.dian.software_provider.provider_id = 'provider-id' + invoice.dian.software_provider.software_id = 'facho' + invoice.dian.control.prefix = 'SETP' + invoice.dian.control.from_range = '1000' + invoice.dian.control.to_range = '1000' + invoice.id = '323200000129' + invoice.issue = datetime.strptime('2019-01-16 10:53:10-05:00', '%Y-%m-%d %H:%M:%S%z') + invoice.supplier.party.id = '700085371' + invoice.customer.party.id = '800199436' + + line = invoice.lines.create() + line.add_tax(model.Taxes.Iva(19.0)) + + # TODO(bit4bit) acoplamiento temporal + # se debe crear primero el subotatl + # para poder calcularse al cambiar el precio + line.quantity = 1 + line.price = 1_500_000 + + return invoice + +def test_simple_invoice_cufe(): + token = '693ff6f2a553c3646a063436fd4dd9ded0311471' + environment = fe.AMBIENTE_PRODUCCION + invoice = simple_invoice() + assert invoice.cufe(token, environment) == '8bb918b19ba22a694f1da11c643b5e9de39adf60311cf179179e9b33381030bcd4c3c3f156c506ed5908f9276f5bd9b4' + +def test_simple_invoice_sign_dian(monkeypatch): + invoice = simple_invoice() + + xmlstring = invoice.to_xml() + p12_data = open('./tests/example.p12', 'rb').read() + signer = fe.DianXMLExtensionSigner.from_bytes(p12_data) + + with monkeypatch.context() as m: + helpers.mock_urlopen(m) + xmlsigned = signer.sign_xml_string(xmlstring) + assert "Signature" in xmlsigned + + +def test_dian_extension_authorization_provider(): + invoice = simple_invoice() + xml = fe.FeXML.from_string(invoice.to_xml()) + provider_id = xml.get_element('/fe:Invoice/ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent/sts:DianExtensions/sts:AuthorizationProvider/sts:AuthorizationProviderID') + + assert provider_id.attrib['schemeID'] == '4' + assert provider_id.attrib['schemeName'] == '31' + assert provider_id.attrib['schemeAgencyName'] == 'CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)' + assert provider_id.attrib['schemeAgencyID'] == '195' + assert provider_id.text == '800197268' + +def test_invoicesimple_xml_signed_using_fexml(monkeypatch): + invoice = simple_invoice() + + xml = fe.FeXML.from_string(invoice.to_xml()) + + signer = fe.DianXMLExtensionSigner('./tests/example.p12') + + print(xml.tostring()) + with monkeypatch.context() as m: + import helpers + helpers.mock_urlopen(m) + xml.add_extension(signer) + + elem = xml.get_element('/fe:Invoice/ext:UBLExtensions/ext:UBLExtension[2]/ext:ExtensionContent/ds:Signature') + assert elem.text is not None + +def test_invoice_supplier_party(): + invoice = simple_invoice() + invoice.supplier.party.name = 'superfacho' + invoice.supplier.party.tax_scheme.registration_name = 'legal-superfacho' + invoice.supplier.party.contact.email = 'superfacho@etrivial.net' + + xml = fe.FeXML.from_string(invoice.to_xml()) + + name = xml.get_element('/fe:Invoice/cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name') + assert name.text == 'superfacho' + + name = xml.get_element('/fe:Invoice/cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:RegistrationName') + assert name.text == 'legal-superfacho' + + name = xml.get_element('/fe:Invoice/cac:AccountingSupplierParty/cac:Party/cac:Contact/cbc:ElectronicEmail') + assert name.text == 'superfacho@etrivial.net' + +def test_invoice_customer_party(): + invoice = simple_invoice() + invoice.customer.party.name = 'superfacho-customer' + invoice.customer.party.tax_scheme.registration_name = 'legal-superfacho-customer' + invoice.customer.party.contact.email = 'superfacho@etrivial.net' + + xml = fe.FeXML.from_string(invoice.to_xml()) + + name = xml.get_element('/fe:Invoice/cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name') + assert name.text == 'superfacho-customer' + + name = xml.get_element('/fe:Invoice/cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:RegistrationName') + assert name.text == 'legal-superfacho-customer' + + name = xml.get_element('/fe:Invoice/cac:AccountingCustomerParty/cac:Party/cac:Contact/cbc:ElectronicEmail') + assert name.text == 'superfacho@etrivial.net'