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'