Escribiendo un Módulo de Tryton ERP Básico 2

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

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

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
cd charging_tel
python3 setup.py develop --user

confirmamos que tengamos operatividad

cd charging_tel
python3 -munittest
Ran 23 tests in 4.381s

OK

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

tests/scenario_administrator_create_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'
python3 -munittest
Failed example:
    Party = Model.get('party.party')
	...
        return self._pool[self.database_name][type][name]
    KeyError: 'party.party'

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

pip3 install trytond-party==6.8.0 --user

tryton.cfg

[tryton]
version=6.8.0
depends:
    ir
    party
xml:
python3 -munittest
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 "", line 1, in 
        party.client_code = 'AB123' # valor real uno de los entregados
    AttributeError: 'party.party' object has no attribute 'client_code'

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

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

from trytond.pool import Pool

from . import party

__all__ = ['register']

def register():
    Pool.register(
        party.Party,
        module='charging_tel', type_='model')
python3 -munittest
Ran 24 tests in 8.997s

OK

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

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
python3 -munittest
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

AssertionError: ValidationError not raised ya conocemos como proceder

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))
python3 -munittest
Ran 47 tests in 11.937s

OK

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

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
python3 -munittest
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

aja ya sabemos que hacer ;)

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))
python3 -munittest
Ran 49 tests in 12.318s

OK

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

tests/scenario_create_recharge_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
python3 -munittest
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 "", line 1, in 
        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'

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

tests/tel_2023-06-23.csv

client_code,billsec,sms,day
ABC123,1000,1,2023-06-22
CP535,2000,100,2023-06-22
python3 -munittest
Failed example:
    ChargeTel = Model.get('charging_tel.charge_tel')
	...
		return self._pool[self.database_name][type][name]
    KeyError: 'charging_tel.charge_tel'

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 y que a su vez va ser visible al usuario ModelView adicionalmente le indicamos cuales son los campos que vamos a gestionar.

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

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')
python3 -munittest
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 "", line 1, in 
        ChargeTel.import_csv(example_csv)
    AttributeError: type object 'charging_tel.charge_tel' has no attribute 'import_csv'

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
python3 -munittest
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 "", line 1, in 
        ChargeTel.import_csv(example_csv)
    AttributeError: type object 'charging_tel.charge_tel' has no attribute 'import_csv'

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.

rm -f tests/scenario_create_charge_tel.rst

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
python3 -munittest
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)

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

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)
python3 -munittest
Ran 72 tests in 15.477s

OK

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

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    
python3 -munittest
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'

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

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
python3 -munittest
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}]

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

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()
python3 -munittest
Ran 95 tests in 0.579s

OK

uii yuiyuii, volvimos a estar estable y ya mas cerca de cerrar este escenario, ahora para hacer la importación diaria podemos usar Cron de trytond que nos facilita programar acciones.

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')
python3 -munittest
       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. -

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

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

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')
python3 -munittest
Ran 95 tests in 0.585s

OK

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, 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

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()
python3 -munittest
  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'

ya hemos visto anteriormente este error.

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')
python3 -munittest
    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

uuups faltaron importar las dependencias

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()
python3 -munittest
    return cls.create(reader)
AttributeError: type object 'charging_tel.charge_tel_importer' has no attribute 'create'

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

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()
python3 -munittest
  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

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

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    
python3 -munittest
  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

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

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

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)
python3 -munittest
Ran 95 tests in 0.597s

OK

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

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.

rm tests/test_create_charge_tel.py
python3 -munittest
Ran 95 tests in 0.623s

OK

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

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"),
            )
python3 -munittest
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. - 

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

======================
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')
python3 -munittest
Ran 96 tests in 0.695s

OK

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

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'