Initial commit: Vertical Industry packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:52 +02:00
commit d5567a0017
766 changed files with 733028 additions and 0 deletions

View file

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import lunch_alert
from . import lunch_cashmove
from . import lunch_location
from . import lunch_order
from . import lunch_product
from . import lunch_product_category
from . import lunch_topping
from . import lunch_supplier
from . import res_company
from . import res_config_settings
from . import res_users

View file

@ -0,0 +1,198 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import pytz
import logging
from odoo import api, fields, models
from odoo.osv import expression
from .lunch_supplier import float_to_time
from datetime import datetime, timedelta
from textwrap import dedent
from odoo.addons.base.models.res_partner import _tz_get
_logger = logging.getLogger(__name__)
WEEKDAY_TO_NAME = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
CRON_DEPENDS = {'name', 'active', 'mode', 'until', 'notification_time', 'notification_moment', 'tz'}
class LunchAlert(models.Model):
""" Alerts to display during a lunch order. An alert can be specific to a
given day, weekly or daily. The alert is displayed from start to end hour. """
_name = 'lunch.alert'
_description = 'Lunch Alert'
_order = 'write_date desc, id'
name = fields.Char('Alert Name', required=True, translate=True)
message = fields.Html('Message', required=True, translate=True)
mode = fields.Selection([
('alert', 'Alert in app'),
('chat', 'Chat notification')], string='Display', default='alert')
recipients = fields.Selection([
('everyone', 'Everyone'),
('last_week', 'Employee who ordered last week'),
('last_month', 'Employee who ordered last month'),
('last_year', 'Employee who ordered last year')], string='Recipients', default='everyone')
notification_time = fields.Float(default=10.0, string='Notification Time')
notification_moment = fields.Selection([
('am', 'AM'),
('pm', 'PM')], default='am', required=True)
tz = fields.Selection(_tz_get, string='Timezone', required=True, default=lambda self: self.env.user.tz or 'UTC')
cron_id = fields.Many2one('ir.cron', ondelete='cascade', required=True, readonly=True)
until = fields.Date('Show Until')
mon = fields.Boolean(default=True)
tue = fields.Boolean(default=True)
wed = fields.Boolean(default=True)
thu = fields.Boolean(default=True)
fri = fields.Boolean(default=True)
sat = fields.Boolean(default=True)
sun = fields.Boolean(default=True)
available_today = fields.Boolean('Is Displayed Today',
compute='_compute_available_today', search='_search_available_today')
active = fields.Boolean('Active', default=True)
location_ids = fields.Many2many('lunch.location', string='Location')
_sql_constraints = [
('notification_time_range',
'CHECK(notification_time >= 0 and notification_time <= 12)',
'Notification time must be between 0 and 12')
]
@api.depends('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun')
def _compute_available_today(self):
today = fields.Date.context_today(self)
fieldname = WEEKDAY_TO_NAME[today.weekday()]
for alert in self:
alert.available_today = alert.until > today if alert.until else True and alert[fieldname]
def _search_available_today(self, operator, value):
if (not operator in ['=', '!=']) or (not value in [True, False]):
return []
searching_for_true = (operator == '=' and value) or (operator == '!=' and not value)
today = fields.Date.context_today(self)
fieldname = WEEKDAY_TO_NAME[today.weekday()]
return expression.AND([
[(fieldname, operator, value)],
expression.OR([
[('until', '=', False)],
[('until', '>' if searching_for_true else '<', today)],
])
])
def _sync_cron(self):
""" Synchronise the related cron fields to reflect this alert """
for alert in self:
alert = alert.with_context(tz=alert.tz)
cron_required = (
alert.active
and alert.mode == 'chat'
and (not alert.until or fields.Date.context_today(alert) <= alert.until)
)
sendat_tz = pytz.timezone(alert.tz).localize(datetime.combine(
fields.Date.context_today(alert, fields.Datetime.now()),
float_to_time(alert.notification_time, alert.notification_moment)))
cron = alert.cron_id.sudo()
lc = cron.lastcall
if ((
lc and sendat_tz.date() <= fields.Datetime.context_timestamp(alert, lc).date()
) or (
not lc and sendat_tz <= fields.Datetime.context_timestamp(alert, fields.Datetime.now())
)):
sendat_tz += timedelta(days=1)
sendat_utc = sendat_tz.astimezone(pytz.UTC).replace(tzinfo=None)
cron.name = f"Lunch: alert chat notification ({alert.name})"
cron.active = cron_required
cron.nextcall = sendat_utc
cron.code = dedent(f"""\
# This cron is dynamically controlled by {self._description}.
# Do NOT modify this cron, modify the related record instead.
env['{self._name}'].browse([{alert.id}])._notify_chat()""")
@api.model_create_multi
def create(self, vals_list):
crons = self.env['ir.cron'].sudo().create([
{
'user_id': self.env.ref('base.user_root').id,
'active': False,
'interval_type': 'days',
'interval_number': 1,
'numbercall': -1,
'doall': False,
'name': "Lunch: alert chat notification",
'model_id': self.env['ir.model']._get_id(self._name),
'state': 'code',
'code': "",
}
for _ in range(len(vals_list))
])
self.env['ir.model.data'].sudo().create([{
'name': f'lunch_alert_cron_sa_{cron.ir_actions_server_id.id}',
'module': 'lunch',
'res_id': cron.ir_actions_server_id.id,
'model': 'ir.actions.server',
# noupdate is set to true to avoid to delete record at module update
'noupdate': True,
} for cron in crons])
for vals, cron in zip(vals_list, crons):
vals['cron_id'] = cron.id
alerts = super().create(vals_list)
alerts._sync_cron()
return alerts
def write(self, values):
super().write(values)
if not CRON_DEPENDS.isdisjoint(values):
self._sync_cron()
def unlink(self):
crons = self.cron_id.sudo()
server_actions = crons.ir_actions_server_id
super().unlink()
crons.unlink()
server_actions.unlink()
def _notify_chat(self):
# Called daily by cron
self.ensure_one()
if not self.available_today:
_logger.warning("cancelled, not available today")
if self.cron_id and self.until and fields.Date.context_today(self) > self.until:
self.cron_id.unlink()
self.cron_id = False
return
if not self.active or self.mode != 'chat':
raise ValueError("Cannot send a chat notification in the current state")
order_domain = [('state', '!=', 'cancelled')]
if self.location_ids.ids:
order_domain = expression.AND([order_domain, [('user_id.last_lunch_location_id', 'in', self.location_ids.ids)]])
if self.recipients != 'everyone':
weeksago = fields.Date.today() - timedelta(weeks=(
1 if self.recipients == 'last_week' else
4 if self.recipients == 'last_month' else
52 # if self.recipients == 'last_year'
))
order_domain = expression.AND([order_domain, [('date', '>=', weeksago)]])
partners = self.env['lunch.order'].search(order_domain).user_id.partner_id
if partners:
self.env['mail.thread'].message_notify(
body=self.message,
partner_ids=partners.ids
)

