# Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import datetime from math import ceil, floor from dateutil.relativedelta import relativedelta from pytz import UTC, timezone from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.http import request from odoo.tools import format_amount from odoo.addons.sale_renting.models.product_pricing import PERIOD_RATIO class ProductTemplate(models.Model): _inherit = 'product.template' def _get_additionnal_combination_info(self, product_or_template, quantity, uom, date, website): """Override to add the information about renting for rental products If the product is rent_ok, this override adds the following information about the rental: - is_rental: Whether combination is rental, - rental_duration: The duration of the first defined product pricing on this product - rental_unit: The unit of the first defined product pricing on this product - default_start_date: If no pickup nor rental date in context, the start_date of the first renting sale order line in the cart; - default_end_date: If no pickup nor rental date in context, the end_date of the first renting sale order line in the cart; - current_rental_duration: If no pickup nor rental date in context, see rental_duration, otherwise, the duration between pickup and rental date in the current_rental_unit unit. - current_rental_unit: If no pickup nor rental date in context, see rental_unit, otherwise the unit of the best pricing for the renting between pickup and rental date. - current_rental_price: If no pickup nor rental date in context, see price, otherwise the price of the best pricing for the renting between pickup and rental date. """ res = super()._get_additionnal_combination_info(product_or_template, quantity, uom, date, website) if not product_or_template.rent_ok: return res res['list_price'] = res['price'] # No pricelist discount for rental prices currency = website.currency_id.with_context(self.env.context) pricelist = request.pricelist.with_context(self.env.context) ProductPricing = self.env['product.pricing'] pricing = ProductPricing._get_first_suitable_pricing(product_or_template, pricelist) if not pricing: return res # Compute best pricing rule or set default order_sudo = request and request.cart or self.env['sale.order'].sudo() start_date = order_sudo.rental_start_date or self.env.context.get('start_date') end_date = order_sudo.rental_return_date or self.env.context.get('end_date') if start_date and end_date: current_pricing = product_or_template._get_best_pricing_rule( start_date=start_date, end_date=end_date, pricelist=pricelist, currency=currency, ) current_unit = current_pricing.recurrence_id.unit current_duration = ProductPricing._compute_duration_vals( start_date, end_date )[current_unit] # Ensure minimum duration is 24 (hours) to ensure the return date is start date + 1 # Needed when the start date / end date are taken from the cart if current_pricing.recurrence_id.overnight: current_duration = max(current_duration, 24) else: current_unit = pricing.recurrence_id.unit current_duration = pricing.recurrence_id.duration current_pricing = pricing # Compute current price & set default times pickup_time = current_pricing.recurrence_id.pickup_time return_time = current_pricing.recurrence_id.return_time start_date, end_date = self._get_default_renting_dates( start_date, end_date, current_duration, current_unit, pickup_time, return_time ) # Here we don't add the current_attributes_price_extra nor the # no_variant_attributes_price_extra to the context since those prices are not added # in the context of rental. current_price = pricelist._get_product_price( product=product_or_template, quantity=quantity, currency=currency, start_date=start_date, end_date=end_date, ) ratio = ceil(current_duration) / pricing.recurrence_id.duration if pricing.recurrence_id.duration else 1 if current_unit != pricing.recurrence_id.unit: ratio *= PERIOD_RATIO[current_unit] / PERIOD_RATIO[pricing.recurrence_id.unit] # apply taxes product_taxes = res['product_taxes'] if product_taxes: current_price = self.env['product.template']._apply_taxes_to_price( current_price, currency, product_taxes, res['taxes'], product_or_template, ) suitable_pricings = ProductPricing._get_suitable_pricings(product_or_template, pricelist) # If there are multiple pricings with the same recurrence, we only keep the cheapest ones best_pricings = {} for p in suitable_pricings: if p.recurrence_id not in best_pricings: best_pricings[p.recurrence_id] = p elif best_pricings[p.recurrence_id].price > p.price: best_pricings[p.recurrence_id] = p suitable_pricings = best_pricings.values() def _pricing_price(pricing): if product_taxes: price = self.env['product.template']._apply_taxes_to_price( pricing.price, currency, product_taxes, res['taxes'], product_or_template ) else: price = pricing.price if pricing.currency_id == currency: return price return pricing.currency_id._convert( from_amount=price, to_currency=currency, company=self.env.company, date=date, ) pricing_table = [ (p.name, format_amount(self.env, _pricing_price(p), currency)) for p in suitable_pricings ] recurrence = pricing.recurrence_id current_duration, current_rental_unit = ( current_pricing.recurrence_id._get_converted_duration_and_label(current_duration) ) return { **res, 'is_rental': True, 'rental_duration': recurrence.duration, 'rental_duration_unit': recurrence.unit, 'rental_unit': recurrence._get_unit_label(recurrence.duration), 'overnight_period': recurrence.overnight, 'default_start_date': start_date, 'default_end_date': end_date, 'current_rental_duration': ceil(current_duration), 'current_rental_unit': current_rental_unit, 'current_rental_price': current_price, 'current_rental_price_per_unit': current_price / (ratio or 1), 'base_unit_price': 0, 'base_unit_name': False, 'pricing_table': pricing_table, 'prevent_zero_price_sale': website.prevent_zero_price_sale and currency.is_zero( current_price, ), } @api.model def _get_default_renting_dates(self, start_date, end_date, duration, unit, pickup_time, return_time): """ Get default renting dates to help user :param datetime start_date: a start_date which is directly returned if defined :param datetime end_date: a end_date which is directly returned if defined :param int duration: the duration expressed in int, in the unit given :param string unit: The duration unit, which can be 'hour', 'day', 'week' or 'month' :param float pickup_time: The pickup time in hours (0-23.99) :param float return_time: The return time in hours (0-23.99) """ if start_date and end_date and start_date >= end_date: raise UserError(_("Please choose a return date that is after the pickup date.")) if start_date or end_date: return start_date, end_date default_start_dt = self._get_default_start_date(pickup_time) if unit == 'hour': default_end_dt = self._get_default_end_date( default_start_dt, duration, unit, return_time, ) else: # If unit in day, week, month, take into account the entire day. # 21st + 1 day --> from 21st 00:00:00 to 22nd 23:59:59 default_start_dt = datetime.combine(default_start_dt.date(), datetime.min.time()) # remove a second to avoid adding a day (from date point of view) default_end_dt = self._get_default_end_date( default_start_dt + relativedelta(seconds=-1), duration, unit, return_time, ) # Consider the timezone if frontend request # Return the UTC value according to the client # because the frontend will convert values according to its timezone # (and without conversion, we risk changing day). if request and request.is_frontend and request.cookies.get('tz'): client_tz = timezone(request.cookies['tz']) default_start_dt = client_tz.localize(default_start_dt).astimezone(UTC) default_end_dt = client_tz.localize(default_end_dt).astimezone(UTC) return default_start_dt, default_end_dt @api.model def _get_default_start_date(self, pickup_time): """ Get the default pickup date and make it extensible """ first_date = self._get_first_potential_date( fields.Datetime.now() + relativedelta(days=1, hours=1, minute=0, second=0, microsecond=0) ) if pickup_time: website_tz = timezone(request.website.tz) # convert first_date in website TZ before replacing the hours pickup_hour = floor(pickup_time) first_date_website_tz = website_tz.localize(first_date).replace( hour=pickup_hour, minute=round((pickup_time - pickup_hour) * 60), ) # send it back in UTC (naive) first_date = first_date_website_tz.astimezone(UTC).replace(tzinfo=None) return first_date @api.model def _get_default_end_date(self, start_date, duration, unit, return_time): """ Get the default return date based on pickup date and duration :param datetime start_date: the default start_date :param int duration: the duration expressed in int, in the unit given :param string unit: The duration unit, which can be 'hour', 'day', 'week' or 'month' """ return_date = self._get_first_potential_date(max( start_date + relativedelta(**{f'{unit}s': duration}), start_date + self.env.company._get_minimal_rental_duration(), )) if return_time: website_tz = timezone(request.website.tz) # convert return_date in website TZ before replacing the hours return_hour = floor(return_time) return_date_website_tz = return_date.astimezone(website_tz).replace( hour=return_hour, minute=round((return_time - return_hour) * 60), ) # send it back in UTC (naive) return_date = return_date_website_tz.astimezone(UTC).replace(tzinfo=None) return return_date @api.model def _get_first_potential_date(self, date): """ Get the first potential date which respects company unavailability days settings """ days_forbidden = self.env.company._get_renting_forbidden_days() weekday = date.isoweekday() for i in range(7): if ((weekday + i) % 7 or 7) not in days_forbidden: break return date + relativedelta(days=i) def _search_render_results_prices(self, mapping, combination_info): if not combination_info.get('is_rental'): return super()._search_render_results_prices(mapping, combination_info) duration = combination_info['rental_duration'] unit = combination_info['rental_unit'] if combination_info['overnight_period']: duration = combination_info['current_rental_duration'] unit = combination_info['current_rental_unit'] return self.env['ir.ui.view']._render_template( 'website_sale_renting.rental_search_result_price', values={ 'currency': mapping['detail']['display_currency'], 'price': combination_info['price'], 'duration': duration, 'unit': unit, } ), None def _get_sales_prices(self, website): prices = super()._get_sales_prices(website) pricelist = request.pricelist for template in self: if not template.rent_ok: continue pricing = self.env['product.pricing']._get_first_suitable_pricing(template, pricelist) if pricing: recurrence = pricing.recurrence_id duration, unit = recurrence._get_converted_duration_and_label(recurrence.duration) prices[template.id]['rental_duration'] = duration prices[template.id]['rental_unit'] = unit else: prices[template.id]['rental_duration'] = 0 prices[template.id]['rental_unit'] = False return prices def _search_get_detail(self, website, order, options): search_details = super()._search_get_detail(website, order, options) if options.get('rent_only') or (options.get('from_date') and options.get('to_date')): search_details['base_domain'].append([('rent_ok', '=', True)]) return search_details def _website_show_quick_add(self): return super()._website_show_quick_add() and self.product_variant_id._can_be_added_to_current_cart()