From 8f22f23e61d9fded7eb893008b8f0d173bc0e8fb Mon Sep 17 00:00:00 2001 From: Martin Trigaux Date: Wed, 11 Jul 2018 11:01:24 +0200 Subject: [PATCH 1/4] [ADD] lunch: prevent ordering unavailable products Add a field partner_id on a lunch.alert If the field is set, the alert becomes blocking and will prevent people from ordering a lunch. Add a available field on the lunch.product that will check if any alert forbids ordering that product at the time of the order. Pass the lunch.order date in the context to be able to plan a lunch order another day if the provider is forbidden today. --- addons/lunch/models/lunch.py | 112 +++++++++++++++++++++++------ addons/lunch/views/lunch_views.xml | 16 ++++- 2 files changed, 106 insertions(+), 22 deletions(-) diff --git a/addons/lunch/models/lunch.py b/addons/lunch/models/lunch.py index 5808015d09b..5102b0253f4 100644 --- a/addons/lunch/models/lunch.py +++ b/addons/lunch/models/lunch.py @@ -9,6 +9,7 @@ import datetime from odoo import api, fields, models, _ from odoo.exceptions import AccessError, ValidationError from odoo.addons import decimal_precision as dp +from odoo.osv import expression class LunchOrder(models.Model): @@ -164,7 +165,8 @@ class LunchOrderLine(models.Model): name = fields.Char(related='product_id.name', string="Product Name", readonly=True) order_id = fields.Many2one('lunch.order', 'Order', ondelete='cascade', required=True) - product_id = fields.Many2one('lunch.product', 'Product', required=True) + product_id = fields.Many2one('lunch.product', 'Product', required=True, + domain=[('available', '=', True)]) category_id = fields.Many2one('lunch.product.category', string='Product Category', related='product_id.category_id', readonly=True, store=True) date = fields.Date(string='Date', related='order_id.date', readonly=True, store=True) @@ -183,6 +185,28 @@ class LunchOrderLine(models.Model): cashmove = fields.One2many('lunch.cashmove', 'order_id', 'Cash Move') currency_id = fields.Many2one('res.currency', related='order_id.currency_id') + def _check_supplier_availibility(self): + products = self.mapped('product_id') + if not all(product.available for product in products): + supplier_name = ", ".join(product.supplier.display_name for product in products if not product.available) + raise ValidationError(_("Vendor(s) '%s' is not available today") % supplier_name) + + @api.model + def create(self, vals): + """ Override as an onchange would not apply if using the history buttons """ + res = super(LunchOrderLine, self).create(vals) + res.with_context(lunch_date=res.order_id.date)._check_supplier_availibility() + return res + + @api.model + def write(self, vals): + """ Override as an onchange would not apply if using the history buttons """ + res = super(LunchOrderLine, self).write(vals) + if vals.get('product_id'): + for line in self: + line.with_context(lunch_date=line.order_id.date)._check_supplier_availibility() + return res + @api.one def order(self): """ @@ -236,6 +260,41 @@ class LunchProduct(models.Model): price = fields.Float('Price', digits=dp.get_precision('Account')) supplier = fields.Many2one('res.partner', 'Vendor') active = fields.Boolean(default=True) + available = fields.Boolean(compute='_get_available_product', search='_search_available_products') + + @api.depends('supplier') + def _get_available_product(self): + for product in self: + if not product.supplier: + product.available = True + else: + alerts = self.env['lunch.alert'].search([ + ('partner_id', '=', self.supplier.id) + ]) + if alerts and not any(alert.display for alert in alerts): + # every alert is not available + product.available = False + else: + # no alert for the supplier or at least one is not available + product.available = True + + def _search_available_products(self, operator, value): + alerts = self.env['lunch.alert'].search([]) + supplier_w_alerts = alerts.mapped('partner_id') + available_suppliers = alerts.filtered(lambda a: a.display).mapped('partner_id') + available_products = self.search([ + '|', + ('supplier', 'not in', supplier_w_alerts.ids), + ('supplier', 'in', available_suppliers.ids) + ]) + + if (operator in expression.NEGATIVE_TERM_OPERATORS and value) or \ + (operator not in expression.NEGATIVE_TERM_OPERATORS and not value): + # e.g. (available = False) or (available != True) + return [('id', 'not in', available_products.ids)] + else: + # e.g. (available = True) or (available != False) + return [('id', 'in', available_products.ids)] class LunchProductCategory(models.Model): @@ -270,6 +329,7 @@ class LunchAlert(models.Model): given day, weekly or daily. The alert is displayed from start to end hour. """ _name = 'lunch.alert' _description = 'Lunch Alert' + _rec_name = 'message' display = fields.Boolean(compute='_compute_display_get') message = fields.Text('Message', required=True) @@ -277,6 +337,8 @@ class LunchAlert(models.Model): ('week', 'Every Week'), ('days', 'Every Day')], string='Recurrence', required=True, index=True, default='specific') + partner_id = fields.Many2one('res.partner', string="Vendor", + help="If specified, the selected vendor can be ordered only on selected days") specific_day = fields.Date('Day', default=fields.Date.context_today) monday = fields.Boolean('Monday') tuesday = fields.Boolean('Tuesday') @@ -293,7 +355,8 @@ class LunchAlert(models.Model): def name_get(self): return [(alert.id, '%s %s' % (_('Alert'), '#%d' % alert.id)) for alert in self] - @api.one + @api.depends('alert_type', 'specific_day', 'monday', 'tuesday', 'thursday', + 'friday', 'saturday', 'sunday', 'start_hour', 'end_hour') def _compute_display_get(self): """ This method check if the alert can be displayed today @@ -302,7 +365,6 @@ class LunchAlert(models.Model): if alert type is day : True return : Message if can_display_alert is True else False """ - days_codes = {'0': 'sunday', '1': 'monday', '2': 'tuesday', @@ -310,22 +372,32 @@ class LunchAlert(models.Model): '4': 'thursday', '5': 'friday', '6': 'saturday'} - can_display_alert = { - 'specific': (str(self.specific_day) == fields.Date.context_today(self)), - 'week': self[days_codes[datetime.datetime.now().strftime('%w')]], - 'days': True - } + fullday = False + now = datetime.datetime.now() + if self.env.context.get('lunch_date'): + # lunch_date is a fields.Date -> 00:00:00 + lunch_date = fields.Datetime.from_string(self.env.context['lunch_date']) + # if lunch_date is in the future, planned lunch, ignore hours + fullday = lunch_date > now + now = max(lunch_date, now) + mynow = fields.Datetime.context_timestamp(self, now) - if can_display_alert[self.alert_type]: - mynow = fields.Datetime.context_timestamp(self, datetime.datetime.now()) - hour_to = int(self.end_hour) - min_to = int((self.end_hour - hour_to) * 60) - to_alert = datetime.time(hour_to, min_to) - hour_from = int(self.start_hour) - min_from = int((self.start_hour - hour_from) * 60) - from_alert = datetime.time(hour_from, min_from) + for alert in self: + can_display_alert = { + 'specific': (str(alert.specific_day) == fields.Date.to_string(mynow)), + 'week': alert[days_codes[mynow.strftime('%w')]], + 'days': True + } - if from_alert <= mynow.time() <= to_alert: - self.display = True - else: - self.display = False + if can_display_alert[alert.alert_type]: + hour_to = int(alert.end_hour) + min_to = int((alert.end_hour - hour_to) * 60) + to_alert = datetime.time(hour_to, min_to) + hour_from = int(alert.start_hour) + min_from = int((alert.start_hour - hour_from) * 60) + from_alert = datetime.time(hour_from, min_from) + + if fullday or (from_alert <= mynow.time() <= to_alert): + alert.display = True + else: + alert.display = False diff --git a/addons/lunch/views/lunch_views.xml b/addons/lunch/views/lunch_views.xml index 13582e9bac7..43f5f554515 100644 --- a/addons/lunch/views/lunch_views.xml +++ b/addons/lunch/views/lunch_views.xml @@ -477,7 +477,7 @@ - + @@ -485,7 +485,7 @@
- + @@ -688,6 +688,18 @@ + + + + + +
+ Products from this vendor can not be order at the moment. +
+
+ Products from this vendor can be order at the moment. +
+
From 08dbb39171feba5f103bda20fbd3010ecad4ca01 Mon Sep 17 00:00:00 2001 From: Martin Trigaux Date: Tue, 24 Jul 2018 10:08:02 +0200 Subject: [PATCH 2/4] [IMP] lunch: better alert view Reorganize the fields to clarify the view --- addons/lunch/views/lunch_views.xml | 83 ++++++++++++++++-------------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/addons/lunch/views/lunch_views.xml b/addons/lunch/views/lunch_views.xml index 43f5f554515..aa858b7f4c0 100644 --- a/addons/lunch/views/lunch_views.xml +++ b/addons/lunch/views/lunch_views.xml @@ -660,49 +660,52 @@ lunch.alert -
- -
- - - - - - - - - - - - - - - - - - + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ Products from this vendor can not be order at the moment. +
+
+ Products from this vendor can be order at the moment. +
-
- - - - + + -
- Products from this vendor can not be order at the moment. -
-
- Products from this vendor can be order at the moment. -
-
- - - +
From a2f69cad13aa772b616628e7186aa44ad4f31875 Mon Sep 17 00:00:00 2001 From: Martin Trigaux Date: Tue, 24 Jul 2018 14:52:41 +0200 Subject: [PATCH 3/4] [ADD] lunch: order generates an email for the supplier Confirming orders must now be done per supplier and will open an email composition wizard with the different orders --- addons/lunch/__manifest__.py | 2 +- addons/lunch/data/lunch_data.xml | 119 ++++++++++++++++++++++++++++++- addons/lunch/models/lunch.py | 73 ++++++++++++++++++- 3 files changed, 190 insertions(+), 4 deletions(-) diff --git a/addons/lunch/__manifest__.py b/addons/lunch/__manifest__.py index ab38919601e..828f2f29f45 100644 --- a/addons/lunch/__manifest__.py +++ b/addons/lunch/__manifest__.py @@ -4,7 +4,7 @@ 'name': 'Lunch', 'sequence': 120, 'version': '1.0', - 'depends': ['base', 'web', 'decimal_precision'], + 'depends': ['decimal_precision', 'mail'], 'category': 'Human Resources', 'summary': 'Handle lunch orders of your employees', 'description': """ diff --git a/addons/lunch/data/lunch_data.xml b/addons/lunch/data/lunch_data.xml index f66fbb4788a..2332d2e5272 100644 --- a/addons/lunch/data/lunch_data.xml +++ b/addons/lunch/data/lunch_data.xml @@ -1,5 +1,6 @@ + Lunch: Order meals @@ -7,7 +8,7 @@ code if records: - records.order() + action = records.order() @@ -26,5 +27,121 @@ if records: code records.cancel() + + + + Lunch: Send by email + + ${ctx['order']['supplier_email'] |safe} + ${ctx['order']['supplier_id']} + Orders for ${ctx['order']['company_name']} + + + + +
+ + + + + + + + + + + + + + + +
+ + + +
+ Lunch Order
+
+ +
+
+
+
+ + + + + + + +
+
+ % set lines = ctx['lines'] + % set order = ctx['order'] + % set supplier = user.env['res.partner'].browse(order['supplier_id']) + % set currency = user.env['res.currency'].browse(order['currency_id']) +

+ Dear ${order['supplier_name']}, +

+ Here is, today orders for ${order['company_name']}: +

+
    + % for line in lines: +
  • + + ${line['product']} + (x${line['quantity']}) + + % if line['note']: + (${line['note']}) + % endif +
    + ${format_amount(line['price'], currency)} +
    +
  • + % endfor +
+ +

Amounting in ${format_amount(order['amount_total'], currency)}.

+

Do not hesitate to contact us if you have any question.

+
+
+
+
+
+ + + +
+ ${user.company_id.name} +
+ ${user.company_id.phone} + % if user.company_id.phone and (user.company_id.email or user.company_id.website) + | + % endif + % if user.company_id.email + ${user.company_id.email} + % endif + % if user.company_id.email and user.company_id.website + | + % endif + % if user.company_id.website + + ${user.company_id.website} + + % endif +
+
+
+ + +
+ Powered by Odoo +
+
+
+
+ +
diff --git a/addons/lunch/models/lunch.py b/addons/lunch/models/lunch.py index 5102b0253f4..a5e021ca7e4 100644 --- a/addons/lunch/models/lunch.py +++ b/addons/lunch/models/lunch.py @@ -207,13 +207,82 @@ class LunchOrderLine(models.Model): line.with_context(lunch_date=line.order_id.date)._check_supplier_availibility() return res - @api.one def order(self): """ The order_line is ordered to the vendor but isn't received yet """ if self.user_has_groups("lunch.group_lunch_manager"): - self.state = 'ordered' + self.write({'state': 'ordered'}) + + order = { + 'supplier': False, + 'company': False, + 'currency': False, + } + group_lines = {} + for line in self: + if not line.supplier: + # do not send emails for products with no suppliers + continue + + if order['supplier'] and line.supplier != order['supplier']: + raise ValidationError(_("Validate order for one supplier at a time to send emails (mixed orders from %s and %s)") % ( + order['supplier'].display_name, line.supplier.display_name)) + order['supplier'] = line.supplier + + if order['company'] and line.order_id.company_id != order['company']: + raise ValidationError(_("Validate order for one company at a time to send emails (mixed orders from %s and %s)") % ( + order['company'].name, line.order_id.company_id.name)) + order['company'] = line.order_id.company_id + + if order['currency'] and line.currency_id != order['currency']: + raise ValidationError(_("Validate order for one currency at a time to send emails (mixed orders from %s and %s)") % ( + order['currency'].name, line.currency_id.name)) + order['currency'] = line.currency_id + + # group the order by products and note + key = (line.product_id, line.note) + group_lines.setdefault(key, 0) + group_lines[key] += 1 + + order['company_name'] = order['company'].name + order['currency_id'] = order['currency'].id + order['supplier_id'] = order['supplier'].id + order['supplier_name'] = order['supplier'].name + order['supplier_email'] = order['supplier'].email_formatted + + lines = [] + # sort by product name, note + for product, note in sorted(group_lines, key=lambda k: (k[0].name, bool(k[1]))): + quantity = group_lines[(product, note)] + lines.append({ + 'product': product.name, + 'note': note or '', + 'quantity': quantity, + 'price': product.price * quantity, + }) + + order['amount_total'] = sum(l['price'] for l in lines) + + template = self.env.ref('lunch.lunch_order_mail_supplier', raise_if_not_found=False) + ctx = dict( + default_composition_mode='comment', + default_model='lunch.order', + default_use_template=bool(template), + default_template_id=template.id, + default_lang=order['supplier'].lang or self.env.user.lang, + order=order, + lines=lines, + ) + return { + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'mail.compose.message', + 'target': 'new', + 'context': ctx, + } + else: raise AccessError(_("Only your lunch manager processes the orders.")) From f7c53830b3b6c23b6faaec6eed78064ffe4393ea Mon Sep 17 00:00:00 2001 From: Martin Trigaux Date: Fri, 10 Aug 2018 12:01:48 +0200 Subject: [PATCH 4/4] [ADD] lunch: tests for the new behavior And make sure multiple alerts combine correctly --- addons/lunch/tests/test_lunch.py | 95 ++++++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 4 deletions(-) diff --git a/addons/lunch/tests/test_lunch.py b/addons/lunch/tests/test_lunch.py index 608b1ed2d3f..fe30ca02854 100644 --- a/addons/lunch/tests/test_lunch.py +++ b/addons/lunch/tests/test_lunch.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.exceptions import ValidationError from odoo.tests import common +from datetime import date, datetime +from unittest.mock import patch class Test_Lunch(common.TransactionCase): @@ -11,18 +14,17 @@ class Test_Lunch(common.TransactionCase): super(Test_Lunch, self).setUp() self.demo_user = self.env['res.users'].search([('name', '=', 'Marc Brown')]) - self.product_bolognese_ref = self.env['ir.model.data'].get_object_reference('lunch', 'product_Bolognese') - self.product_Bolognese_id = self.product_bolognese_ref and self.product_bolognese_ref[1] or False + self.product_Bolognese_id = self.env.ref('lunch.product_Bolognese') self.new_id_order = self.env['lunch.order'].create({ 'user_id': self.demo_user.id, 'order_line_ids': '[]', }) self.new_id_order_line = self.env['lunch.order.line'].create({ 'order_id': self.new_id_order.id, - 'product_id': self.product_Bolognese_id, + 'product_id': self.product_Bolognese_id.id, 'note': '+Emmental', 'cashmove': [], - 'price': self.env['lunch.product'].browse(self.product_Bolognese_id).price, + 'price': self.product_Bolognese_id.price, }) def test_00_lunch_order(self): @@ -60,3 +62,88 @@ class Test_Lunch(common.TransactionCase): #We check that the state is cancelled and that the cashmove has been deleted self.assertEqual(self.order_one.state, 'cancelled') self.assertFalse(self.order_one.cashmove) + + def test_03_lunch_alert(self): + """ specify a lunch alert and make sure it is not possible to make an + order that day + """ + alert_01 = self.env['lunch.alert'].create({ + 'message': 'Order Bolognese only on Tuesday', + 'partner_id': self.product_Bolognese_id.supplier.id, + 'alert_type': 'week', + 'tuesday': True + }) + + patcher_date = patch('odoo.addons.lunch.models.lunch.datetime.date', wraps=date) + patcher_datetime = patch('odoo.addons.lunch.models.lunch.datetime.datetime', wraps=datetime) + mock_date = patcher_date.start() + mock_datetime = patcher_datetime.start() + + mock_date.today.return_value = date(2018, 8, 14) # a Tuesday + mock_datetime.now.return_value = datetime(2018, 8, 14, 9, 0, 0) + + # We check that we can make an order on a Tuesday + order_01 = self.env['lunch.order'].create({ + 'user_id': self.demo_user.id, + 'order_line_ids': [(0, 0, { + 'product_id': self.product_Bolognese_id.id, + })] + }) + order_01.order_line_ids.order() + + with self.assertRaises(ValidationError): + # Planning an order to an non-authorized day must not be possible + order_02 = self.env['lunch.order'].create({ + 'user_id': self.demo_user.id, + 'date': "2018-08-15", + 'order_line_ids': [(0, 0, { + 'product_id': self.product_Bolognese_id.id, + })] + }) + + alert_02 = self.env['lunch.alert'].create({ + 'message': 'Working one more day', + 'partner_id': self.product_Bolognese_id.supplier.id, + 'alert_type': 'specific', + 'specific_day': date(2018, 8, 15) + }) + + # should now be possible to order tomorrow + order_03 = self.env['lunch.order'].create({ + 'user_id': self.demo_user.id, + 'date': "2018-08-15", + 'order_line_ids': [(0, 0, { + 'product_id': self.product_Bolognese_id.id, + })] + }) + + # should be possible to planned on an allowed day + order_04 = self.env['lunch.order'].create({ + 'user_id': self.demo_user.id, + 'date': "2018-08-21", # next Tuesday + 'order_line_ids': [(0, 0, { + 'product_id': self.product_Bolognese_id.id, + })] + }) + order_04.order_line_ids.order() + + # combinaision of alerts should work + alert_03 = self.env['lunch.alert'].create({ + 'message': 'Order Bolognese also on Wednesday', + 'partner_id': self.product_Bolognese_id.supplier.id, + 'alert_type': 'week', + 'wednesday': True + }) + # Planning an order Wednesday must now be possible + order_05 = self.env['lunch.order'].create({ + 'user_id': self.demo_user.id, + 'date': "2018-08-22", # next Wednesday + 'order_line_ids': [(0, 0, { + 'product_id': self.product_Bolognese_id.id, + })] + }) + order_05.order_line_ids.order() + + + patcher_date.stop() + patcher_datetime.stop()