Práctica: Módulo viajes. Parte 5
Esta lección añade dominios, cálculos, valores por defecto, avisos y restricciones a los modelos existentes. Conservamos todas las relaciones y pestañas de las prácticas anteriores.
1. Dominios y campos calculados #
El dominio del conductor limita las opciones del selector, pero no es una regla de seguridad ni garantiza por sí solo una validación de negocio. Un campo computado puede guardarse con store=True; se recalcula cuando cambian sus dependencias, no una única vez para siempre.
Añade el siguiente archivo e importa logica al final de models/__init__.py:
Fichero models/logica.py:
1from datetime import timedelta 2from odoo import api, fields, models, _ 3from odoo.exceptions import ValidationError 4 5 6class ViajeLogica(models.Model): 7 _inherit = 'viajes.viaje' 8 9 conductor_id = fields.Many2one(domain=[('conductor', '=', True)]) 10 plazas_ocupadas = fields.Float(compute='_compute_plazas_ocupadas') 11 fecha_fin = fields.Date(compute='_compute_fecha_fin', store=True) 12 estado_display = fields.Char(compute='_compute_estado_display') 13 finalizado = fields.Boolean(compute='_compute_finalizado', store=True) 14 15 @api.depends('plazas', 'pasajeros_ids') 16 def _compute_plazas_ocupadas(self): 17 for record in self: 18 record.plazas_ocupadas = (100.0 * len(record.pasajeros_ids) / record.plazas 19 if record.plazas > 0 else 0.0) 20 21 @api.depends('fecha_inicio', 'duracion') 22 def _compute_fecha_fin(self): 23 for record in self: 24 record.fecha_fin = (record.fecha_inicio + timedelta(days=record.duracion) 25 if record.fecha_inicio else False) 26 27 @api.depends('estado') 28 def _compute_estado_display(self): 29 labels = dict(self._fields['estado']._description_selection(self.env)) 30 for record in self: 31 record.estado_display = labels.get(record.estado, '') 32 33 @api.depends('estado') 34 def _compute_finalizado(self): 35 for record in self: 36 record.finalizado = record.estado == 'finalizado' 37 38 @api.onchange('plazas', 'pasajeros_ids') 39 def _onchange_plazas(self): 40 if self.plazas < 0 or len(self.pasajeros_ids) > self.plazas: 41 return {'warning': { 42 'title': _('Invalid capacity'), 43 'message': _('Capacity must be non-negative and fit all passengers.'), 44 }} 45 46 @api.constrains('plazas', 'pasajeros_ids', 'duracion') 47 def _check_capacity_duration(self): 48 for record in self: 49 if record.plazas < 0 or record.duracion < 0: 50 raise ValidationError(_('Capacity and duration cannot be negative.')) 51 if len(record.pasajeros_ids) > record.plazas: 52 raise ValidationError(_('There are more passengers than seats.')) 53 54 @api.constrains('conductor_id', 'pasajeros_ids') 55 def _check_driver(self): 56 for record in self: 57 if record.conductor_id and record.conductor_id in record.pasajeros_ids: 58 raise ValidationError(_('The driver cannot also be a passenger.')) 59 60 61class VehiculoMatricula(models.Model): 62 _inherit = 'viajes.vehiculo' 63 64 matricula = fields.Char() 65 _matricula_unique = models.Constraint( 66 'UNIQUE(matricula)', 'The registration must be unique.', 67 ) 68 _matricula_descripcion_check = models.Constraint( 69 'CHECK(matricula != descripcion)', 70 'The registration and description must differ.', 71 )
plazas_ocupadas es un porcentaje, no un contador de pasajeros. Con cero plazas devuelve cero y la restricción impide guardar pasajeros sin capacidad. fecha_fin siempre se asigna: sin fecha devuelve False; con duración cero coincide con el inicio. Usamos Integer para días enteros: no esperes conservar medias jornadas con un campo Date.
finalizado deriva de estado, evitando una casilla independiente que contradiga el estado. estado_display obtiene las etiquetas traducidas de la selección. Los valores iniciales ya están definidos mediante default en el modelo base.
2. Validación del servidor y SQL #
onchange solo avisa en formularios. Los constraints también comprueban escrituras e importaciones del ORM. El constraint de conductor impide inscribirlo como pasajero del mismo viaje. Los nombres que empiezan por _ son métodos internos; los botones llaman a acciones públicas deliberadas.
Odoo 20 declara restricciones SQL con models.Constraint. UNIQUE(matricula) permite varios valores nulos porque matrícula sigue siendo opcional; no promete normalizar espacios o mayúsculas. Antes de actualizar una base existente, corrige duplicados que impedirían crear la restricción. El CHECK del ejemplo compara matrícula y descripción cuando ambas tienen valor; no valida un formato oficial de matrícula.
3. Ampliar las vistas sin eliminar pestañas #
Fichero views/logica.xml:
1<odoo> 2<record id="viaje_logica_form" model="ir.ui.view"> 3 <field name="name">viaje.logica.form</field> 4 <field name="model">viajes.viaje</field> 5 <field name="inherit_id" ref="viaje_form_view"/> 6 <field name="arch" type="xml"> 7 <xpath expr="//group[@name='general']" position="inside"> 8 <field name="estado_display"/><field name="finalizado"/> 9 </xpath> 10 <xpath expr="//group[@name='calendario']" position="inside"> 11 <field name="fecha_fin"/><field name="plazas_ocupadas" widget="progressbar"/> 12 </xpath> 13 </field> 14 </record> 15 <record id="viaje_logica_list" model="ir.ui.view"> 16 <field name="name">viaje.logica.list</field> 17 <field name="model">viajes.viaje</field> 18 <field name="inherit_id" ref="viaje_list_view"/> 19 <field name="arch" type="xml"> 20 <xpath expr="//list" position="attributes"> 21 <attribute name="decoration-success">estado == 'finalizado'</attribute> 22 </xpath> 23 <xpath expr="//field[@name='estado']" position="after"> 24 <field name="plazas_ocupadas" widget="progressbar"/> 25 </xpath> 26 </field> 27 </record> 28 <record id="vehiculo_matricula_form" model="ir.ui.view"> 29 <field name="name">vehiculo.matricula.form</field> 30 <field name="model">viajes.vehiculo</field> 31 <field name="inherit_id" ref="vehiculo_form_view"/> 32 <field name="arch" type="xml"> 33 <xpath expr="//field[@name='marca']" position="after"><field name="matricula"/></xpath> 34 </field> 35 </record> 36</odoo>
Las vistas heredadas conservan pasajeros, paradas y revisiones. Este es el manifiesto acumulado final:
Fichero __manifest__.py:
1{'name': 'Viajes', 2 'version': '20.0.1.0.0', 3 'license': 'LGPL-3', 4 'depends': ['base'], 5 'data': ['security/ir.access.csv', 6 'views/vehiculo.xml', 7 'views/viaje.xml', 8 'views/relaciones.xml', 9 'views/partner.xml', 10 'views/parada.xml', 11 'views/revision.xml', 12 'views/logica.xml', 13 'views/menus.xml'], 14 'demo': ['demo/vehiculos.xml'], 15 'installable': True, 16 'application': True}
Fichero models/__init__.py:
1from . import vehiculo 2from . import viaje 3from . import partner 4from . import parada 5from . import viaje_paradas 6from . import revision 7from . import logica
Fichero security/ir.access.csv:
1id,name,model_id,group_id/id,operation,domain 2access_viajes_vehiculo,Manage vehiculo,viajes.vehiculo,base.group_user,crud, 3access_viajes_viaje,Manage viaje,viajes.viaje,base.group_user,crud, 4access_viajes_parada,Manage parada,viajes.parada,base.group_user,crud, 5access_viajes_revision,Manage revision,viajes.revision,base.group_user,crud,
4. Comprobar el módulo #
Actualiza y comprueba duración cero, fechas vacías, cambios de duración, viaje sin pasajeros, capacidad exacta y exceso de pasajeros. Repite una escritura desde shell: una advertencia de formulario no debe permitir eludir las restricciones.
Prueba matrícula repetida y conductor inscrito como pasajero: ambas operaciones deben fallar. Comprueba que se mantienen las pestañas de todas las prácticas y que portal y público no reciben permisos de estos CSV.
5. Pruebas de regresión #
Crea tests/__init__.py con from . import test_viajes. Ejecuta estos tests sobre una base desechable con -i viajes --test-enable --test-tags /viajes --stop-after-init --no-http. Al repetir, utiliza -u viajes.
Fichero tests/test_viajes.py:
1from datetime import date 2from urllib.parse import parse_qs, urlparse 3from lxml import html 4from psycopg2 import IntegrityError 5from odoo.tests import TransactionCase, tagged 6from odoo.fields import Command 7from odoo.exceptions import AccessError, ValidationError 8 9 10@tagged('post_install', '-at_install') 11class TestViajes(TransactionCase): 12 @classmethod 13 def setUpClass(cls): 14 super().setUpClass() 15 cls.vehicle = cls.env['viajes.vehiculo'].create({'modelo': 'Test vehicle'}) 16 cls.passenger = cls.env['res.partner'].create({'name': 'Test passenger'}) 17 cls.trip = cls.env['viajes.viaje'].create({ 18 'titulo': 'Test trip', 'vehiculo_id': cls.vehicle.id, 19 'fecha_inicio': date(2026, 11, 13), 'duracion': 0, 'plazas': 1, 20 }) 21 22 def test_dates_and_capacity(self): 23 self.assertEqual(self.trip.fecha_fin, date(2026, 11, 13)) 24 self.trip.duracion = 2 25 self.assertEqual(self.trip.fecha_fin, date(2026, 11, 15)) 26 self.trip.fecha_inicio = False 27 self.assertFalse(self.trip.fecha_fin) 28 self.assertEqual(self.trip.plazas_ocupadas, 0) 29 self.trip.pasajeros_ids = [Command.link(self.passenger.id)] 30 self.assertEqual(self.trip.plazas_ocupadas, 100) 31 with self.assertRaises(ValidationError), self.cr.savepoint(): 32 self.trip.write({'plazas': 0}) 33 with self.assertRaises(ValidationError), self.cr.savepoint(): 34 self.trip.write({'duracion': -1}) 35 with self.assertRaises(ValidationError), self.cr.savepoint(): 36 self.trip.write({'plazas': -1}) 37 38 def test_driver_and_registration(self): 39 self.trip.pasajeros_ids = [Command.link(self.passenger.id)] 40 with self.assertRaises(ValidationError), self.cr.savepoint(): 41 self.trip.write({'conductor_id': self.passenger.id}) 42 self.vehicle.matricula = 'TEST-001' 43 with self.assertRaises(IntegrityError), self.cr.savepoint(): 44 self.env['viajes.vehiculo'].create({'modelo': 'Duplicate', 'matricula': 'TEST-001'}) 45 46 def test_map_escapes_input_and_toggle(self): 47 value = '"><img src=x onerror=alert(1)>& calle' 48 stop = self.env['viajes.parada'].create({ 49 'titulo': 'Test stop', 'viaje_id': self.trip.id, 'localizacion': value, 50 }) 51 document = html.fromstring(str(stop.mapa_embed)) 52 self.assertEqual(document.tag, 'iframe') 53 self.assertFalse(document.xpath('.//img | .//script')) 54 url = urlparse(document.get('src')) 55 self.assertEqual(url.netloc, 'www.google.com') 56 self.assertEqual(parse_qs(url.query)['q'], [value]) 57 stop.marcar_completada() 58 self.assertTrue(stop.completada) 59 stop.marcar_completada() 60 self.assertFalse(stop.completada) 61 stop.localizacion = False 62 self.assertIn('Ubicación no disponible', stop.mapa_embed) 63 64 def test_portal_cannot_read_trips(self): 65 portal = self.env['res.users'].create({ 66 'name': 'Test portal', 'login': 'test_travel_portal', 67 'group_ids': [Command.set(self.env.ref('base.group_portal').ids)], 68 }) 69 with self.assertRaises(AccessError), self.cr.savepoint(): 70 self.trip.with_user(portal).read(['titulo'])
En tests que llaman directamente al ORM, una restricción SQL puede lanzar psycopg2.IntegrityError; el servicio transforma esos errores en mensajes de usuario. Por eso la prueba SQL utiliza esa excepción y un savepoint para recuperar la transacción.