View file

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.tools import float_round
class LunchCashMove(models.Model):
""" Two types of cashmoves: payment (credit) or order (debit) """
_name = 'lunch.cashmove'
_description = 'Lunch Cashmove'
_order = 'date desc'
currency_id = fields.Many2one('res.currency', default=lambda self: self.env.company.currency_id, required=True)
user_id = fields.Many2one('res.users', 'User',
default=lambda self: self.env.uid)
date = fields.Date('Date', required=True, default=fields.Date.context_today)
amount = fields.Float('Amount', required=True)
description = fields.Text('Description')
def name_get(self):
return [(cashmove.id, '%s %s' % (_('Lunch Cashmove'), '#%d' % cashmove.id)) for cashmove in self]
@api.model
def get_wallet_balance(self, user, include_config=True):
result = float_round(sum(move['amount'] for move in self.env['lunch.cashmove.report'].search_read(
[('user_id', '=', user.id)], ['amount'])), precision_digits=2)
if include_config:
result += user.company_id.lunch_minimum_threshold
return result

View file

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class LunchLocation(models.Model):
_name = 'lunch.location'
_description = 'Lunch Locations'
name = fields.Char('Location Name', required=True)
address = fields.Text('Address')
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)

View file

@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
class LunchOrder(models.Model):
_name = 'lunch.order'
_description = 'Lunch Order'
_order = 'id desc'
_display_name = 'product_id'
name = fields.Char(related='product_id.name', string="Product Name", store=True, readonly=True)
topping_ids_1 = fields.Many2many('lunch.topping', 'lunch_order_topping', 'order_id', 'topping_id', string='Extras 1', domain=[('topping_category', '=', 1)])
topping_ids_2 = fields.Many2many('lunch.topping', 'lunch_order_topping', 'order_id', 'topping_id', string='Extras 2', domain=[('topping_category', '=', 2)])
topping_ids_3 = fields.Many2many('lunch.topping', 'lunch_order_topping', 'order_id', 'topping_id', string='Extras 3', domain=[('topping_category', '=', 3)])
product_id = fields.Many2one('lunch.product', string="Product", required=True)
category_id = fields.Many2one(
string='Product Category', related='product_id.category_id', store=True)
date = fields.Date('Order Date', required=True, readonly=True,
states={'new': [('readonly', False)]},
default=fields.Date.context_today)
supplier_id = fields.Many2one(
string='Vendor', related='product_id.supplier_id', store=True, index=True)
available_today = fields.Boolean(related='supplier_id.available_today')
order_deadline_passed = fields.Boolean(related='supplier_id.order_deadline_passed')
user_id = fields.Many2one('res.users', 'User', readonly=True,
states={'new': [('readonly', False)]},
default=lambda self: self.env.uid)
lunch_location_id = fields.Many2one('lunch.location', default=lambda self: self.env.user.last_lunch_location_id)
note = fields.Text('Notes')
price = fields.Monetary('Total Price', compute='_compute_total_price', readonly=True, store=True)
active = fields.Boolean('Active', default=True)
state = fields.Selection([('new', 'To Order'),
('ordered', 'Ordered'), # "Internally" ordered
('sent', 'Sent'), # Order sent to the supplier
('confirmed', 'Received'), # Order received
('cancelled', 'Cancelled')],
'Status', readonly=True, index=True, default='new')
notified = fields.Boolean(default=False)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company.id)
currency_id = fields.Many2one(related='company_id.currency_id', store=True)
quantity = fields.Float('Quantity', required=True, default=1)
display_toppings = fields.Text('Extras', compute='_compute_display_toppings', store=True)
product_description = fields.Html('Description', related='product_id.description')
topping_label_1 = fields.Char(related='product_id.supplier_id.topping_label_1')
topping_label_2 = fields.Char(related='product_id.supplier_id.topping_label_2')
topping_label_3 = fields.Char(related='product_id.supplier_id.topping_label_3')
topping_quantity_1 = fields.Selection(related='product_id.supplier_id.topping_quantity_1')
topping_quantity_2 = fields.Selection(related='product_id.supplier_id.topping_quantity_2')
topping_quantity_3 = fields.Selection(related='product_id.supplier_id.topping_quantity_3')
image_1920 = fields.Image(compute='_compute_product_images')
image_128 = fields.Image(compute='_compute_product_images')
available_toppings_1 = fields.Boolean(help='Are extras available for this product', compute='_compute_available_toppings')
available_toppings_2 = fields.Boolean(help='Are extras available for this product', compute='_compute_available_toppings')
available_toppings_3 = fields.Boolean(help='Are extras available for this product', compute='_compute_available_toppings')
display_reorder_button = fields.Boolean(compute='_compute_display_reorder_button')
@api.depends('product_id')
def _compute_product_images(self):
for line in self:
line.image_1920 = line.product_id.image_1920 or line.category_id.image_1920
line.image_128 = line.product_id.image_128 or line.category_id.image_128
@api.depends('category_id')
def _compute_available_toppings(self):
for order in self:
order.available_toppings_1 = bool(order.env['lunch.topping'].search_count([('supplier_id', '=', order.supplier_id.id), ('topping_category', '=', 1)]))
order.available_toppings_2 = bool(order.env['lunch.topping'].search_count([('supplier_id', '=', order.supplier_id.id), ('topping_category', '=', 2)]))
order.available_toppings_3 = bool(order.env['lunch.topping'].search_count([('supplier_id', '=', order.supplier_id.id), ('topping_category', '=', 3)]))
@api.depends_context('show_reorder_button')
@api.depends('state')
def _compute_display_reorder_button(self):
show_button = self.env.context.get('show_reorder_button')
for order in self:
order.display_reorder_button = show_button and order.state == 'confirmed' and order.supplier_id.available_today
def init(self):
self._cr.execute("""CREATE INDEX IF NOT EXISTS lunch_order_user_product_date ON %s (user_id, product_id, date)"""
% self._table)
def _extract_toppings(self, values):
"""
If called in api.multi then it will pop topping_ids_1,2,3 from values
"""
if self.ids:
# TODO This is not taking into account all the toppings for each individual order, this is usually not a problem
# since in the interface you usually don't update more than one order at a time but this is a bug nonetheless
topping_1 = values.pop('topping_ids_1')[0][2] if 'topping_ids_1' in values else self[:1].topping_ids_1.ids
topping_2 = values.pop('topping_ids_2')[0][2] if 'topping_ids_2' in values else self[:1].topping_ids_2.ids
topping_3 = values.pop('topping_ids_3')[0][2] if 'topping_ids_3' in values else self[:1].topping_ids_3.ids
else:
topping_1 = values['topping_ids_1'][0][2] if 'topping_ids_1' in values else []
topping_2 = values['topping_ids_2'][0][2] if 'topping_ids_2' in values else []
topping_3 = values['topping_ids_3'][0][2] if 'topping_ids_3' in values else []
return topping_1 + topping_2 + topping_3
@api.constrains('topping_ids_1', 'topping_ids_2', 'topping_ids_3')
def _check_topping_quantity(self):
errors = {
'1_more': _('You should order at least one %s'),
'1': _('You have to order one and only one %s'),
}
for line in self:
for index in range(1, 4):
availability = line['available_toppings_%s' % index]
quantity = line['topping_quantity_%s' % index]
toppings = line['topping_ids_%s' % index].filtered(lambda x: x.topping_category == index)
label = line['topping_label_%s' % index]
if availability and quantity != '0_more':
check = bool(len(toppings) == 1 if quantity == '1' else toppings)
if not check:
raise ValidationError(errors[quantity] % label)
@api.model_create_multi
def create(self, vals_list):
orders = self.env['lunch.order']
for vals in vals_list:
lines = self._find_matching_lines({
**vals,
'toppings': self._extract_toppings(vals),
})
if lines.filtered(lambda l: l.state not in ['sent', 'confirmed']):
# YTI FIXME This will update multiple lines in the case there are multiple
# matching lines which should not happen through the interface
lines.update_quantity(1)
orders |= lines[:1]
else:
orders |= super().create(vals)
return orders
def write(self, values):
merge_needed = 'note' in values or 'topping_ids_1' in values or 'topping_ids_2' in values or 'topping_ids_3' in values
default_location_id = self.env.user.last_lunch_location_id and self.env.user.last_lunch_location_id.id or False
if merge_needed:
lines_to_deactivate = self.env['lunch.order']
for line in self:
# Only write on topping_ids_1 because they all share the same table
# and we don't want to remove all the records
# _extract_toppings will pop topping_ids_1, topping_ids_2 and topping_ids_3 from values
# This also forces us to invalidate the cache for topping_ids_2 and topping_ids_3 that
# could have changed through topping_ids_1 without the cache knowing about it
toppings = self._extract_toppings(values)
self.invalidate_model(['topping_ids_2', 'topping_ids_3'])
values['topping_ids_1'] = [(6, 0, toppings)]
matching_lines = self._find_matching_lines({
'user_id': values.get('user_id', line.user_id.id),
'product_id': values.get('product_id', line.product_id.id),
'note': values.get('note', line.note or False),
'toppings': toppings,
'lunch_location_id': values.get('lunch_location_id', default_location_id),
})
if matching_lines:
lines_to_deactivate |= line
matching_lines.update_quantity(line.quantity)
lines_to_deactivate.write({'active': False})
return super(LunchOrder, self - lines_to_deactivate).write(values)
return super().write(values)
@api.model
def _find_matching_lines(self, values):
default_location_id = self.env.user.last_lunch_location_id and self.env.user.last_lunch_location_id.id or False
domain = [
('user_id', '=', values.get('user_id', self.default_get(['user_id'])['user_id'])),
('product_id', '=', values.get('product_id', False)),
('date', '=', fields.Date.today()),
('note', '=', values.get('note', False)),
('lunch_location_id', '=', values.get('lunch_location_id', default_location_id)),
]
toppings = values.get('toppings', [])
return self.search(domain).filtered(lambda line: (line.topping_ids_1 | line.topping_ids_2 | line.topping_ids_3).ids == toppings)
@api.depends('topping_ids_1', 'topping_ids_2', 'topping_ids_3', 'product_id', 'quantity')
def _compute_total_price(self):
for line in self:
line.price = line.quantity * (line.product_id.price + sum((line.topping_ids_1 | line.topping_ids_2 | line.topping_ids_3).mapped('price')))
@api.depends('topping_ids_1', 'topping_ids_2', 'topping_ids_3')
def _compute_display_toppings(self):
for line in self:
toppings = line.topping_ids_1 | line.topping_ids_2 | line.topping_ids_3
line.display_toppings = ' + '.join(toppings.mapped('name'))
def update_quantity(self, increment):
for line in self.filtered(lambda line: line.state not in ['sent', 'confirmed']):
if line.quantity <= -increment:
# TODO: maybe unlink the order?
line.active = False
else:
line.quantity += increment
self._check_wallet()
def add_to_cart(self):
"""
This method currently does nothing, we currently need it in order to
be able to reuse this model in place of a wizard
"""
# YTI FIXME: Find a way to drop this.
return True
def _check_wallet(self):
self.env.flush_all()
for line in self:
if self.env['lunch.cashmove'].get_wallet_balance(line.user_id) < 0:
raise ValidationError(_('Your wallet does not contain enough money to order that. To add some money to your wallet, please contact your lunch manager.'))
def action_order(self):
for order in self:
if not order.supplier_id.available_today:
raise UserError(_('The vendor related to this order is not available today.'))
if self.filtered(lambda line: not line.product_id.active):
raise ValidationError(_('Product is no longer available.'))
self.write({
'state': 'ordered',
})
for order in self:
order.lunch_location_id = order.user_id.last_lunch_location_id
self._check_wallet()
def action_reorder(self):
self.ensure_one()
if not self.supplier_id.available_today:
raise UserError(_('The vendor related to this order is not available today.'))
self.copy({
'date': fields.Date.context_today(self),
'state': 'ordered',
})
action = self.env['ir.actions.act_window']._for_xml_id('lunch.lunch_order_action')
return action
def action_confirm(self):
self.write({'state': 'confirmed'})
def action_cancel(self):
self.write({'state': 'cancelled'})
def action_reset(self):
self.write({'state': 'ordered'})
def action_send(self):
self.state = 'sent'
def action_notify(self):
self -= self.filtered('notified')
if not self:
return
notified_users = set()
# (company, lang): (subject, body)
translate_cache = dict()
for order in self:
user = order.user_id
if user in notified_users:
continue
_key = (order.company_id, user.lang)
if _key not in translate_cache:
context = {'lang': user.lang}
translate_cache[_key] = (_('Lunch notification'), order.company_id.with_context(lang=user.lang).lunch_notify_message)
del context
subject, body = translate_cache[_key]
user.partner_id.message_notify(
subject=subject,
body=body,
partner_ids=user.partner_id.ids,
email_layout_xmlid='mail.mail_notification_light',
)
notified_users.add(user)
self.write({'notified': True})

