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%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'