[MERGE] lunch: improve supplier management

Make the lunch alerts blocking if ordering a product from a supplier if not
available at the order time

Add mail templates to generate emails to suppliers.

This merge is only a first step before the Big Lunch Refactoring that will
change forever the way peoples are considering eating lunch.
This commit is contained in:
Martin Trigaux
2018-08-10 15:32:20 +02:00
5 changed files with 419 additions and 59 deletions
+1 -1
View File
@@ -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': """
+118 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="lunch_order_line_action_order" model="ir.actions.server">
<field name="name">Lunch: Order meals</field>
<field name="model_id" ref="model_lunch_order_line"/>
@@ -7,7 +8,7 @@
<field name="state">code</field>
<field name="code">
if records:
records.order()
action = records.order()
</field>
</record>
@@ -26,5 +27,121 @@ if records:
<field name="state">code</field>
<field name="code">records.cancel()</field>
</record>
</data>
<data noupdate="0">
<record id="lunch_order_mail_supplier" model="mail.template">
<field name="name">Lunch: Send by email</field>
<field name="model_id" ref="lunch.model_lunch_order"/>
<field name="email_from">${ctx['order']['supplier_email'] |safe}</field>
<field name="partner_to">${ctx['order']['supplier_id']}</field>
<field name="subject">Orders for ${ctx['order']['company_name']}</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle">
<span style="font-size: 10px;">Lunch Order</span><br/>
</td><td valign="middle" align="right">
<img src="/logo.png?company=${user.company_id.id}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="'%s' % company.name"/>
</td></tr>
<tr><td colspan="2" style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin:16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr>
<td valign="top" style="font-size: 13px;">
<div>
% 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'])
<p>
Dear ${order['supplier_name']},
</p><p>
Here is, today orders for ${order['company_name']}:
</p>
<ul>
% for line in lines:
<li>
<strong>
${line['product']}
(x${line['quantity']})
</strong>
% if line['note']:
<em>(${line['note']})</em>
% endif
<div style="display: inline-block; float:right">
${format_amount(line['price'], currency)}
</div>
</li>
% endfor
</ul>
<p>Amounting in <strong>${format_amount(order['amount_total'], currency)}</strong>.</p>
<p>Do not hesitate to contact us if you have any question.</p>
</div>
</td>
</tr>
<tr>
<td style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td>
</tr>
</table>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle" align="left">
${user.company_id.name}
</td></tr>
<tr><td valign="middle" align="left" style="opacity: 0.7;">
${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
<a href="'mailto:%s' % ${user.company_id.email}" style="text-decoration:none; color: #454748;">${user.company_id.email}</a>
% endif
% if user.company_id.email and user.company_id.website
|
% endif
% if user.company_id.website
<a href="'%s' % ${user.company_id.website}" style="text-decoration:none; color: #454748;">
${user.company_id.website}
</a>
% endif
</td></tr>
</table>
</td>
</tr>
</tbody>
</table>
</td></tr>
<!-- POWERED BY -->
<tr><td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
<tr><td style="text-align: center; font-size: 13px;">
Powered by <a target="_blank" href="https://www.odoo.com" style="color: #875A7B;">Odoo</a>
</td></tr>
</table>
</td></tr>
</table>
</field>
</record>
</data>
</odoo>
+163 -22
View File
@@ -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,13 +185,104 @@ class LunchOrderLine(models.Model):
cashmove = fields.One2many('lunch.cashmove', 'order_id', 'Cash Move')
currency_id = fields.Many2one('res.currency', related='order_id.currency_id')
@api.one
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
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."))
@@ -236,6 +329,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 +398,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 +406,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 +424,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 +434,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 +441,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
+91 -4
View File
@@ -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()
+46 -31
View File
@@ -477,7 +477,7 @@
<separator string='Select your order'/>
<field name='order_line_ids'>
<tree string='List' editable='bottom'>
<field name='product_id'/>
<field name='product_id' context="{'lunch_date': parent.date}"/>
<field name='note' />
<field name='price'/>
<field name='supplier' invisible="1"/>
@@ -485,7 +485,7 @@
</tree>
<form string='Lunch Order Form'>
<group>
<field name='product_id'/>
<field name='product_id' context="{'lunch_date': parent.date}"/>
<field name='note' />
<field name='price'/>
</group>
@@ -660,37 +660,52 @@
<field name="model">lunch.alert</field>
<field name="arch" type="xml">
<form>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object"
class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</button>
</div>
<group>
<group string="Schedule Date">
<field name="alert_type"/>
<field name="specific_day" attrs="{'invisible': [('alert_type','!=','specific')], 'required':[('alert_type','=','specific')]}"/>
</group>
<group string="Schedule Hour">
<field name='start_hour' widget='float_time'/>
<field name='end_hour' widget='float_time'/>
</group>
<group attrs="{'invisible': [('alert_type','!=','week')]}">
<group col="4">
<field name="monday"/>
<field name="tuesday"/>
<field name="wednesday"/>
<field name="thursday"/>
<field name="friday"/>
<field name="saturday"/>
<field name="sunday"/>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object"
class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</button>
</div>
<group>
<group string="Schedule">
<group colspan="4">
<field name="alert_type"/>
<field name="specific_day"
attrs="{'invisible': [('alert_type','!=','specific')], 'required':[('alert_type','=','specific')]}"/>
<field name='start_hour' widget='float_time' />
<field name='end_hour' widget='float_time' />
</group>
<group col="4" attrs="{'invisible': [('alert_type','!=','week')]}">
<field name="monday"/>
<field name="tuesday"/>
<field name="wednesday"/>
<field name="thursday"/>
<field name="friday"/>
<field name="saturday"/>
<field name="sunday"/>
</group>
</group>
<group string="Vendor">
<group colspan="4">
<field name="partner_id"/>
<field name="display" invisible="True"/>
</group>
<div class="oe_grey" colspan="4"
attrs="{'invisible': ['|', ('partner_id', '=', False), ('display', '=', True)]}">
Products from this vendor can <strong>not</strong> be order at the moment.
</div>
<div class="oe_grey" colspan="4"
attrs="{'invisible': ['|', ('partner_id', '=', False), ('display', '=', False)]}">
Products from this vendor can be order at the moment.
</div>
</group>
</group>
</group>
<group string='Message'>
<field name='message' nolabel='1' placeholder="Write the message you want to display during the defined period..."/>
</group>
<group string='Message'>
<field name='message' nolabel='1' placeholder="Write the message you want to display during the defined period..."/>
</group>
</sheet>
</form>
</field>
</record>