View file

@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from collections import defaultdict
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.osv import expression
class LunchProduct(models.Model):
""" Products available to order. A product is linked to a specific vendor. """
_name = 'lunch.product'
_description = 'Lunch Product'
_inherit = 'image.mixin'
_order = 'name'
_check_company_auto = True
name = fields.Char('Product Name', required=True, translate=True)
category_id = fields.Many2one('lunch.product.category', 'Product Category', check_company=True, required=True)
description = fields.Html('Description', translate=True)
price = fields.Float('Price', digits='Account', required=True)
supplier_id = fields.Many2one('lunch.supplier', 'Vendor', check_company=True, required=True)
active = fields.Boolean(default=True)
company_id = fields.Many2one('res.company', related='supplier_id.company_id', readonly=False, store=True)
currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
new_until = fields.Date('New Until')
is_new = fields.Boolean(compute='_compute_is_new')
favorite_user_ids = fields.Many2many('res.users', 'lunch_product_favorite_user_rel', 'product_id', 'user_id', check_company=True)
is_favorite = fields.Boolean(compute='_compute_is_favorite', inverse='_inverse_is_favorite')
last_order_date = fields.Date(compute='_compute_last_order_date')
product_image = fields.Image(compute='_compute_product_image')
# This field is used only for searching
is_available_at = fields.Many2one('lunch.location', 'Product Availability', compute='_compute_is_available_at', search='_search_is_available_at')
@api.depends('image_128', 'category_id.image_128')
def _compute_product_image(self):
for product in self:
product.product_image = product.image_128 or product.category_id.image_128
@api.depends('new_until')
def _compute_is_new(self):
today = fields.Date.context_today(self)
for product in self:
if product.new_until:
product.is_new = today <= product.new_until
else:
product.is_new = False
@api.depends_context('uid')
@api.depends('favorite_user_ids')
def _compute_is_favorite(self):
for product in self:
product.is_favorite = self.env.user in product.favorite_user_ids
@api.depends_context('uid')
def _compute_last_order_date(self):
all_orders = self.env['lunch.order'].search([
('user_id', '=', self.env.user.id),
('product_id', 'in', self.ids),
])
mapped_orders = defaultdict(lambda: self.env['lunch.order'])
for order in all_orders:
mapped_orders[order.product_id] |= order
for product in self:
if not mapped_orders[product]:
product.last_order_date = False
else:
product.last_order_date = max(mapped_orders[product].mapped('date'))
def _compute_is_available_at(self):
"""
Is available_at is always false when browsing it
this field is there only to search (see _search_is_available_at)
"""
for product in self:
product.is_available_at = False
def _search_is_available_at(self, operator, value):
supported_operators = ['in', 'not in', '=', '!=']
if not operator in supported_operators:
return expression.TRUE_DOMAIN
if isinstance(value, int):
value = [value]
if operator in expression.NEGATIVE_TERM_OPERATORS:
return expression.AND([[('supplier_id.available_location_ids', 'not in', value)], [('supplier_id.available_location_ids', '!=', False)]])
return expression.OR([[('supplier_id.available_location_ids', 'in', value)], [('supplier_id.available_location_ids', '=', False)]])
def _sync_active_from_related(self):
""" Archive/unarchive product after related field is archived/unarchived """
return self.filtered(lambda p: (p.category_id.active and p.supplier_id.active) != p.active).toggle_active()
def toggle_active(self):
invalid_products = self.filtered(lambda product: not product.active and not product.category_id.active)
if invalid_products:
raise UserError(_("The following product categories are archived. You should either unarchive the categories or change the category of the product.\n%s", '\n'.join(invalid_products.category_id.mapped('name'))))
invalid_products = self.filtered(lambda product: not product.active and not product.supplier_id.active)
if invalid_products:
raise UserError(_("The following suppliers are archived. You should either unarchive the suppliers or change the supplier of the product.\n%s", '\n'.join(invalid_products.supplier_id.mapped('name'))))
return super().toggle_active()
def _inverse_is_favorite(self):
""" Handled in the write() """
return
def write(self, vals):
if 'is_favorite' in vals:
if vals.pop('is_favorite'):
commands = [(4, product.id) for product in self]
else:
commands = [(3, product.id) for product in self]
self.env.user.write({
'favorite_lunch_product_ids': commands,
})
if not vals:
return True
return super().write(vals)

