diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/lunch/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/lunch/models')
| -rw-r--r-- | addons/lunch/models/__init__.py | 12 | ||||
| -rw-r--r-- | addons/lunch/models/lunch_alert.py | 119 | ||||
| -rw-r--r-- | addons/lunch/models/lunch_cashmove.py | 30 | ||||
| -rw-r--r-- | addons/lunch/models/lunch_location.py | 13 | ||||
| -rw-r--r-- | addons/lunch/models/lunch_order.py | 208 | ||||
| -rw-r--r-- | addons/lunch/models/lunch_product.py | 131 | ||||
| -rw-r--r-- | addons/lunch/models/lunch_supplier.py | 187 | ||||
| -rw-r--r-- | addons/lunch/models/res_company.py | 10 | ||||
| -rw-r--r-- | addons/lunch/models/res_config_settings.py | 11 | ||||
| -rw-r--r-- | addons/lunch/models/res_users.py | 11 |
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') |
