summaryrefslogtreecommitdiff
path: root/addons/lunch/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/lunch/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/lunch/models')
-rw-r--r--addons/lunch/models/__init__.py12
-rw-r--r--addons/lunch/models/lunch_alert.py119
-rw-r--r--addons/lunch/models/lunch_cashmove.py30
-rw-r--r--addons/lunch/models/lunch_location.py13
-rw-r--r--addons/lunch/models/lunch_order.py208
-rw-r--r--addons/lunch/models/lunch_product.py131
-rw-r--r--addons/lunch/models/lunch_supplier.py187
-rw-r--r--addons/lunch/models/res_company.py10
-rw-r--r--addons/lunch/models/res_config_settings.py11
-rw-r--r--addons/lunch/models/res_users.py11
10 files changed, 732 insertions, 0 deletions
diff --git a/addons/lunch/models/__init__.py b/addons/lunch/models/__init__.py
new file mode 100644
index 00000000..94739d3c
--- /dev/null
+++ b/addons/lunch/models/__init__.py
@@ -0,0 +1,12 @@
+# -*- 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_supplier
+from . import res_company
+from . import res_config_settings
+from . import res_users
diff --git a/addons/lunch/models/lunch_alert.py b/addons/lunch/models/lunch_alert.py
new file mode 100644
index 00000000..9110f792
--- /dev/null
+++ b/addons/lunch/models/lunch_alert.py
@@ -0,0 +1,119 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import pytz
+
+from odoo import api, fields, models
+from odoo.osv import expression
+
+from .lunch_supplier import float_to_time
+from datetime import datetime, timedelta
+
+from odoo.addons.base.models.res_partner import _tz_get
+
+WEEKDAY_TO_NAME = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
+
+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')
+
+ until = fields.Date('Show Until')
+ recurrency_monday = fields.Boolean('Monday', default=True)
+ recurrency_tuesday = fields.Boolean('Tuesday', default=True)
+ recurrency_wednesday = fields.Boolean('Wednesday', default=True)
+ recurrency_thursday = fields.Boolean('Thursday', default=True)
+ recurrency_friday = fields.Boolean('Friday', default=True)
+ recurrency_saturday = fields.Boolean('Saturday', default=True)
+ recurrency_sunday = fields.Boolean('Sunday', 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('recurrency_monday', 'recurrency_tuesday', 'recurrency_wednesday',
+ 'recurrency_thursday', 'recurrency_friday', 'recurrency_saturday',
+ 'recurrency_sunday')
+ def _compute_available_today(self):
+ today = fields.Date.context_today(self)
+ fieldname = 'recurrency_%s' % (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 = 'recurrency_%s' % (WEEKDAY_TO_NAME[today.weekday()])
+
+ return expression.AND([
+ [(fieldname, operator, value)],
+ expression.OR([
+ [('until', '=', False)],
+ [('until', '>' if searching_for_true else '<', today)],
+ ])
+ ])
+
+ def _notify_chat(self):
+ records = self.search([('mode', '=', 'chat'), ('active', '=', True)])
+
+ today = fields.Date.today()
+ now = fields.Datetime.now()
+
+ for alert in records:
+ notification_to = now.astimezone(pytz.timezone(alert.tz)).replace(second=0, microsecond=0, tzinfo=None)
+ notification_from = notification_to - timedelta(minutes=5)
+ send_at = datetime.combine(fields.Date.today(),
+ float_to_time(alert.notification_time, alert.notification_moment))
+
+ if alert.available_today and send_at > notification_from and send_at <= notification_to:
+ order_domain = [('state', '!=', 'cancelled')]
+
+ if alert.location_ids.ids:
+ order_domain = expression.AND([order_domain, [('user_id.last_lunch_location_id', 'in', alert.location_ids.ids)]])
+
+ if alert.recipients != 'everyone':
+ weeks = 1
+
+ if alert.recipients == 'last_month':
+ weeks = 4
+ else: # last_year
+ weeks = 52
+
+ delta = timedelta(weeks=weeks)
+ order_domain = expression.AND([order_domain, [('date', '>=', today - delta)]])
+
+ orders = self.env['lunch.order'].search(order_domain).mapped('user_id')
+ partner_ids = [user.partner_id.id for user in orders]
+ if partner_ids:
+ self.env['mail.thread'].message_notify(body=alert.message, partner_ids=partner_ids)
diff --git a/addons/lunch/models/lunch_cashmove.py b/addons/lunch/models/lunch_cashmove.py
new file mode 100644
index 00000000..bb7a0380
--- /dev/null
+++ b/addons/lunch/models/lunch_cashmove.py
@@ -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)
+ 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
diff --git a/addons/lunch/models/lunch_location.py b/addons/lunch/models/lunch_location.py
new file mode 100644
index 00000000..5bb47de3
--- /dev/null
+++ b/addons/lunch/models/lunch_location.py
@@ -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)
diff --git a/addons/lunch/models/lunch_order.py b/addons/lunch/models/lunch_order.py
new file mode 100644
index 00000000..e25a5507
--- /dev/null
+++ b/addons/lunch/models/lunch_order.py
@@ -0,0 +1,208 @@
+# -*- 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
+
+
+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", readonly=True) # to remove
+ 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)
+ user_id = fields.Many2one('res.users', 'User', readonly=True,
+ states={'new': [('readonly', False)]},
+ default=lambda self: self.env.uid)
+ note = fields.Text('Notes')
+ price = fields.Float('Total Price', compute='_compute_total_price', readonly=True, store=True,
+ digits='Account')
+ active = fields.Boolean('Active', default=True)
+ state = fields.Selection([('new', 'To Order'),
+ ('ordered', 'Ordered'),
+ ('confirmed', 'Received'),
+ ('cancelled', 'Cancelled')],
+ 'Status', readonly=True, index=True, default='new')
+ 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.Text('Description', related='product_id.description')
+ topping_label_1 = fields.Char(related='product_id.category_id.topping_label_1')
+ topping_label_2 = fields.Char(related='product_id.category_id.topping_label_2')
+ topping_label_3 = fields.Char(related='product_id.category_id.topping_label_3')
+ topping_quantity_1 = fields.Selection(related='product_id.category_id.topping_quantity_1')
+ topping_quantity_2 = fields.Selection(related='product_id.category_id.topping_quantity_2')
+ topping_quantity_3 = fields.Selection(related='product_id.category_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')
+
+ @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 line in self:
+ line.available_toppings_1 = bool(line.env['lunch.topping'].search_count([('category_id', '=', line.category_id.id), ('topping_category', '=', 1)]))
+ line.available_toppings_2 = bool(line.env['lunch.topping'].search_count([('category_id', '=', line.category_id.id), ('topping_category', '=', 2)]))
+ line.available_toppings_3 = bool(line.env['lunch.topping'].search_count([('category_id', '=', line.category_id.id), ('topping_category', '=', 3)]))
+
+ 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
+ def create(self, values):
+ lines = self._find_matching_lines({
+ **values,
+ 'toppings': self._extract_toppings(values),
+ })
+ if lines:
+ # 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)
+ return lines[:1]
+ return super().create(values)
+
+ 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
+
+ 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_cache(['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,
+ })
+ if matching_lines:
+ lines_to_deactivate |= line
+ # YTI TODO Try to batch it, be careful there might be multiple matching
+ # lines for the same order hence quantity should not always be
+ # line.quantity, but rather a sum
+ 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):
+ 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)),
+ ]
+ 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 != '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.flush()
+ 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):
+ if self.filtered(lambda line: not line.product_id.active):
+ raise ValidationError(_('Product is no longer available.'))
+ self.write({'state': 'ordered'})
+ self._check_wallet()
+
+ def action_confirm(self):
+ self.write({'state': 'confirmed'})
+
+ def action_cancel(self):
+ self.write({'state': 'cancelled'})
diff --git a/addons/lunch/models/lunch_product.py b/addons/lunch/models/lunch_product.py
new file mode 100644
index 00000000..e787c154
--- /dev/null
+++ b/addons/lunch/models/lunch_product.py
@@ -0,0 +1,131 @@
+# -*- 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.exceptions import UserError
+from odoo.modules.module import get_module_resource
+from odoo.tools import formatLang
+
+
+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')
+ 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', 'category_id', domain=[('topping_category', '=', 1)])
+ topping_ids_2 = fields.One2many('lunch.topping', 'category_id', domain=[('topping_category', '=', 2)])
+ topping_ids_3 = fields.One2many('lunch.topping', 'category_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)
+ 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)
+
+ @api.model
+ def create(self, vals):
+ 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})
+ return super(LunchProductCategory, self).create(vals)
+
+ def write(self, vals):
+ for topping in vals.get('topping_ids_2', []):
+ topping_values = topping[2]
+ if topping_values:
+ topping_values.update({'topping_category': 2})
+ for topping in vals.get('topping_ids_3', []):
+ topping_values = topping[2]
+ if topping_values:
+ topping_values.update({'topping_category': 3})
+ return super(LunchProductCategory, self).write(vals)
+
+ 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
+
+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.Float('Price', digits='Account', required=True)
+ category_id = fields.Many2one('lunch.product.category', ondelete='cascade')
+ topping_category = fields.Integer('Topping Category', help="This field is a technical field", 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())
+
+
+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.Text('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')
+ favorite_user_ids = fields.Many2many('res.users', 'lunch_product_favorite_user_rel', 'product_id', 'user_id', check_company=True)
+
+ 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):
+ if self.filtered(lambda product: not product.active and not product.category_id.active):
+ raise UserError(_("The product category is archived. The user have to unarchive the category or change the category of the product."))
+ if self.filtered(lambda product: not product.active and not product.supplier_id.active):
+ raise UserError(_("The product supplier is archived. The user have to unarchive the supplier or change the supplier of the product."))
+ return super().toggle_active()
diff --git a/addons/lunch/models/lunch_supplier.py b/addons/lunch/models/lunch_supplier.py
new file mode 100644
index 00000000..2514f03f
--- /dev/null
+++ b/addons/lunch/models/lunch_supplier.py
@@ -0,0 +1,187 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import math
+import pytz
+
+from datetime import datetime, time
+
+from odoo import api, fields, models
+from odoo.osv import expression
+from odoo.tools import float_round
+
+from odoo.addons.base.models.res_partner import _tz_get
+
+
+WEEKDAY_TO_NAME = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
+
+def float_to_time(hours, moment='am', tz=None):
+ """ 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
+ res = time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
+ if tz:
+ res = res.replace(tzinfo=pytz.timezone(tz))
+ return res
+
+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)
+
+ recurrency_monday = fields.Boolean('Monday', default=True)
+ recurrency_tuesday = fields.Boolean('Tuesday', default=True)
+ recurrency_wednesday = fields.Boolean('Wednesday', default=True)
+ recurrency_thursday = fields.Boolean('Thursday', default=True)
+ recurrency_friday = fields.Boolean('Friday', default=True)
+ recurrency_saturday = fields.Boolean('Saturday')
+ recurrency_sunday = fields.Boolean('Sunday')
+
+ 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')
+
+ 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')
+
+ _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 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([('supplier_id', 'in', self.ids)])
+ all_products._sync_active_from_related()
+ return res
+
+ @api.model
+ def _auto_email_send(self):
+ """
+ This method is called every 20 minutes via a cron.
+ Its job is simply to get all the orders made for each supplier and send an email
+ automatically to the supplier if the supplier is configured for it and we are ready
+ to send it (usually at 11am or so)
+ """
+ records = self.search([('send_by', '=', 'mail')])
+
+ for supplier in records:
+ send_at = datetime.combine(fields.Date.today(),
+ float_to_time(supplier.automatic_email_time, supplier.moment, supplier.tz)).astimezone(pytz.UTC).replace(tzinfo=None)
+ if supplier.available_today and fields.Datetime.now() > send_at:
+ lines = self.env['lunch.order'].search([('supplier_id', '=', supplier.id),
+ ('state', '=', 'ordered'), ('date', '=', fields.Date.today())])
+
+ if lines:
+ order = {
+ 'company_name': lines[0].company_id.name,
+ 'currency_id': lines[0].currency_id.id,
+ 'supplier_id': supplier.partner_id.id,
+ 'supplier_name': supplier.name,
+ 'email_from': supplier.responsible_id.email_formatted,
+ }
+
+ _lines = [{
+ 'product': line.product_id.name,
+ 'note': line.note,
+ 'quantity': line.quantity,
+ 'price': line.price,
+ 'toppings': line.display_toppings,
+ 'username': line.user_id.name,
+ } for line in lines]
+
+ order['amount_total'] = sum(line.price for line in lines)
+
+ self.env.ref('lunch.lunch_order_mail_supplier').with_context(order=order, lines=_lines).send_mail(supplier.id)
+
+ lines.action_confirm()
+
+ @api.depends('recurrency_end_date', 'recurrency_monday', 'recurrency_tuesday',
+ 'recurrency_wednesday', 'recurrency_thursday', 'recurrency_friday',
+ 'recurrency_saturday', 'recurrency_sunday')
+ def _compute_available_today(self):
+ now = fields.Datetime.now().replace(tzinfo=pytz.UTC)
+
+ for supplier in self:
+ now = now.astimezone(pytz.timezone(supplier.tz))
+
+ if supplier.recurrency_end_date and now.date() >= supplier.recurrency_end_date:
+ supplier.available_today = False
+ else:
+ fieldname = 'recurrency_%s' % (WEEKDAY_TO_NAME[now.weekday()])
+ supplier.available_today = supplier[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)
+
+ now = fields.Datetime.now().replace(tzinfo=pytz.UTC).astimezone(pytz.timezone(self.env.user.tz or 'UTC'))
+ fieldname = 'recurrency_%s' % (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)]
+ ])
diff --git a/addons/lunch/models/res_company.py b/addons/lunch/models/res_company.py
new file mode 100644
index 00000000..95d91d09
--- /dev/null
+++ b/addons/lunch/models/res_company.py
@@ -0,0 +1,10 @@
+# -*- 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()
diff --git a/addons/lunch/models/res_config_settings.py b/addons/lunch/models/res_config_settings.py
new file mode 100644
index 00000000..faa8f256
--- /dev/null
+++ b/addons/lunch/models/res_config_settings.py
@@ -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 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')
diff --git a/addons/lunch/models/res_users.py b/addons/lunch/models/res_users.py
new file mode 100644
index 00000000..bc7cd5ae
--- /dev/null
+++ b/addons/lunch/models/res_users.py
@@ -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')