View file

@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo import api, fields, models
from odoo.modules.module import get_module_resource
class LunchProductCategory(models.Model):
""" Category of the product such as pizza, sandwich, pasta, chinese, burger... """
_name = 'lunch.product.category'
_inherit = 'image.mixin'
_description = 'Lunch Product Category'
@api.model
def _default_image(self):
image_path = get_module_resource('lunch', 'static/img', 'lunch.png')
return base64.b64encode(open(image_path, 'rb').read())
name = fields.Char('Product Category', required=True, translate=True)
company_id = fields.Many2one('res.company')
currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
product_count = fields.Integer(compute='_compute_product_count', help="The number of products related to this category")
active = fields.Boolean(string='Active', default=True)
image_1920 = fields.Image(default=_default_image)
def _compute_product_count(self):
product_data = self.env['lunch.product']._read_group([('category_id', 'in', self.ids)], ['category_id'], ['category_id'])
data = {product['category_id'][0]: product['category_id_count'] for product in product_data}
for category in self:
category.product_count = data.get(category.id, 0)
def toggle_active(self):
""" Archiving related lunch product """
res = super().toggle_active()
Product = self.env['lunch.product'].with_context(active_test=False)
all_products = Product.search([('category_id', 'in', self.ids)])
all_products._sync_active_from_related()
return res

