﻿---
title: "Escribiendo un Módulo de Tryton ERP Básico 2"
date: 2023-06-22T16:40:04-05:00
draft: false
---

Ahora el siguiente paso para personalizar trytond a nivel de código es la creación de modelos,
para una solución efectiva al problema vamos a dar prioridad a la comunicación entre modelos 
para expresar la solución, es decir la solución no esta en los modelos en si sino en la conversación entre estos,
lo anterior es importante ternelo presente cuando modelamos ya que trytond
sigue el patron [Active Record](https://martinfowler.com/eaaCatalog/activeRecord.html) el
cual nos invita a adherir la solución (comportamiento) a las carácteristicas (attributos)
y apoyados [en indicar que hacer en vez de preguntar que hacer](https://www.martinfowler.com/bliki/TellDontAsk.html)
podemos hacer más flexible el modelado antes los cambios, ahora
cuando hablo de conversación entre modelos técnicamente seria la llamada
de los métodos de los objetos representativos de los modelos, veamos un ejercicio un poco
más complejo que el presentado en **Básico 1**.

Digamos que nuestro cliente vende minutos de telefonía y mensajes de texto SMS,
actualmente se encuentra con el inconveniente que no tiene una plataforma para llevar
control de la facturación y una vez conversado sobre el asunto
se llega a la conclusión de que:

* Tryton ERP es el software adecuado
* se debe vincular Tryton ERP a la plataforma de telefonía para llevar el control.

En la siguiente conversación con el equipo de telefonía, nos comentan
que diariamente a las 00:00-05 suben el archivo a un servidor y nos podrían
dar acceso por medio de HTTP para descargar el archivo de nombre formateado `yyyy-mm-dd.csv`

```csv
client_code,billsec,sms,day
AB123,1000,1,2023-06-22
CP535,2000,100,2023-06-22
```

Con la información anterior se plantean los siguientes escenarios con el cliente
para coordinar la entregar del proyecto:

* Como administrador debo ser capaz de asociar tercero a un cliente de telefonía.
* Diariamente conciliar los saldos entregados por telefonía.

### creamos el modulo

```sh @mdtoapp @execute @silence @tdd @artifact
pip3 install trytond==6.8.0 --user
pip3 install proteus --user

cookiecutter git+https://gitlab.com/bit4bit/cookiecutter-tryton-module.git --directory template --no-input module_name=charging_tel version=6.8.0 test_with_scenario=charging_tel.rst
```

```sh @mdtoapp @execute @silence @tdd
cd charging_tel
python3 setup.py develop --user
```

confirmamos que tengamos operatividad

```sh @mdtoapp @execute
cd charging_tel
python3 -munittest
```
<pre>
Ran 23 tests in 4.381s

OK
</pre>

#### Escenario: Como administrador debo ser capaz de asociar Party a un cliente de telefonía.

**tests/scenario_administrator_create_party_with_client_code.rst**
```rst @mdtoapp file:charging_tel/tests/scenario_administrator_party_with_client_code.rst
===================
Administrador Crear Party con campo client_code Scenario
===================

Dependencias::

    >>> from proteus import Model, Wizard
    >>> from trytond.tests.tools import activate_modules

Activacion de modulos::

	>>> config = activate_modules('charging_tel')

Party con client_code::

    >>> Party = Model.get('party.party')
	>>> party = Party()
    >>> party.client_code = 'AB123' # valor real uno de los entregados
    >>> party.save()
    >>> party.client_code
    'AB123'
```

```sh @mdtoapp @execute
python3 -munittest
```
<pre>
Failed example:
    Party = Model.get('party.party')
	...
        return self._pool[self.database_name][type][name]
    KeyError: 'party.party'
</pre>

`KeyError: 'party.party'` ya conocemos que debemos hacer

```sh @mdtoapp @execute @silence
pip3 install trytond-party==6.8.0 --user
```
**tryton.cfg**
```config @mdtoapp @create file:charging_tel/tryton.cfg
[tryton]
version=6.8.0
depends:
    ir
    party
xml:
```
```sh @mdtoapp @execute
python3 -munittest
```
<pre>
Failed example:
    party.client_code = 'AB123' # valor real uno de los entregados
Exception raised:
    Traceback (most recent call last):
      File "/usr/lib/python3.9/doctest.py", line 1336, in __run
        exec(compile(example.source, filename, "single",
      File "<doctest scenario_party_with_client_code.rst[5]>", line 1, in <module>
        party.client_code = 'AB123' # valor real uno de los entregados
    AttributeError: 'party.party' object has no attribute 'client_code'
</pre>

`AttributeError: 'party.party'` ya conocemos que debemos hacer

**party.py**
```python @mdtoapp @create file:charging_tel/party.py
from trytond.pool import PoolMeta
from trytond.model import fields

class Party(metaclass=PoolMeta):
    __name__ = 'party.party'

    # campo para relacionar party con el cliente de telefonia
    client_code = fields.Char('Client Code', required=False)
```

Le indicamos a trytond el modelo que hemos extendido

**__init__.py**
```python @mdtoapp @create file:charging_tel/__init__.py
from trytond.pool import Pool

from . import party

__all__ = ['register']

def register():
    Pool.register(
        party.Party,
        module='charging_tel', type_='model')
```
```sh @mdtoapp @execute
python3 -munittest
```
<pre>
Ran 24 tests in 8.997s

OK
</pre>

por ahora dejare por fuera la gestión de permisos por usuario, ya
que los usuarios no administradores no deberían ser capaces de cambiar el campo
`client_code`

## ahora pasamos a verificar que el client_code sea en el formato esperado

**tests/test_party_client_code.py**
```python @mdtoapp file:charging_tel/tests/test_party_client_code.py
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.pool import Pool
from trytond.model.exceptions import ValidationError

class Party_client_code_TestCase(ModuleTestCase):
    "Test Party client_code"
    module = 'charging_tel'

    @with_transaction()
    def test_para_entrada_invalida_hay_error(self):
        pool = Pool()
        Party = pool.get('party.party')

        party, = Party.create([{'name': 'TEL'}])

        with self.assertRaises(ValidationError):
            party.client_code = '123546'
            party.save()

        with self.assertRaises(ValidationError):
            party.client_code = '3515ABC'
            party.save()

        with self.assertRaises(ValidationError):
            party.client_code = '_535'
            party.save()

        with self.assertRaises(ValidationError):
            party.client_code = 'A535.'
            party.save()

    @with_transaction()
    def test_para_entrada_valida_no_hay_error(self):
        pool = Pool()
        Party = pool.get('party.party')

        party, = Party.create([{'name': 'TELO'}])

        party.client_code = 'AB123'
        party.save()

        party.client_code = 'CP535'
        party.save()

del ModuleTestCase
```

```sh @mdtoapp @execute
python3 -munittest
```
<pre>
Traceback (most recent call last):
  File "/home/bit4bit/.local/lib/python3.9/site-packages/trytond/tests/test_tryton.py", line 223, in wrapper
    result = func(*args, **kwargs)
  File "/tmp/d20230622-6588-e3ry82/charging_tel/tests/test_party_client_code.py", line 18, in test_para_entrada_invalida_UserError
    party.save()
AssertionError: ValidationError not raised
</pre>

`AssertionError: ValidationError not raised` ya conocemos como proceder

**party.py**
```python @mdtoapp @create file:charging_tel/party.py
import re

from trytond.pool import PoolMeta
from trytond.model import fields
from trytond.i18n import gettext
from trytond.model.exceptions import ValidationError

class Party(metaclass=PoolMeta):
    __name__ = 'party.party'

    # relacionamos party con cliente de telefonia
    client_code = fields.Char('Client Code', required=False)

    @classmethod
    def validate_fields(cls, records, field_names):
        super().validate_fields(records, field_names)
        cls.check_fields(records, field_names)

    @classmethod
    def check_fields(cls, records, field_names):
        if field_names and not (field_names & {'client_code'}):
            return

        for record in records:
            if not cls.is_allowed_client_code(record.client_code):
                raise ValidationError(gettext('charging_tel.client_code_invalid'))

    @classmethod
    def is_allowed_client_code(cls, code):
        # opcional
        if code is None:
            return True

        # formato entregado por area de telefonia
        return bool(re.match(r"^[A-Z]{2,10}[0-9]{2,8}$", code))
```
```sh @mdtoapp @execute
python3 -munittest
```
<pre>
Ran 47 tests in 11.937s

OK
</pre>

a este punto nos preguntamos si `client_code` debe ser único por cliente,
lo cual es luego confirmado por el equipo técnico de telefonía,
entonces procedemos confirmar esta restricción.

**tests/test_party_client_code.py**
```python @mdtoapp file:charging_tel/tests/test_party_client_code.py
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.pool import Pool
from trytond.model.exceptions import ValidationError

class Party_client_code_TestCase(ModuleTestCase):
    "Test Party client_code"
    module = 'charging_tel'

    @with_transaction()
    def test_para_entrada_valida_debe_ser_unico(self):
        pool = Pool()
        Party = pool.get('party.party')

        _, party = Party.create([{'name': 'TEL A', 'client_code': 'AB123'},
                                 {'name': 'TEL B'}])

        with self.assertRaises(Exception):
            party.client_code = 'AB123'
            party.save()

    @with_transaction()
    def test_para_entrada_invalida_hay_error(self):
        pool = Pool()
        Party = pool.get('party.party')

        party, = Party.create([{'name': 'TEL'}])

        with self.assertRaises(ValidationError):
            party.client_code = '123546'
            party.save()

        with self.assertRaises(ValidationError):
            party.client_code = '3515ABC'
            party.save()

        with self.assertRaises(ValidationError):
            party.client_code = '_535'
            party.save()

        with self.assertRaises(ValidationError):
            party.client_code = 'A535.'
            party.save()

    @with_transaction()
    def test_para_entrada_valida_no_hay_error(self):
        pool = Pool()
        Party = pool.get('party.party')

        party, = Party.create([{'name': 'TELO'}])

        party.client_code = 'AB123'
        party.save()

        party.client_code = 'CP535'
        party.save()

del ModuleTestCase
```

```sh @mdtoapp @execute
python3 -munittest
```
<pre>
Traceback (most recent call last):
  File "/home/bit4bit/.local/lib/python3.9/site-packages/trytond/tests/test_tryton.py", line 223, in wrapper
    result = func(*args, **kwargs)
  File "/tmp/d20230622-7701-1wybgva/charging_tel/tests/test_party_client_code.py", line 99, in test_para_entrada_valida_debe_ser_unico
    party.save()
AssertionError: Exception not raised
</pre>

aja ya sabemos que hacer ;)

**party.py**
```python @mdtoapp @create file:charging_tel/party.py
import re

from trytond.pool import PoolMeta
from trytond.model import (Unique, fields)
from trytond.i18n import gettext
from trytond.model.exceptions import ValidationError

class Party(metaclass=PoolMeta):
    __name__ = 'party.party'

    # relacionamos party con cliente de telefonia
    client_code = fields.Char('Client Code', required=False)

    @classmethod
    def __setup__(cls):
        super().__setup__()

        # ver https://docs.tryton.org/projects/server/en/latest/ref/models.html#trytond.model.ModelSQL._sql_constraints
        t = cls.__table__()
        # hacemos efectiva la restriccion desde base de datos
        cls._sql_constraints += [
            ('client_code_unique', Unique(t, t.client_code),
             'charging_tel.msg_client_code_unique')
        ]

    @classmethod
    def validate_fields(cls, records, field_names):
        super().validate_fields(records, field_names)
        cls.check_fields(records, field_names)

    @classmethod
    def check_fields(cls, records, field_names):
        if field_names and not (field_names & {'client_code'}):
            return

        for record in records:
            if not cls.is_allowed_client_code(record.client_code):
                raise ValidationError(gettext('charging_tel.client_code_invalid'))

    @classmethod
    def is_allowed_client_code(cls, code):
        # opcional
        if code is None:
            return True

        # formato entregado por area de telefonia
        return bool(re.match(r"^[A-Z]{2,10}[0-9]{2,8}$", code))
```
```sh @mdtoapp @execute
python3 -munittest
```
<pre>
Ran 49 tests in 12.318s

OK
</pre>


#### Escenario: Como servicio autónomo concilio diariamente los saldos entregados por telefonía.

**tests/scenario_create_recharge_tel.rst**
```rst @mdtoapp file:charging_tel/tests/scenario_create_charge_tel.rst
===================
Crear Cargo de Telefonia
===================

Dependencias::

    >>> from proteus import Model, Wizard
    >>> from trytond.tests.tools import activate_modules

Activacion de modulos::

    >>> config = activate_modules('charging_tel')

Crear cargo de telefonia:

    >>> example_csv = open('tests/tel_2023-06-23.csv').read().encode()
    >>> ChargeTel = Model.get('charging_tel.charge_tel')
	>>> ChargeTel.import_csv(example_csv)
	>>> len(ChargeTel.find())
	2
```

```sh @mdtoapp @execute
python3 -munittest
```
<pre>
Failed example:
    example_csv = open('tests/tel_2023-06-23.csv').read().encode()
Exception raised:
    Traceback (most recent call last):
      File "/usr/lib/python3.9/doctest.py", line 1336, in __run
        exec(compile(example.source, filename, "single",
      File "<doctest scenario_create_charge_tel.rst[3]>", line 1, in <module>
        example_csv = open('tests/tel_2023-06-23.csv').read().encode()
    FileNotFoundError: [Errno 2] No such file or directory: 'tests/tel_2023-06-23.csv'
</pre>

Efectivamente nos informa que no tenemos el archivo de muestra, vamos a crearlo

**tests/tel_2023-06-23.csv**
```csv @mdtoapp file:charging_tel/tests/tel_2023-06-23.csv
client_code,billsec,sms,day
ABC123,1000,1,2023-06-22
CP535,2000,100,2023-06-22
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
Failed example:
    ChargeTel = Model.get('charging_tel.charge_tel')
	...
		return self._pool[self.database_name][type][name]
    KeyError: 'charging_tel.charge_tel'

</pre>


Aja, ya vamos volviéndonos hábiles en esto, ya sabemos que debemos hacer y es crear el modelo,
para esto debemos indicarle a trytond que nuestro modelo va a gestionar [datos extendiendo la clase de `ModelSQL`](https://docs.tryton.org/projects/server/en/latest/tutorial/module/model.html)
y que a su vez [va ser visible al usuario `ModelView`](https://docs.tryton.org/projects/server/en/latest/tutorial/module/view.html) adicionalmente le indicamos
cuales son los [campos](https://docs.tryton.org/projects/server/en/latest/ref/fields.html#field) que vamos a gestionar.

**charge_tel.py**
```python @mdtoapp @create file:charging_tel/charge_tel.py
from trytond.model import ModelView, ModelSQL, fields

# le indicamos a trytond que nuestra modelo va a gestionar datos `ModelSQL`
# y va ha ser presentable desde la interfaz `ModelView`
class ChargeTel(ModelSQL, ModelView):
    "Charge Tel"
    __name__ = 'charging_tel.charge_tel'

    # habilitamos `readonly` para indicar que no puede ser editado luego de creado
    client_code = fields.Char('Client Code', readonly=True)
    billsec = fields.Integer('Billsec', readonly=True)
    sms = fields.Integer('SMS', readonly=True)
    day = fields.Date('DAY', readonly=True)
```

**__init__.py**
```python @mdtoapp @create file:charging_tel/__init__.py
from trytond.pool import Pool

from . import party
from . import charge_tel

__all__ = ['register']

def register():
    Pool.register(
        party.Party,
        charge_tel.ChargeTel,
        module='charging_tel', type_='model')
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
Failed example:
    ChargeTel.import_csv(example_csv)
Exception raised:
    Traceback (most recent call last):
      File "/usr/lib/python3.9/doctest.py", line 1336, in __run
        exec(compile(example.source, filename, "single",
      File "<doctest scenario_create_charge_tel.rst[5]>", line 1, in <module>
        ChargeTel.import_csv(example_csv)
    AttributeError: type object 'charging_tel.charge_tel' has no attribute 'import_csv'
</pre>


**charge_tel.py**
```python @mdtoapp @create file:charging_tel/charge_tel.py
from trytond.model import ModelView, ModelSQL, fields

class ChargeTel(ModelSQL, ModelView):
    "Charge Tel"
    __name__ = 'charging_tel.charge_tel'

    # habilitamos `readonly` para indicar que no puede ser editado luego de creado
    client_code = fields.Char('Client Code', readonly=True)
    billsec = fields.Integer('Billsec', readonly=True)
    sms = fields.Integer('SMS', readonly=True)
    day = fields.Date('DAY', readonly=True)

    @classmethod
    def import_csv(cls, path):
        pass
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
Failed example:
    ChargeTel.import_csv(example_csv)
Exception raised:
    Traceback (most recent call last):
      File "/usr/lib/python3.9/doctest.py", line 1336, in __run
        exec(compile(example.source, filename, "single",
      File "<doctest scenario_create_charge_tel.rst[5]>", line 1, in <module>
        ChargeTel.import_csv(example_csv)
    AttributeError: type object 'charging_tel.charge_tel' has no attribute 'import_csv'
</pre>


uuu nos indica que el modelo no tiene el atributo `import_csv`, que en nuestro caso
esperamos sea el método de importación, podríamos hacer accesible este método a proteus usando el mecanismo **RPC** de trytond pero en nuestro caso publicaríamos
este método solo con el objetivo de realizar la prueba, estas situaciones
es un indicio que el camino que hemos tomado no es el mas adecuado sea que
la prueba que planteamos es confusa o bien el mecanismo para probar no es el mas adecuado,
vamos a trasladar la prueba como prueba de unidad.

```sh @mdtoapp @execute @tdd @artifact chdir:charging_tel
rm -f tests/scenario_create_charge_tel.rst
```

**tests/test_create_charge_tel.py**
```python @mdtoapp file:charging_tel/tests/test_create_charge_tel.py
import datetime

from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.pool import Pool

class CreateChargeTel(ModuleTestCase):
    "Test Create Charge Tel"
    module = 'charging_tel'

    @with_transaction()
    def test_crear_cargos_desde_csv(self):
        pool = Pool()
        ChargeTel = pool.get('charging_tel.charge_tel')

        ChargeTel.import_csv('tests/tel_2023-06-23.csv')

        charges = ChargeTel.search([])
        got = [{'client_code': c.client_code,
                          'billsec': c.billsec,
                          'sms': c.sms,
                          'day': c.day} for c in charges]
        self.assertListEqual([
            {'client_code': 'ABC123',
             'billsec': 1000,
             'sms': 1,
             'day': datetime.date(2023, 6, 22)},
            {'client_code': 'CP535',
             'billsec': 2000,
             'sms': 100,
             'day': datetime.date(2023, 6, 22)}
        ], got)
        

del ModuleTestCase
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
Traceback (most recent call last):
  File "/home/bit4bit/.local/lib/python3.9/site-packages/trytond/tests/test_tryton.py", line 223, in wrapper
    result = func(*args, **kwargs)
  File "/tmp/d20230623-7356-1tmqcx4/charging_tel/tests/test_create_charge_tel.py", line 16, in test_crear_cargos_desde_csv
    self.assertEqual(len(charges), 2)
AssertionError: 0 != 2

----------------------------------------------------------------------
Ran 72 tests in 15.397s

FAILED (failures=1, errors=1)
</pre>

excelente ya nos reconoció el método `import_csv` y ya con la prueba ilustrando nos,
procedemos a realizar los ajustes sobre el método `import_csv`.

**charge_tel.py**
```python @mdtoapp @create file:charging_tel/charge_tel.py
import csv
from trytond.model import ModelView, ModelSQL, fields

class ChargeTel(ModelSQL, ModelView):
    "Charge Tel"
    __name__ = 'charging_tel.charge_tel'

    # habilitamos `readonly` para indicar que no puede ser editado luego de creado
    client_code = fields.Char('Client Code', readonly=True)
    billsec = fields.Integer('Billsec', readonly=True)
    sms = fields.Integer('SMS', readonly=True)
    day = fields.Date('DAY', readonly=True)

    @classmethod
    def import_csv(cls, path):
        # https://docs.python.org/3/library/csv.html
        with open(path, newline='') as csvfile:
            reader = list(csv.DictReader(csvfile))
            return cls.create(reader)
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
Ran 72 tests in 15.477s

OK
</pre>

muy bien, ya tenemos el metodo que nos importaría desde csv los cargos de telefonía

ahora vamos a proceder a obtener el archivo desde el endpoint entregado por telefonía.

**tests/test_import_remote_charge_tel.py**
```python @mdtoapp file:charging_tel/tests/test_import_remote_charge_tel.py
import datetime
import unittest

from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.pool import Pool

class ImportFromRemoteChargeTel(ModuleTestCase):
    "Import from remote endpoint Charge Tel"
    module = 'charging_tel'

    @with_transaction()
    def test_importar_cargos_desde_servicio_remote(self):
        pool = Pool()
        ChargeTel = pool.get('charging_tel.charge_tel')
        # sustituimos el metodo de descarga por una version
        # ajustada para las pruebas, evitamos las acciones sobre
        # el servicio remoto
        ChargeTel._downloader = self._fake_downloader

        ChargeTel.import_remote()

        charges = ChargeTel.search([])
        got = [{'client_code': c.client_code,
                          'billsec': c.billsec,
                          'sms': c.sms,
                          'day': c.day} for c in charges]
        self.assertListEqual([
            {'client_code': 'ABC123',
             'billsec': 1000,
             'sms': 1,
             'day': datetime.date(2023, 6, 22)},
            {'client_code': 'CP535',
             'billsec': 2000,
             'sms': 100,
             'day': datetime.date(2023, 6, 22)}
        ], got)

    def _fake_downloader(self, year, month, day):
        with open('tests/tel_2023-06-23.csv') as f:
            return f.read().encode()

del ModuleTestCase    
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
Traceback (most recent call last):
  File "/home/bit4bit/.local/lib/python3.9/site-packages/trytond/tests/test_tryton.py", line 223, in wrapper
    result = func(*args, **kwargs)
  File "/tmp/d20230623-35869-15s1jvk/charging_tel/tests/test_import_remote_charge_tel.py", line 16, in test_importar_cargos_desde_servicio_remote
    ChargeTel.import_remote(downloader=self._fake_downloader)
AttributeError: type object 'charging_tel.charge_tel' has no attribute 'import_remote'
</pre>

Tal cual, ya vamos conociendo el ciclo de: especular, ilustrar y ajustar :).

**charge_tel.py**
```python @mdtoapp @create file:charging_tel/charge_tel.py
import csv
from trytond.model import ModelView, ModelSQL, fields

class ChargeTel(ModelSQL, ModelView):
    "Charge Tel"
    __name__ = 'charging_tel.charge_tel'

    # habilitamos `readonly` para indicar que no puede ser editado luego de creado
    client_code = fields.Char('Client Code', readonly=True)
    billsec = fields.Integer('Billsec', readonly=True)
    sms = fields.Integer('SMS', readonly=True)
    day = fields.Date('DAY', readonly=True)

    @classmethod
    def import_csv(cls, path):
        # https://docs.python.org/3/library/csv.html
        with open(path, newline='') as csvfile:
            reader = list(csv.DictReader(csvfile))
            return cls.create(reader)

    @classmethod
    def import_remote(cls, downloader=None):
        if downloader is None:
            downloader = cls._downloader
        pass

    @classmethod
    def _downloader(cls):
        pass
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
First list contains 2 additional elements.
First extra element 0:
{'client_code': 'ABC123', 'billsec': 1000, 'sms': 1, 'day': datetime.date(2023, 6, 22)}

+ []
- [{'billsec': 1000,
-   'client_code': 'ABC123',
-   'day': datetime.date(2023, 6, 22),
-   'sms': 1},
-  {'billsec': 2000,
-   'client_code': 'CP535',
-   'day': datetime.date(2023, 6, 22),
-   'sms': 100}]
</pre>

Excelente, ya no tenemos errores por parte Python, ahora la prueba nos
indica que no coinciden los valores, pasemos a ajustar la implementación es
decir vamos a sustituir los `pass` por nuestra primera versión de código ejecutable.

**charge_tel.py**
```python @mdtoapp @create file:charging_tel/charge_tel.py
import csv
import tempfile
import urllib.request

from trytond.model import ModelView, ModelSQL, fields
from trytond.pool import Pool

class ChargeTel(ModelSQL, ModelView):
    "Charge Tel"
    __name__ = 'charging_tel.charge_tel'

    # habilitamos `readonly` para indicar que no puede ser editado luego de creado
    # si esto es un requerimiento por parte del cliente, debemos realizar
    # una prueba para garantizar este comportamiento.
	# TODO: como procedemos si la codificacion es incorrecta a la entregada?
    client_code = fields.Char('Client Code', readonly=True)
    billsec = fields.Integer('Billsec', readonly=True)
    sms = fields.Integer('SMS', readonly=True)
    day = fields.Date('DAY', readonly=True)

    @classmethod
    def import_csv(cls, path):
        # https://docs.python.org/3/library/csv.html
        with open(path, newline='') as csvfile:
            reader = list(csv.DictReader(csvfile))
            return cls.create(reader)

    @classmethod
    def import_remote(cls, downloader=None):
        pool = Pool()
        Date = pool.get('ir.date')
        today = Date.today()

        content = cls._downloader(year=today.year,
                                  month=today.month,
                                  day=today.day)
        
        with tempfile.NamedTemporaryFile(prefix='charge-tel') as f:
            f.write(content)
            f.flush()
            cls.import_csv(f.name)

    @classmethod
    def _downloader(cls, year, month, day):
        return urllib.request.urlopen(f"http://example.com/{year}-{month}-{day}.csv").read()
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```

<pre>
Ran 95 tests in 0.579s

OK
</pre>

uii yuiyuii, volvimos a estar estable y ya mas cerca de cerrar este escenario,
ahora para hacer la importación diaria podemos usar [Cron](https://docs.tryton.org/projects/server/en/latest/topics/cron.html) de trytond que nos facilita
programar acciones.

**tests/scenario_cron_importer.rst**
```rst @mdtoapp @create file:charging_tel/tests/scenario_cron_importer.rst
=====================
Cron Charge Importer
=====================

Dependencias::

    >>> from proteus import Model, Wizard
    >>> from trytond.tests.tools import activate_modules

Activacion de modulos::

	>>> config = activate_modules('charging_tel')

Disparar importador desde Cron::

    >>> Cron = Model.get('ir.cron')
	>>> cron = Cron()
	>>> cron.method = 'charging_tel.charge_tel|import_remote'
	>>> cron.interval_number = 1
	>>> cron.interval_type = 'days'
	>>> cron.save()
	>>> cron.click('run_once')
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```

<pre>
       raise SelectionValidationError(gettext(
    trytond.model.modelstorage.SelectionValidationError: The value "charging_tel.charge_tel" for field "Method" in "4" of "Cron" is not one of the allowed options. -
</pre>

como nos indica el mensaje de error no logra ubicar ese valor, el ajuste que podemos
hacer es indicarle a `Cron` acerca de nuestro interés.

**cron.py**
```python @mdtoapp @create file:charging_tel/cron.py
from trytond.pool import PoolMeta

class ChargeTelCron(metaclass=PoolMeta):
    __name__ = 'ir.cron'

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.method.selection.append(
            ('charging_tel.charge_tel|import_remote', "Import Charges From Remote Server"),
            )
```

e informamos a trytond

**__init__.py**
```python @mdtoapp @create file:charging_tel/__init__.py
from trytond.pool import Pool

from . import party
from . import charge_tel
from . import cron

__all__ = ['register']

def register():
    Pool.register(
        party.Party,
        charge_tel.ChargeTel,
        cron.ChargeTelCron,
        module='charging_tel', type_='model')
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
Ran 95 tests in 0.585s

OK
</pre>

muy bien todo sigue operativo y ademas ya hemos indicado a trytond nuestra
intención de poder registrar un Cron para importar los registros del servidor remoto.

Ahora aunque tenemos una implementación trabajando no necesariamente es la mas adecuada,
este punto es crucial ya que si continuamos adicionando caracteristicas sin reajustar el código
a nuevos conceptos o eliminando los que ya cambiaron, rápidamente perderíamos el control 
sobre la mantenibilidad y legibilidad, las palabras que usamos en clases, métodos,variables,archivos o carpetas
nos crean un vocabulario y si este vocabulario es obsoleto o distorsionado se nos dificultaría
entender que es lo que el software esta realizando o bien este como participa de los objetivos del cliente,
lo anterior es llamado [Refactorizacion](https://martinfowler.com/tags/refactoring.html), si queremos
construir un software solido debemos constantemente estar refactorizando.

Como vemos el modelo **ChargeTel** tienen los métodos **import_csv** y **import_remote** que tiene en común importar un archivo de formato csv,
con los cargos de telefonía? nada, cuando tenemos un comportamiento que no aporta al modelo, movemos este comportamiento por fuera del modelo.

**charge_tel_importer.py**
```python @mdtoapp @create file:charging_tel/charge_tel_importer.py
from trytond.model import Model
from trytond.pool import Pool

class ChargeTelImporter(Model):
    "Charge Tel Importer"
    __name__ = 'charging_tel.charge_tel_importer'
    
    @classmethod
    def import_csv(cls, path):
        # https://docs.python.org/3/library/csv.html
        with open(path, newline='') as csvfile:
            reader = list(csv.DictReader(csvfile))
            return cls.create(reader)

    @classmethod
    def import_remote(cls, downloader=None):
        pool = Pool()
        Date = pool.get('ir.date')
        today = Date.today()

        content = cls._downloader(year=today.year,
                                  month=today.month,
                                  day=today.day)
        
        with tempfile.NamedTemporaryFile(prefix='charge-tel') as f:
            f.write(content)
            f.flush()
            cls.import_csv(f.name)

    @classmethod
    def _downloader(cls, year, month, day):
        return urllib.request.urlopen(f"http://example.com/{year}-{month}-{day}.csv").read()
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```

<pre>
  File "/home/bit4bit/.local/lib/python3.9/site-packages/trytond/pool.py", line 190, in get
    return self._pool[self.database_name][type][name]
KeyError: 'charging_tel.charge_tel_importer'
</pre>

ya hemos visto anteriormente este error.

**__init__.py**
```python @mdtoapp @create file:charging_tel/__init__.py
from trytond.pool import Pool

from . import party
from . import charge_tel
from . import cron
from . import charge_tel_importer

__all__ = ['register']

def register():
    Pool.register(
        party.Party,
        charge_tel.ChargeTel,
        cron.ChargeTelCron,
        charge_tel_importer.ChargeTelImporter,
        module='charging_tel', type_='model')
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
    reader = list(csv.DictReader(csvfile))
NameError: name 'csv' is not defined
...
    return urllib.request.urlopen(f"http://example.com/{year}-{month}-{day}.csv").read()
NameError: name 'urllib' is not defined
</pre>

uuups faltaron importar las dependencias

**charge_tel_importer.py**
```python @mdtoapp @create file:charging_tel/charge_tel_importer.py
import csv
import urllib
from trytond.model import Model
from trytond.pool import Pool

class ChargeTelImporter(Model):
    "Charge Tel Importer"
    __name__ = 'charging_tel.charge_tel_importer'
    
    @classmethod
    def import_csv(cls, path):
        # https://docs.python.org/3/library/csv.html
        with open(path, newline='') as csvfile:
            reader = list(csv.DictReader(csvfile))
            return cls.create(reader)

    @classmethod
    def import_remote(cls, downloader=None):
        pool = Pool()
        Date = pool.get('ir.date')
        today = Date.today()

        content = cls._downloader(year=today.year,
                                  month=today.month,
                                  day=today.day)
        
        with tempfile.NamedTemporaryFile(prefix='charge-tel') as f:
            f.write(content)
            f.flush()
            cls.import_csv(f.name)

    @classmethod
    def _downloader(cls, year, month, day):
        return urllib.request.urlopen(f"http://example.com/{year}-{month}-{day}.csv").read()
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```

<pre>
    return cls.create(reader)
AttributeError: type object 'charging_tel.charge_tel_importer' has no attribute 'create'
</pre>

epa, eso nos pasa por duplicar el código.

**charge_tel_importer.py**
```python @mdtoapp @create file:charging_tel/charge_tel_importer.py
import csv
import urllib
from trytond.model import Model
from trytond.pool import Pool

class ChargeTelImporter(Model):
    "Charge Tel Importer"
    __name__ = 'charging_tel.charge_tel_importer'
    
    @classmethod
    def import_csv(cls, path):
        pool = Pool()
        ChargeTel = pool.get('charging_tel.charge_tel')
        # https://docs.python.org/3/library/csv.html
        with open(path, newline='') as csvfile:
            reader = list(csv.DictReader(csvfile))
            return ChargeTel.create(reader)

    @classmethod
    def import_remote(cls, downloader=None):
        pool = Pool()
        Date = pool.get('ir.date')
        today = Date.today()

        content = cls._downloader(year=today.year,
                                  month=today.month,
                                  day=today.day)
        
        with tempfile.NamedTemporaryFile(prefix='charge-tel') as f:
            f.write(content)
            f.flush()
            cls.import_csv(f.name)

    @classmethod
    def _downloader(cls, year, month, day):
        return urllib.request.urlopen(f"http://example.com/{year}-{month}-{day}.csv").read()
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```

<pre>
  File "/usr/lib/python3.9/urllib/request.py", line 641, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 404: Not Found
</pre>

`urllib.error.HTTPError: HTTP Error 404: Not Found` nos informa que el un hubo respuesta del servidor,esto nos esta indicando
que la prueba esta haciendo la petición al servidor el cual no existe.

**tests/test_import_remote_charge_tel.py**
```python @mdtoapp file:charging_tel/tests/test_import_remote_charge_tel.py
import datetime
import unittest

from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.pool import Pool

class ImportFromRemoteChargeTel(ModuleTestCase):
    "Import from remote endpoint Charge Tel"
    module = 'charging_tel'

    @with_transaction()
    def test_importar_cargos_desde_servicio_remote(self):
        pool = Pool()
        ChargeTel = pool.get('charging_tel.charge_tel')
        ChargeTelImporter = pool.get('charging_tel.charge_tel_importer')
        # sustituimos el metodo de descarga por una version
        # ajustada para las pruebas, evitamos las acciones sobre
        # el servicio remoto
        ChargeTelImporter._downloader = self._fake_downloader

        ChargeTelImporter.import_remote()

        charges = ChargeTel.search([])
        got = [{'client_code': c.client_code,
                          'billsec': c.billsec,
                          'sms': c.sms,
                          'day': c.day} for c in charges]
        self.assertListEqual([
            {'client_code': 'ABC123',
             'billsec': 1000,
             'sms': 1,
             'day': datetime.date(2023, 6, 22)},
            {'client_code': 'CP535',
             'billsec': 2000,
             'sms': 100,
             'day': datetime.date(2023, 6, 22)}
        ], got)

    def _fake_downloader(self, year, month, day):
        with open('tests/tel_2023-06-23.csv') as f:
            return f.read().encode()

del ModuleTestCase    
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
  File "/tmp/d20230624-6103-16gzx79/charging_tel/charge_tel_importer.py", line 29, in import_remote
    with tempfile.NamedTemporaryFile(prefix='charge-tel') as f:
NameError: name 'tempfile' is not defined
</pre>

`NameError: name 'tempfile' is not defined` de nuevo una dependencia que no trasladamos al crear el importador, acá se ve lo
importante de las pruebas, Python es un código interpretado y hasta que el interprete no ejecute la linea en cuestión no nos enteraríamos
que el comportamiento es inconsistente o bien hasta que el usuario del software lo reporte.


**charge_tel_importer.py**
```python @mdtoapp @create file:charging_tel/charge_tel_importer.py
import csv
import urllib
import tempfile
from trytond.model import Model
from trytond.pool import Pool

class ChargeTelImporter(Model):
    "Charge Tel Importer"
    __name__ = 'charging_tel.charge_tel_importer'
    
    @classmethod
    def import_csv(cls, path):
        pool = Pool()
        ChargeTel = pool.get('charging_tel.charge_tel')
        # https://docs.python.org/3/library/csv.html
        with open(path, newline='') as csvfile:
            reader = list(csv.DictReader(csvfile))
            return ChargeTel.create(reader)

    @classmethod
    def import_remote(cls, downloader=None):
        pool = Pool()
        Date = pool.get('ir.date')
        today = Date.today()

        content = cls._downloader(year=today.year,
                                  month=today.month,
                                  day=today.day)
        
        with tempfile.NamedTemporaryFile(prefix='charge-tel') as f:
            f.write(content)
            f.flush()
            cls.import_csv(f.name)

    @classmethod
    def _downloader(cls, year, month, day):
        return urllib.request.urlopen(f"http://example.com/{year}-{month}-{day}.csv").read()
```

**charge_tel.py**
```python @mdtoapp @create file:charging_tel/charge_tel.py
from trytond.model import ModelView, ModelSQL, fields

class ChargeTel(ModelSQL, ModelView):
    "Charge Tel"
    __name__ = 'charging_tel.charge_tel'

    # habilitamos `readonly` para indicar que no puede ser editado luego de creado
    client_code = fields.Char('Client Code', readonly=True)
    billsec = fields.Integer('Billsec', readonly=True)
    sms = fields.Integer('SMS', readonly=True)
    day = fields.Date('DAY', readonly=True)
```


```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
Ran 95 tests in 0.597s

OK
</pre>


ahora renombramos la prueba para hacer la aclaración de que el responsable de importar es `ChargeTelImporter`.

**tests/test_charge_tel_importer.py**
```python @mdtoapp file:charging_tel/tests/test_charge_tel_importer.py
import datetime

from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.pool import Pool

class ChargeTelImporter(ModuleTestCase):
    "Test Charge Tel Importer"
    module = 'charging_tel'

    @with_transaction()
    def test_crear_cargos_desde_csv(self):
        pool = Pool()
        ChargeTel = pool.get('charging_tel.charge_tel')
        ChargeTelImporter = pool.get('charging_tel.charge_tel_importer')

        ChargeTelImporter.import_csv('tests/tel_2023-06-23.csv')

        charges = ChargeTel.search([])
        got = [{'client_code': c.client_code,
                'billsec': c.billsec,
                'sms': c.sms,
                'day': c.day} for c in charges]
        self.assertListEqual([
            {'client_code': 'ABC123',
             'billsec': 1000,
             'sms': 1,
             'day': datetime.date(2023, 6, 22)},
            {'client_code': 'CP535',
             'billsec': 2000,
             'sms': 100,
             'day': datetime.date(2023, 6, 22)}
        ], got)
        

del ModuleTestCase
```

eliminamos la prueba obsoleta.

```sh @mdtoapp @execute @artifact @tdd chdir:charging_tel
rm tests/test_create_charge_tel.py
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```

<pre>
Ran 95 tests in 0.623s

OK
</pre>

Excelente ya estamos estables nuevamente, ahora vamos actualizar el cron para usar el importador.

**cron.py**
```python @mdtoapp @create file:charging_tel/cron.py
from trytond.pool import PoolMeta

class ChargeTelCron(metaclass=PoolMeta):
    __name__ = 'ir.cron'

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.method.selection.append(
            ('charging_tel.charge_tel_importer|import_remote', "Import Charges From Remote Server"),
            )
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```

<pre>
File "/tmp/d20230624-9646-5mhmjy/charging_tel/tests/scenario_cron_importer.rst", line 21, in scenario_cron_importer.rst
Failed example:
    cron.save()
	...
        raise SelectionValidationError(gettext(
    trytond.model.modelstorage.SelectionValidationError: The value "charging_tel.charge_tel|import_remote" for field "Method" in "4" of "Cron" is not one of the allowed options. - 
</pre>

efectivamente ya Cron no puede ubicar el método, debemos actualizar la prueba ya que cambio el método de importación

**tests/scenario_cron_importer.rst**
```rst @mdtoapp @create file:charging_tel/tests/scenario_cron_importer.rst
======================
Cron Charge Importer
======================

Dependencias::

    >>> from proteus import Model, Wizard
    >>> from trytond.tests.tools import activate_modules

Activacion de modulos::

	>>> config = activate_modules('charging_tel')

Disparar importador desde Cron::

    >>> Cron = Model.get('ir.cron')
	>>> cron = Cron()
	>>> cron.method = 'charging_tel.charge_tel_importer|import_remote'
	>>> cron.interval_number = 1
	>>> cron.interval_type = 'days'
	>>> cron.save()
	>>> cron.click('run_once')
```

```sh @mdtoapp @execute chdir:charging_tel
python3 -munittest
```
<pre>
Ran 96 tests in 0.695s

OK
</pre>


Con la ultima implementación daríamos por cerrado los escenarios planteados
con el cliente ademas quedamos con una suite de pruebas para garantizar lo acordado y documentación de como el software participa de los objetivos del cliente.

Por ultimo dejo como ejercicio al lector la implementación de las vistas.

En resumen:

* creación del módulo usando la plantilla
* escribir el módulo usando el un ciclo de: especular, ilustrar y ajustar
* plantear los escenarios aceptables acordados con el cliente en **doctest**
* informar a trytond nuestra intención por medio de **tryton.cfg** y **__init__.py**
* extender modelo usando *PoolMeta*
* crear nuevo modelo usando *ModelSQL* y *ModelView*
* informar a trytond por medio `ir.cron` la posibilidad de programar tareas recurrentes.

si deseas generar el proyecto creado en este post puedes usar [mdtoapp](/post/mdtoapp)

```sh
ARTIFACT=1 TO=/tmp/basico-2 ruby mdtoapp.rb 'https://chiselapp.com/user/bit4bit/repository/bit4bit_website/raw?ci=tip&filename=content/post%2fwriting_module_trytond_basics_2.md'
```