View file

@ -0,0 +1,383 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import math
import pytz
from collections import defaultdict
from datetime import datetime, time, timedelta
from textwrap import dedent
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools import float_round
from odoo.addons.base.models.res_partner import _tz_get
WEEKDAY_TO_NAME = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
CRON_DEPENDS = {'name', 'active', 'send_by', 'automatic_email_time', 'moment', 'tz'}
def float_to_time(hours, moment='am'):
""" Convert a number of hours into a time object. """
if hours == 12.0 and moment == 'pm':
return time.max
fractional, integral = math.modf(hours)
if moment == 'pm':
integral += 12
return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
def time_to_float(t):
return float_round(t.hour + t.minute/60 + t.second/3600, precision_digits=2)
class LunchSupplier(models.Model):
_name = 'lunch.supplier'
_description = 'Lunch Supplier'
_inherit = ['mail.thread', 'mail.activity.mixin']
partner_id = fields.Many2one('res.partner', string='Vendor', required=True)
name = fields.Char('Name', related='partner_id.name', readonly=False)
email = fields.Char(related='partner_id.email', readonly=False)
email_formatted = fields.Char(related='partner_id.email_formatted', readonly=True)
phone = fields.Char(related='partner_id.phone', readonly=False)
street = fields.Char(related='partner_id.street', readonly=False)
street2 = fields.Char(related='partner_id.street2', readonly=False)
zip_code = fields.Char(related='partner_id.zip', readonly=False)
city = fields.Char(related='partner_id.city', readonly=False)
state_id = fields.Many2one("res.country.state", related='partner_id.state_id', readonly=False)
country_id = fields.Many2one('res.country', related='partner_id.country_id', readonly=False)
company_id = fields.Many2one('res.company', related='partner_id.company_id', readonly=False, store=True)
responsible_id = fields.Many2one('res.users', string="Responsible", domain=lambda self: [('groups_id', 'in', self.env.ref('lunch.group_lunch_manager').id)],
default=lambda self: self.env.user,
help="The responsible is the person that will order lunch for everyone. It will be used as the 'from' when sending the automatic email.")
send_by = fields.Selection([
('phone', 'Phone'),
('mail', 'Email'),
], 'Send Order By', default='phone')
automatic_email_time = fields.Float('Order Time', default=12.0, required=True)
cron_id = fields.Many2one('ir.cron', ondelete='cascade', required=True, readonly=True)
mon = fields.Boolean(default=True)
tue = fields.Boolean(default=True)
wed = fields.Boolean(default=True)
thu = fields.Boolean(default=True)
fri = fields.Boolean(default=True)
sat = fields.Boolean()
sun = fields.Boolean()
recurrency_end_date = fields.Date('Until', help="This field is used in order to ")
available_location_ids = fields.Many2many('lunch.location', string='Location')
available_today = fields.Boolean('This is True when if the supplier is available today',
compute='_compute_available_today', search='_search_available_today')
order_deadline_passed = fields.Boolean(compute='_compute_order_deadline_passed')
tz = fields.Selection(_tz_get, string='Timezone', required=True, default=lambda self: self.env.user.tz or 'UTC')
active = fields.Boolean(default=True)
moment = fields.Selection([
('am', 'AM'),
('pm', 'PM'),
], default='am', required=True)
delivery = fields.Selection([
('delivery', 'Delivery'),
('no_delivery', 'No Delivery')
], default='no_delivery')
topping_label_1 = fields.Char('Extra 1 Label', required=True, default='Extras')
topping_label_2 = fields.Char('Extra 2 Label', required=True, default='Beverages')
topping_label_3 = fields.Char('Extra 3 Label', required=True, default='Extra Label 3')
topping_ids_1 = fields.One2many('lunch.topping', 'supplier_id', domain=[('topping_category', '=', 1)])
topping_ids_2 = fields.One2many('lunch.topping', 'supplier_id', domain=[('topping_category', '=', 2)])
topping_ids_3 = fields.One2many('lunch.topping', 'supplier_id', domain=[('topping_category', '=', 3)])
topping_quantity_1 = fields.Selection([
('0_more', 'None or More'),
('1_more', 'One or More'),
('1', 'Only One')], 'Extra 1 Quantity', default='0_more', required=True)
topping_quantity_2 = fields.Selection([
('0_more', 'None or More'),
('1_more', 'One or More'),
('1', 'Only One')], 'Extra 2 Quantity', default='0_more', required=True)
topping_quantity_3 = fields.Selection([
('0_more', 'None or More'),
('1_more', 'One or More'),
('1', 'Only One')], 'Extra 3 Quantity', default='0_more', required=True)
show_order_button = fields.Boolean(compute='_compute_buttons')
show_confirm_button = fields.Boolean(compute='_compute_buttons')
_sql_constraints = [
('automatic_email_time_range',
'CHECK(automatic_email_time >= 0 AND automatic_email_time <= 12)',
'Automatic Email Sending Time should be between 0 and 12'),
]
def name_get(self):
res = []
for supplier in self:
if supplier.phone:
res.append((supplier.id, '%s %s' % (supplier.name, supplier.phone)))
else:
res.append((supplier.id, supplier.name))
return res
def _sync_cron(self):
for supplier in self:
supplier = supplier.with_context(tz=supplier.tz)
sendat_tz = pytz.timezone(supplier.tz).localize(datetime.combine(
fields.Date.context_today(supplier),
float_to_time(supplier.automatic_email_time, supplier.moment)))
cron = supplier.cron_id.sudo()
lc = cron.lastcall
if ((
lc and sendat_tz.date() <= fields.Datetime.context_timestamp(supplier, lc).date()
) or (
not lc and sendat_tz <= fields.Datetime.context_timestamp(supplier, fields.Datetime.now())
)):
sendat_tz += timedelta(days=1)
sendat_utc = sendat_tz.astimezone(pytz.UTC).replace(tzinfo=None)
cron.active = supplier.active and supplier.send_by == 'mail'
cron.name = f"Lunch: send automatic email to {supplier.name}"
cron.nextcall = sendat_utc
cron.code = dedent(f"""\
# This cron is dynamically controlled by {self._description}.
# Do NOT modify this cron, modify the related record instead.
env['{self._name}'].browse([{supplier.id}])._send_auto_email()""")
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
for topping in vals.get('topping_ids_2', []):
topping[2].update({'topping_category': 2})
for topping in vals.get('topping_ids_3', []):
topping[2].update({'topping_category': 3})
crons = self.env['ir.cron'].sudo().create([
{
'user_id': self.env.ref('base.user_root').id,
'active': False,
'interval_type': 'days',
'interval_number': 1,
'numbercall': -1,
'doall': False,
'name': "Lunch: send automatic email",
'model_id': self.env['ir.model']._get_id(self._name),
'state': 'code',
'code': "",
}
for _ in range(len(vals_list))
])
self.env['ir.model.data'].sudo().create([{
'name': f'lunch_supplier_cron_sa_{cron.ir_actions_server_id.id}',
'module': 'lunch',
'res_id': cron.ir_actions_server_id.id,
'model': 'ir.actions.server',
# noupdate is set to true to avoid to delete record at module update
'noupdate': True,
} for cron in crons])
for vals, cron in zip(vals_list, crons):
vals['cron_id'] = cron.id
suppliers = super().create(vals_list)
suppliers._sync_cron()
return suppliers
def write(self, values):
for topping in values.get('topping_ids_2', []):
topping_values = topping[2]
if topping_values:
topping_values.update({'topping_category': 2})
for topping in values.get('topping_ids_3', []):
topping_values = topping[2]
if topping_values:
topping_values.update({'topping_category': 3})
if values.get('company_id'):
self.env['lunch.order'].search([('supplier_id', 'in', self.ids)]).write({'company_id': values['company_id']})
super().write(values)
if not CRON_DEPENDS.isdisjoint(values):
# flush automatic_email_time field to call _sql_constraints
if 'automatic_email_time' in values:
self.flush_model(['automatic_email_time'])
self._sync_cron()
def unlink(self):
crons = self.cron_id.sudo()
server_actions = crons.ir_actions_server_id
super().unlink()
crons.unlink()
server_actions.unlink()
def toggle_active(self):
""" Archiving related lunch product """
res = super().toggle_active()
active_suppliers = self.filtered(lambda s: s.active)
inactive_suppliers = self - active_suppliers
Product = self.env['lunch.product'].with_context(active_test=False)
Product.search([('supplier_id', 'in', active_suppliers.ids)]).write({'active': True})
Product.search([('supplier_id', 'in', inactive_suppliers.ids)]).write({'active': False})
return res
def _get_current_orders(self, state='ordered'):
""" Returns today's orders """
available_today = self.filtered('available_today')
if not available_today:
return self.env['lunch.order']
orders = self.env['lunch.order'].search([
('supplier_id', 'in', available_today.ids),
('state', '=', state),
('date', '=', fields.Date.context_today(self.with_context(tz=self.tz))),
], order="user_id, name")
return orders
def _send_auto_email(self):
""" Send an email to the supplier with the order of the day """
# Called daily by cron
self.ensure_one()
if not self.available_today:
return
if self.send_by != 'mail':
raise UserError(_("Cannot send an email to this supplier!"))
orders = self._get_current_orders()
if not orders:
return
order = {
'company_name': orders[0].company_id.name,
'currency_id': orders[0].currency_id.id,
'supplier_id': self.partner_id.id,
'supplier_name': self.name,
'email_from': self.responsible_id.email_formatted,
'amount_total': sum(order.price for order in orders),
}
sites = orders.mapped('user_id.last_lunch_location_id').sorted(lambda x: x.name)
orders_per_site = orders.sorted(lambda x: x.user_id.last_lunch_location_id.id)
email_orders = [{
'product': order.product_id.name,
'note': order.note,
'quantity': order.quantity,
'price': order.price,
'toppings': order.display_toppings,
'username': order.user_id.name,
'site': order.user_id.last_lunch_location_id.name,
} for order in orders_per_site]
email_sites = [{
'name': site.name,
'address': site.address,
} for site in sites]
self.env.ref('lunch.lunch_order_mail_supplier').with_context(
order=order, lines=email_orders, sites=email_sites
).send_mail(self.id)
orders.action_send()
@api.depends('recurrency_end_date', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun')
def _compute_available_today(self):
now = fields.Datetime.now().replace(tzinfo=pytz.UTC)
for supplier in self:
now = now.astimezone(pytz.timezone(supplier.tz))
supplier.available_today = supplier._available_on_date(now)
def _available_on_date(self, date):
self.ensure_one()
fieldname = WEEKDAY_TO_NAME[date.weekday()]
return not (self.recurrency_end_date and date.date() >= self.recurrency_end_date) and self[fieldname]
@api.depends('available_today', 'automatic_email_time', 'send_by')
def _compute_order_deadline_passed(self):
now = fields.Datetime.now().replace(tzinfo=pytz.UTC)
for supplier in self:
if supplier.send_by == 'mail':
now = now.astimezone(pytz.timezone(supplier.tz))
email_time = pytz.timezone(supplier.tz).localize(datetime.combine(
fields.Date.context_today(supplier),
float_to_time(supplier.automatic_email_time, supplier.moment)))
supplier.order_deadline_passed = supplier.available_today and now > email_time
else:
supplier.order_deadline_passed = not supplier.available_today
def _search_available_today(self, operator, value):
if (not operator in ['=', '!=']) or (not value in [True, False]):
return []
searching_for_true = (operator == '=' and value) or (operator == '!=' and not value)
now = fields.Datetime.now().replace(tzinfo=pytz.UTC).astimezone(pytz.timezone(self.env.user.tz or 'UTC'))
fieldname = WEEKDAY_TO_NAME[now.weekday()]
recurrency_domain = expression.OR([
[('recurrency_end_date', '=', False)],
[('recurrency_end_date', '>' if searching_for_true else '<', now)]
])
return expression.AND([
recurrency_domain,
[(fieldname, operator, value)]
])
def _compute_buttons(self):
self.env.cr.execute("""
SELECT supplier_id, state, COUNT(*)
FROM lunch_order
WHERE supplier_id IN %s
AND state in ('ordered', 'sent')
AND date = %s
AND active
GROUP BY supplier_id, state
""", (tuple(self.ids), fields.Date.context_today(self)))
supplier_orders = defaultdict(dict)
for order in self.env.cr.fetchall():
supplier_orders[order[0]][order[1]] = order[2]
for supplier in self:
supplier.show_order_button = supplier_orders[supplier.id].get('ordered', False)
supplier.show_confirm_button = supplier_orders[supplier.id].get('sent', False)
def action_send_orders(self):
no_auto_mail = self.filtered(lambda s: s.send_by != 'mail')
for supplier in self - no_auto_mail:
supplier._send_auto_email()
orders = no_auto_mail._get_current_orders()
orders.action_send()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'message': _('The orders have been sent!'),
'next': {'type': 'ir.actions.act_window_close'},
}
}
def action_confirm_orders(self):
orders = self._get_current_orders(state='sent')
orders.action_confirm()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'message': _('The orders have been confirmed!'),
'next': {'type': 'ir.actions.act_window_close'},
}
}

View file

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo.tools import formatLang
class LunchTopping(models.Model):
_name = 'lunch.topping'
_description = 'Lunch Extras'
name = fields.Char('Name', required=True)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
price = fields.Monetary('Price', required=True)
supplier_id = fields.Many2one('lunch.supplier', ondelete='cascade')
topping_category = fields.Integer('Topping Category', required=True, default=1)
def name_get(self):
currency_id = self.env.company.currency_id
res = dict(super(LunchTopping, self).name_get())
for topping in self:
price = formatLang(self.env, topping.price, currency_obj=currency_id)
res[topping.id] = '%s %s' % (topping.name, price)
return list(res.items())

View file

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class Company(models.Model):
_inherit = 'res.company'
lunch_minimum_threshold = fields.Float()
lunch_notify_message = fields.Html(
default="""Your lunch has been delivered.
Enjoy your meal!""", translate=True)

View file

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
company_lunch_minimum_threshold = fields.Float(string="Maximum Allowed Overdraft", readonly=False, related='company_id.lunch_minimum_threshold')
company_lunch_notify_message = fields.Html(string="Lunch notification message", readonly=False, related="company_id.lunch_notify_message")

View file

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResUsers(models.Model):
_inherit = 'res.users'
last_lunch_location_id = fields.Many2one('lunch.location')
favorite_lunch_product_ids = fields.Many2many('lunch.product', 'lunch_product_favorite_user_rel', 'user_id', 'product_id')