commit db4f4f7ef126519e9e34cdb708bd5537f756448b Author: Alaguraj0361 Date: Fri Jun 12 15:12:50 2026 +0530 first commit diff --git a/addons/event_rental/__init__.py b/addons/event_rental/__init__.py new file mode 100644 index 0000000..c3d410e --- /dev/null +++ b/addons/event_rental/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import controllers diff --git a/addons/event_rental/__manifest__.py b/addons/event_rental/__manifest__.py new file mode 100644 index 0000000..259fea2 --- /dev/null +++ b/addons/event_rental/__manifest__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Event Rental Management', + 'version': '17.0.1.0.0', + 'category': 'Sales/Rental', + 'summary': 'Manage event decoration and equipment rentals', + 'description': """ + Event Rental Management System for Wedding Decorations, Birthday Decorations, + Corporate Event Setup, Stage Decorations, Sound Systems, Lighting, Furniture, Tents, etc. + """, + 'author': 'Antigravity', + 'depends': ['website', 'sale_management', 'stock', 'portal', 'calendar'], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'data/sequence.xml', + 'data/mail_templates.xml', + 'views/product_template_views.xml', + 'views/event_rental_request_views.xml', + 'views/website_templates.xml', + 'views/portal_templates.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/addons/event_rental/__pycache__/__init__.cpython-310.pyc b/addons/event_rental/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..b55446a Binary files /dev/null and b/addons/event_rental/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/event_rental/controllers/__init__.py b/addons/event_rental/controllers/__init__.py new file mode 100644 index 0000000..65a8c12 --- /dev/null +++ b/addons/event_rental/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import main diff --git a/addons/event_rental/controllers/__pycache__/__init__.cpython-310.pyc b/addons/event_rental/controllers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..df6702b Binary files /dev/null and b/addons/event_rental/controllers/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/event_rental/controllers/__pycache__/main.cpython-310.pyc b/addons/event_rental/controllers/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..5bb6221 Binary files /dev/null and b/addons/event_rental/controllers/__pycache__/main.cpython-310.pyc differ diff --git a/addons/event_rental/controllers/main.py b/addons/event_rental/controllers/main.py new file mode 100644 index 0000000..551d6d2 --- /dev/null +++ b/addons/event_rental/controllers/main.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +import base64 +import logging +from datetime import datetime +from odoo import http, fields, _, exceptions +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager + +_logger = logging.getLogger(__name__) + +def parse_html_datetime(dt_str): + if not dt_str: + return None + for fmt in ( + "%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", + "%m/%d/%Y %I:%M %p", "%d/%m/%Y %I:%M %p", + "%m/%d/%Y %H:%M", "%d/%m/%Y %H:%M", + "%m/%d/%Y", "%d/%m/%Y" + ): + try: + return datetime.strptime(dt_str.strip(), fmt) + except ValueError: + continue + try: + return fields.Datetime.to_datetime(dt_str) + except Exception: + return None + +class EventRentalController(http.Controller): + + @http.route('/rentals', type='http', auth='public', website=True) + def rental_catalog(self, search='', category=None, **post): + domain = [('is_rental', '=', True)] + if search: + domain.append(('name', 'ilike', search)) + + # Fetch products matching domain + products = request.env['product.template'].sudo().search(domain) + + # Extract categories represented by these products to display in the filter sidebar + categories = products.mapped('categ_id') + + if category: + domain.append(('categ_id', '=', int(category))) + products = request.env['product.template'].sudo().search(domain) + + values = { + 'products': products, + 'categories': categories, + 'search': search, + 'current_category': int(category) if category else None, + } + return request.render('event_rental.rental_catalog_template', values) + + @http.route('/rentals/', type='http', auth='public', website=True) + def rental_product_detail(self, product, **post): + return request.render('event_rental.rental_product_detail_template', {'product': product}) + + @http.route('/rental/request', type='http', auth='public', website=True, methods=['GET', 'POST']) + def rental_request(self, product_id=None, **post): + if request.httprequest.method == 'GET': + selected_product = None + if product_id: + # Browse product product variant + product_tmpl = request.env['product.template'].sudo().browse(int(product_id)) + selected_product = product_tmpl.product_variant_id + all_products = request.env['product.product'].sudo().search([('is_rental', '=', True)]) + return request.render('event_rental.rental_request_form_template', { + 'all_products': all_products, + 'selected_product': selected_product, + 'error_message': None, + 'post': post + }) + + # Process POST request + try: + _logger.info("RENTAL REQUEST POST PARAMS (product_id=%s): %s", product_id, post) + customer_name = post.get('customer_name') + customer_email = post.get('customer_email') + customer_phone = post.get('customer_phone') + company_name = post.get('company_name') + customer_address = post.get('customer_address') + + start_date_str = post.get('start_date') + end_date_str = post.get('end_date') + location = post.get('location') + event_type = post.get('event_type') + + req_product_id = int(product_id or post.get('product_id') or 0) + quantity = float(post.get('quantity') or 1.0) + doc_type = post.get('doc_type', 'aadhaar') + id_proof_file = request.httprequest.files.get('id_proof') + + # Simple UI checks + if not customer_name or not customer_email or not customer_phone or not start_date_str or not end_date_str or not location or not req_product_id: + raise ValueError(_("All required fields must be filled.")) + + if not id_proof_file or id_proof_file.filename == '': + raise ValueError(_("Please upload a valid Government ID Proof document.")) + + start_date = parse_html_datetime(start_date_str) + end_date = parse_html_datetime(end_date_str) + + if not start_date or not end_date: + raise ValueError(_("Invalid start or end date format.")) + + if start_date >= end_date: + raise ValueError(_("Event Start Date must be earlier than End Date.")) + + product = request.env['product.product'].sudo().browse(req_product_id) + if not product: + raise ValueError(_("Invalid rental product selected.")) + + # Availability checking + dummy_request = request.env['event.rental.request'].sudo() + available_qty = dummy_request.check_availability(start_date, end_date, product) + + if available_qty < quantity: + raise ValueError(_("Product '%s' is not available in the quantity requested (%s) for the selected dates. Only %s units are currently available.") % ( + product.name, quantity, available_qty + )) + + # Find or create partner + partner = request.env['res.partner'].sudo().search([('email', '=', customer_email)], limit=1) + if not partner: + partner = request.env['res.partner'].sudo().create({ + 'name': customer_name, + 'email': customer_email, + 'phone': customer_phone, + 'company_name': company_name, + 'street': customer_address, + }) + + # Create rental request record + rental_request = request.env['event.rental.request'].sudo().create({ + 'partner_id': partner.id, + 'customer_name': customer_name, + 'customer_email': customer_email, + 'customer_phone': customer_phone, + 'company_name': company_name, + 'customer_address': customer_address, + 'start_date': start_date, + 'end_date': end_date, + 'location': location, + 'event_type': event_type, + 'status': 'under_review', + }) + + # Create request line + request.env['event.rental.line'].sudo().create({ + 'request_id': rental_request.id, + 'product_id': product.id, + 'quantity': quantity, + }) + + # Store ID proof attachment + file_content = id_proof_file.read() + file_base64 = base64.b64encode(file_content) + attachment = request.env['ir.attachment'].sudo().create({ + 'name': id_proof_file.filename, + 'type': 'binary', + 'datas': file_base64, + 'res_model': 'event.rental.request', + 'res_id': rental_request.id, + }) + + # Create document record + request.env['event.document'].sudo().create({ + 'request_id': rental_request.id, + 'partner_id': partner.id, + 'doc_type': doc_type, + 'attachment_id': attachment.id, + 'verification_status': 'pending', + }) + + # Post submission trace + rental_request.message_post(body=_("Rental request submitted successfully from public website.")) + + return request.redirect(f'/rental/request/success?name={rental_request.name}') + + except Exception as e: + selected_product = None + if post.get('product_id'): + selected_product = request.env['product.product'].sudo().browse(int(post.get('product_id'))) + all_products = request.env['product.product'].sudo().search([('is_rental', '=', True)]) + return request.render('event_rental.rental_request_form_template', { + 'all_products': all_products, + 'selected_product': selected_product, + 'error_message': str(e), + 'post': post + }) + + @http.route('/rental/request/success', type='http', auth='public', website=True) + def rental_request_success(self, name, **post): + return request.render('event_rental.rental_request_success_template', {'name': name}) + + +class CustomerPortalRental(CustomerPortal): + + def _prepare_home_portal_values(self, counters): + values = super()._prepare_home_portal_values(counters) + if 'rental_count' in counters: + partner = request.env.user.partner_id + rental_count = request.env['event.rental.request'].search_count([ + ('partner_id', '=', partner.id) + ]) + values['rental_count'] = rental_count + return values + + @http.route(['/my/rentals', '/my/rentals/page/'], type='http', auth="user", website=True) + def portal_my_rentals(self, page=1, date_begin=None, date_end=None, sortby=None, **kw): + values = self._prepare_portal_layout_values() + partner = request.env.user.partner_id + RentalRequest = request.env['event.rental.request'] + domain = [('partner_id', '=', partner.id)] + + # Count for pagination + rental_count = RentalRequest.search_count(domain) + pager = portal_pager( + url="/my/rentals", + total=rental_count, + page=page, + step=10 + ) + + requests = RentalRequest.search(domain, limit=10, offset=pager['offset']) + + values.update({ + 'requests': requests, + 'page_name': 'rental_requests', + 'pager': pager, + 'default_url': '/my/rentals', + }) + return request.render("event_rental.portal_my_rental_requests", values) + + @http.route(['/my/rentals/'], type='http', auth="user", website=True) + def portal_rental_request_detail(self, request_id, **kw): + rental_request = request.env['event.rental.request'].browse(request_id) + try: + # Enforce native record rules / access rights + rental_request.check_access_rights('read') + rental_request.check_access_rule('read') + except exceptions.AccessError: + return request.redirect('/my') + + values = { + 'rental_request': rental_request, + 'page_name': 'rental_request_detail', + } + return request.render("event_rental.portal_rental_request_detail_template", values) diff --git a/addons/event_rental/data/mail_templates.xml b/addons/event_rental/data/mail_templates.xml new file mode 100644 index 0000000..b4bcbb2 --- /dev/null +++ b/addons/event_rental/data/mail_templates.xml @@ -0,0 +1,53 @@ + + + + + Event Rental: Approved & Quotation Ready + + Your Event Rental Request {{ object.name }} has been Approved + {{ object.create_uid.email_formatted or object.env.company.email_formatted }} + {{ object.customer_email }} + +
+

Rental Request Approved!

+

Dear ,

+

We are happy to inform you that your event rental request has been approved for your event scheduled on .

+

We have generated a Sales Quotation for you. Please review the rental details:

+ + + + + + + + + + + + + + + + + +
Request Number:
Rental Period: + From to +
Event Location:
Event Type:
+ +

To view your detailed quotation, check pricing, and make online payment to confirm your booking, please click the button below:

+ + +

Please note that your items are tentatively held. Payment is required to guarantee your rental reservation.

+

Thank you for choosing us for your event!

+

Best regards,
+ Event Rental Team

+
+
+
+
+
diff --git a/addons/event_rental/data/sequence.xml b/addons/event_rental/data/sequence.xml new file mode 100644 index 0000000..180455c --- /dev/null +++ b/addons/event_rental/data/sequence.xml @@ -0,0 +1,12 @@ + + + + + Event Rental Request Sequence + event.rental.request + RENT/%(year)s/ + 4 + + + + diff --git a/addons/event_rental/models/__init__.py b/addons/event_rental/models/__init__.py new file mode 100644 index 0000000..0a1c454 --- /dev/null +++ b/addons/event_rental/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import product_template +from . import event_rental_request +from . import sale_order diff --git a/addons/event_rental/models/__pycache__/__init__.cpython-310.pyc b/addons/event_rental/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..0d6d277 Binary files /dev/null and b/addons/event_rental/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/event_rental/models/__pycache__/event_rental_request.cpython-310.pyc b/addons/event_rental/models/__pycache__/event_rental_request.cpython-310.pyc new file mode 100644 index 0000000..a6a8229 Binary files /dev/null and b/addons/event_rental/models/__pycache__/event_rental_request.cpython-310.pyc differ diff --git a/addons/event_rental/models/__pycache__/product_template.cpython-310.pyc b/addons/event_rental/models/__pycache__/product_template.cpython-310.pyc new file mode 100644 index 0000000..468a782 Binary files /dev/null and b/addons/event_rental/models/__pycache__/product_template.cpython-310.pyc differ diff --git a/addons/event_rental/models/__pycache__/sale_order.cpython-310.pyc b/addons/event_rental/models/__pycache__/sale_order.cpython-310.pyc new file mode 100644 index 0000000..58c5e21 Binary files /dev/null and b/addons/event_rental/models/__pycache__/sale_order.cpython-310.pyc differ diff --git a/addons/event_rental/models/event_rental_request.py b/addons/event_rental/models/event_rental_request.py new file mode 100644 index 0000000..361b688 --- /dev/null +++ b/addons/event_rental/models/event_rental_request.py @@ -0,0 +1,368 @@ +# -*- coding: utf-8 -*- +import logging +from odoo import models, fields, api, exceptions, _ +from odoo.exceptions import UserError +import urllib.parse + +_logger = logging.getLogger(__name__) + +class EventRentalRequest(models.Model): + _name = 'event.rental.request' + _description = 'Event Rental Request' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'id desc' + + name = fields.Char(string='Request Number', required=True, copy=False, readonly=True, index=True, default=lambda self: _('New')) + partner_id = fields.Many2one('res.partner', string='Customer', tracking=True) + + # Customer Details (Useful for Guest/Website checkouts) + customer_name = fields.Char(string='Customer Name', tracking=True) + customer_email = fields.Char(string='Email', tracking=True) + customer_phone = fields.Char(string='Mobile Number', tracking=True) + company_name = fields.Char(string='Company Name') + customer_address = fields.Text(string='Address') + + # Event Details + event_date = fields.Date(string='Event Date', compute='_compute_event_date', store=True) + start_date = fields.Datetime(string='Start Date & Time', required=True, tracking=True) + end_date = fields.Datetime(string='End Date & Time', required=True, tracking=True) + location = fields.Text(string='Event Location', required=True) + event_type = fields.Selection([ + ('wedding', 'Wedding'), + ('birthday', 'Birthday'), + ('corporate', 'Corporate Event'), + ('stage', 'Stage Setup'), + ('festival', 'Festival'), + ('exhibition', 'Exhibition'), + ('other', 'Other') + ], string='Event Type', default='other', required=True) + + # Financial details + delivery_charge = fields.Float(string='Delivery Charge', default=0.0, tracking=True) + setup_charge = fields.Float(string='Setup Charge', default=0.0, tracking=True) + amount_total = fields.Float(string='Total Amount', compute='_compute_amount_total', store=True) + + status = fields.Selection([ + ('draft', 'Draft'), + ('under_review', 'Under Review'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ('quotation_sent', 'Quotation Sent'), + ('confirmed', 'Confirmed'), + ('delivered', 'Delivered'), + ('returned', 'Returned'), + ('completed', 'Completed') + ], string='Status', default='draft', tracking=True, required=True) + + line_ids = fields.One2many('event.rental.line', 'request_id', string='Rental Lines') + document_ids = fields.One2many('event.document', 'request_id', string='Uploaded Documents') + + # Sales Integration + sale_order_id = fields.Many2one('sale.order', string='Sales Quotation', readonly=True, copy=False) + + # Delivery tracking + delivery_date = fields.Datetime(string='Delivery Scheduled Date') + delivery_staff_id = fields.Many2one('res.users', string='Delivery Staff') + delivery_status = fields.Selection([ + ('pending', 'Pending'), + ('delivered', 'Delivered'), + ('picked_up', 'Picked Up'), + ('returned', 'Returned') + ], string='Delivery Status', default='pending', tracking=True) + + is_all_available = fields.Boolean(string='All Items Available', compute='_compute_is_all_available') + + @api.depends('start_date') + def _compute_event_date(self): + for rec in self: + if rec.start_date: + rec.event_date = rec.start_date.date() + else: + rec.event_date = False + + @api.depends('line_ids.price_subtotal', 'delivery_charge', 'setup_charge') + def _compute_amount_total(self): + for rec in self: + lines_sum = sum(rec.line_ids.mapped('price_subtotal')) + rec.amount_total = lines_sum + rec.delivery_charge + rec.setup_charge + + @api.depends('line_ids.is_available') + def _compute_is_all_available(self): + for rec in self: + rec.is_all_available = all(line.is_available for line in rec.line_ids) if rec.line_ids else False + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('event.rental.request') or _('New') + # If partner is set, automatically pre-fill customer details if empty + if vals.get('partner_id'): + partner = self.env['res.partner'].browse(vals['partner_id']) + if not vals.get('customer_name'): + vals['customer_name'] = partner.name + if not vals.get('customer_email'): + vals['customer_email'] = partner.email + if not vals.get('customer_phone'): + vals['customer_phone'] = partner.phone or partner.mobile + if not vals.get('customer_address'): + vals['customer_address'] = partner.contact_address + return super(EventRentalRequest, self).create(vals_list) + + def check_availability(self, start_date, end_date, product_id, exclude_request_id=None): + """ + Check the available inventory of a product for the selected date range. + Standard qty_available acts as total pool. + """ + if not product_id or not start_date or not end_date: + return 0.0 + + # Non-storable products are assumed to have infinite quantity + if product_id.type != 'product': + return 999999.0 + + total_capacity = product_id.qty_available + + # Overlapping criteria: + # (Start1 < End2) AND (End1 > Start2) + # Statuses blocking inventory: approved, quotation_sent, confirmed, delivered, returned + domain = [ + ('product_id', '=', product_id.id), + ('request_id.status', 'in', ['approved', 'quotation_sent', 'confirmed', 'delivered', 'returned']), + ('request_id.start_date', '<', end_date), + ('request_id.end_date', '>', start_date), + ] + if exclude_request_id: + domain.append(('request_id', '!=', exclude_request_id)) + + overlapping_lines = self.env['event.rental.line'].search(domain) + reserved_qty = sum(overlapping_lines.mapped('quantity')) + + return max(0.0, total_capacity - reserved_qty) + + def _get_or_create_service_product(self, name, default_code): + product = self.env['product.product'].search([('default_code', '=', default_code)], limit=1) + if not product: + product = self.env['product.product'].create({ + 'name': name, + 'type': 'service', + 'default_code': default_code, + 'sale_ok': True, + 'purchase_ok': False, + }) + return product + + def action_approve(self): + """ + Approve the request. Check availability, find/create customer partner, + create sales quotation, set status to quotation_sent, and send notification. + """ + self.ensure_one() + if not self.line_ids: + raise UserError(_("Please add at least one rental product line.")) + + # Re-check availability before approving + for line in self.line_ids: + available_qty = self.check_availability(self.start_date, self.end_date, line.product_id, exclude_request_id=self.id) + if available_qty < line.quantity: + raise UserError(_("Product '%s' is not available in the required quantity (%s) for the selected dates. Only %s units are available.") % ( + line.product_id.display_name, line.quantity, available_qty + )) + + # Check/create partner + partner = self.partner_id + if not partner: + partner = self.env['res.partner'].search([('email', '=', self.customer_email)], limit=1) + if not partner: + partner = self.env['res.partner'].create({ + 'name': self.customer_name or self.customer_email or _('Guest Customer'), + 'email': self.customer_email, + 'phone': self.customer_phone, + 'company_name': self.company_name, + 'street': self.customer_address, + }) + self.partner_id = partner + + # Create Odoo Sales Order + so_vals = { + 'partner_id': partner.id, + 'origin': self.name, + 'event_rental_request_id': self.id, + } + sale_order = self.env['sale.order'].create(so_vals) + self.sale_order_id = sale_order + + # Rental product lines + for line in self.line_ids: + self.env['sale.order.line'].create({ + 'order_id': sale_order.id, + 'product_id': line.product_id.id, + 'product_uom_qty': line.quantity, + 'price_unit': line.price_unit, + 'name': _("Rental: %s (Period: %s to %s)") % (line.product_id.display_name, self.start_date, self.end_date), + }) + + # Add Delivery charges + if self.delivery_charge > 0: + delivery_product = self._get_or_create_service_product("Delivery Charges", "RENTAL_DELIVERY") + self.env['sale.order.line'].create({ + 'order_id': sale_order.id, + 'product_id': delivery_product.id, + 'product_uom_qty': 1, + 'price_unit': self.delivery_charge, + 'name': _("Delivery Charges for Rental %s") % self.name, + }) + + # Add Setup charges + if self.setup_charge > 0: + setup_product = self._get_or_create_service_product("Setup Charges", "RENTAL_SETUP") + self.env['sale.order.line'].create({ + 'order_id': sale_order.id, + 'product_id': setup_product.id, + 'product_uom_qty': 1, + 'price_unit': self.setup_charge, + 'name': _("Setup Charges for Rental %s") % self.name, + }) + + # Set status to approved and then quotation_sent + self.write({ + 'status': 'quotation_sent', + 'delivery_status': 'pending' + }) + + # Send Email Notification + template = self.env.ref('event_rental.email_template_rental_approved', raise_if_not_found=False) + if template: + template.send_mail(self.id, force_send=True) + self.message_post(body=_("Quotation created and email notification sent to customer.")) + else: + self.message_post(body=_("Rental Request Approved. Quotation %s generated.") % sale_order.name) + + # Log WhatsApp Notification & Generate Link + self._log_whatsapp_notification() + + def action_reject(self): + self.write({'status': 'rejected'}) + self.message_post(body=_("Rental Request has been Rejected.")) + + def action_deliver(self): + self.write({ + 'status': 'delivered', + 'delivery_status': 'delivered', + 'delivery_date': fields.Datetime.now() + }) + self.message_post(body=_("Products have been delivered to the event location.")) + + def action_pickup(self): + self.write({ + 'status': 'returned', + 'delivery_status': 'picked_up' + }) + self.message_post(body=_("Products have been picked up from the location.")) + + def action_return(self): + self.write({ + 'status': 'returned', + 'delivery_status': 'returned' + }) + self.message_post(body=_("Products returned and checked back in inventory.")) + + def action_complete(self): + self.write({'status': 'completed'}) + self.message_post(body=_("Rental Request completed.")) + + def action_reset_draft(self): + self.write({'status': 'draft'}) + self.message_post(body=_("Request reset to Draft.")) + + def _log_whatsapp_notification(self): + """ + Log WhatsApp message details and prepare a quick-action link for the administrator + """ + if not self.customer_phone: + return + + message = _("Hello %s, your rental request %s has been approved. Please review the quotation: %s") % ( + self.customer_name, + self.name, + self.sale_order_id.get_portal_url() if self.sale_order_id else '' + ) + + encoded_message = urllib.parse.quote(message) + wa_url = f"https://web.whatsapp.com/send?phone={self.customer_phone}&text={encoded_message}" + + chatter_body = _( + "WhatsApp Notification Queued:
" + "Recipient Phone: %s
" + "Message: \"%s\"
" + "Send via WhatsApp Web" + ) % (self.customer_phone, message, wa_url) + + self.message_post(body=chatter_body) + + +class EventRentalLine(models.Model): + _name = 'event.rental.line' + _description = 'Event Rental Line' + + request_id = fields.Many2one('event.rental.request', string='Rental Request', ondelete='cascade', required=True) + product_id = fields.Many2one('product.product', string='Product', domain=[('is_rental', '=', True)], required=True) + quantity = fields.Float(string='Quantity Required', default=1.0, required=True) + price_unit = fields.Float(string='Rental Price Unit', compute='_compute_price_unit', readonly=False, store=True) + price_subtotal = fields.Float(string='Subtotal', compute='_compute_price_subtotal', store=True) + is_available = fields.Boolean(string='Available', compute='_compute_is_available') + + @api.depends('product_id', 'request_id.start_date', 'request_id.end_date') + def _compute_price_unit(self): + for line in self: + if line.product_id: + duration = 1.0 + if line.request_id.start_date and line.request_id.end_date: + delta = line.request_id.end_date - line.request_id.start_date + days = delta.days + if delta.seconds > 0 or delta.days == 0: + days += 1 + duration = max(1.0, float(days)) + line.price_unit = line.product_id.rental_price_per_day * duration + else: + line.price_unit = 0.0 + + @api.depends('quantity', 'price_unit') + def _compute_price_subtotal(self): + for line in self: + line.price_subtotal = line.quantity * line.price_unit + + @api.depends('product_id', 'quantity', 'request_id.start_date', 'request_id.end_date') + def _compute_is_available(self): + for line in self: + if not line.product_id or not line.request_id.start_date or not line.request_id.end_date: + line.is_available = True + continue + available_qty = line.request_id.check_availability( + line.request_id.start_date, + line.request_id.end_date, + line.product_id, + exclude_request_id=line.request_id.id + ) + line.is_available = available_qty >= line.quantity + + +class EventDocument(models.Model): + _name = 'event.document' + _description = 'Event Rental Document' + + request_id = fields.Many2one('event.rental.request', string='Rental Request', ondelete='cascade', required=True) + partner_id = fields.Many2one('res.partner', string='Customer') + doc_type = fields.Selection([ + ('aadhaar', 'Aadhaar Card'), + ('driving_license', 'Driving License'), + ('passport', 'Passport'), + ('voter_id', 'Voter ID'), + ('other', 'Other ID Proof') + ], string='ID Proof Type', required=True) + attachment_id = fields.Many2one('ir.attachment', string='Attachment File', required=True) + verification_status = fields.Selection([ + ('pending', 'Pending Verification'), + ('verified', 'Verified'), + ('rejected', 'Rejected') + ], string='Verification Status', default='pending', required=True) diff --git a/addons/event_rental/models/product_template.py b/addons/event_rental/models/product_template.py new file mode 100644 index 0000000..406a8a4 --- /dev/null +++ b/addons/event_rental/models/product_template.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + is_rental = fields.Boolean( + string='Can be Rented', + default=False, + help="Check this if the product is available for event rental." + ) + rental_price_per_day = fields.Float( + string='Rental Price Per Day', + default=0.0, + help="Price charged per day for renting this product." + ) + rental_terms = fields.Html( + string='Rental Terms', + help="Rental terms and conditions specific to this product." + ) diff --git a/addons/event_rental/models/sale_order.py b/addons/event_rental/models/sale_order.py new file mode 100644 index 0000000..e349b08 --- /dev/null +++ b/addons/event_rental/models/sale_order.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + event_rental_request_id = fields.Many2one( + 'event.rental.request', + string='Event Rental Request', + readonly=True, + copy=False + ) + + def action_confirm(self): + res = super(SaleOrder, self).action_confirm() + for order in self: + if order.event_rental_request_id: + order.event_rental_request_id.write({ + 'status': 'confirmed' + }) + order.event_rental_request_id.message_post( + body=f"Sales Order {order.name} has been confirmed. Rental booking status set to Confirmed." + ) + return res + + def action_cancel(self): + res = super(SaleOrder, self).action_cancel() + for order in self: + if order.event_rental_request_id: + order.event_rental_request_id.write({ + 'status': 'rejected' + }) + order.event_rental_request_id.message_post( + body=f"Sales Order {order.name} was cancelled. Rental booking status set to Rejected." + ) + return res diff --git a/addons/event_rental/security/ir.model.access.csv b/addons/event_rental/security/ir.model.access.csv new file mode 100644 index 0000000..7dc7128 --- /dev/null +++ b/addons/event_rental/security/ir.model.access.csv @@ -0,0 +1,10 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_event_rental_request_officer,event.rental.request.officer,model_event_rental_request,group_event_rental_officer,1,1,1,1 +access_event_rental_request_manager,event.rental.request.manager,model_event_rental_request,group_event_rental_manager,1,1,1,1 +access_event_rental_request_portal,event.rental.request.portal,model_event_rental_request,base.group_portal,1,1,0,0 +access_event_rental_line_officer,event.rental.line.officer,model_event_rental_line,group_event_rental_officer,1,1,1,1 +access_event_rental_line_manager,event.rental.line.manager,model_event_rental_line,group_event_rental_manager,1,1,1,1 +access_event_rental_line_portal,event.rental.line.portal,model_event_rental_line,base.group_portal,1,1,0,0 +access_event_document_officer,event.document.officer,model_event_document,group_event_rental_officer,1,1,1,1 +access_event_document_manager,event.document.manager,model_event_document,group_event_rental_manager,1,1,1,1 +access_event_document_portal,event.document.portal,model_event_document,base.group_portal,1,1,1,0 diff --git a/addons/event_rental/security/security.xml b/addons/event_rental/security/security.xml new file mode 100644 index 0000000..7c12e13 --- /dev/null +++ b/addons/event_rental/security/security.xml @@ -0,0 +1,58 @@ + + + + + + Event Rental Management + Category for event rental management permissions. + 20 + + + + + Rental Officer + + + + + + + Rental Manager + + + + + + + + Portal Rental Requests + + [('partner_id', '=', user.partner_id.id)] + + + + + + + + + + Portal Rental Documents + + [('partner_id', '=', user.partner_id.id)] + + + + + + + + + + Officer Rental Requests + + [(1, '=', 1)] + + + + diff --git a/addons/event_rental/tests/__init__.py b/addons/event_rental/tests/__init__.py new file mode 100644 index 0000000..45c1165 --- /dev/null +++ b/addons/event_rental/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_rental diff --git a/addons/event_rental/tests/__pycache__/__init__.cpython-310.pyc b/addons/event_rental/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..2da8a72 Binary files /dev/null and b/addons/event_rental/tests/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/event_rental/tests/__pycache__/test_rental.cpython-310.pyc b/addons/event_rental/tests/__pycache__/test_rental.cpython-310.pyc new file mode 100644 index 0000000..69c4d2d Binary files /dev/null and b/addons/event_rental/tests/__pycache__/test_rental.cpython-310.pyc differ diff --git a/addons/event_rental/tests/test_rental.py b/addons/event_rental/tests/test_rental.py new file mode 100644 index 0000000..f146ae4 --- /dev/null +++ b/addons/event_rental/tests/test_rental.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase +from odoo.fields import Datetime +from datetime import datetime, timedelta +from odoo.exceptions import UserError + +class TestEventRental(TransactionCase): + + @classmethod + def setUpClass(cls): + super(TestEventRental, cls).setUpClass() + + # Create a rental product template first, then get variant + cls.rental_product = cls.env['product.product'].create({ + 'name': 'Wedding Chair', + 'type': 'product', + 'is_rental': True, + 'rental_price_per_day': 10.0, + }) + + # Set quantity on hand using Odoo stock.quant + warehouse = cls.env['stock.warehouse'].search([], limit=1) + if warehouse: + location = warehouse.lot_stock_id + cls.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': cls.rental_product.id, + 'location_id': location.id, + 'inventory_quantity': 50.0, + }).action_apply_inventory() + + # Create a customer partner + cls.partner = cls.env['res.partner'].create({ + 'name': 'Test Client', + 'email': 'client@test.com', + 'phone': '1234567890', + }) + + def test_01_rental_flow(self): + """ Test the complete rental reservation, pricing, approval, payment, and delivery flow """ + # Define dates: 3 days rental duration + start_date = datetime.now() + timedelta(days=1) + end_date = start_date + timedelta(days=3) + + # Create request + request = self.env['event.rental.request'].create({ + 'partner_id': self.partner.id, + 'start_date': start_date, + 'end_date': end_date, + 'location': 'Grand Palace Hall', + 'event_type': 'wedding', + 'delivery_charge': 50.0, + 'setup_charge': 30.0, + }) + + # Create line: booking 10 chairs + line = self.env['event.rental.line'].create({ + 'request_id': request.id, + 'product_id': self.rental_product.id, + 'quantity': 10.0, + }) + + # Verify duration-based pricing: 3 days * $10 per day = $30 per chair unit + self.assertEqual(line.price_unit, 30.0, "Line unit price should be rental_price_per_day * duration") + self.assertEqual(line.price_subtotal, 300.0, "Subtotal should be quantity * price_unit") + + # Verify grand total: $300 (subtotal) + $50 (delivery) + $30 (setup) = $380 + self.assertEqual(request.amount_total, 380.0, "Grand total is incorrect") + + # Check availability + avail = request.check_availability(start_date, end_date, self.rental_product) + self.assertEqual(avail, 50.0, "Before approval, all 50 chairs should be available") + + # Approve request + request.action_approve() + + # Status should become quotation_sent + self.assertEqual(request.status, 'quotation_sent') + self.assertTrue(request.sale_order_id, "Sales Order should be generated") + self.assertEqual(request.sale_order_id.amount_untaxed, 380.0, "Sales order untaxed total should match rental request total") + + # Check availability again: since approved request blocks inventory, available should drop by 10 + avail_after_approval = request.check_availability(start_date, end_date, self.rental_product) + self.assertEqual(avail_after_approval, 40.0, "Approved booking should block inventory") + + # Confirm Sale Order (simulating payment) + request.sale_order_id.action_confirm() + + # Request status should automatically become confirmed + self.assertEqual(request.status, 'confirmed') + + # Deliver products + request.action_deliver() + self.assertEqual(request.status, 'delivered') + self.assertEqual(request.delivery_status, 'delivered') + + # Return products + request.action_pickup() + self.assertEqual(request.status, 'returned') + self.assertEqual(request.delivery_status, 'picked_up') + + def test_02_overbooking_prevention(self): + """ Test that overbooking raises a UserError during approval """ + start_date = datetime.now() + timedelta(days=1) + end_date = start_date + timedelta(days=3) + + # Request 60 chairs (only 50 are on hand) + request = self.env['event.rental.request'].create({ + 'partner_id': self.partner.id, + 'start_date': start_date, + 'end_date': end_date, + 'location': 'Venue Hall', + }) + self.env['event.rental.line'].create({ + 'request_id': request.id, + 'product_id': self.rental_product.id, + 'quantity': 60.0, + }) + + # Approving should raise UserError + with self.assertRaises(UserError): + request.action_approve() diff --git a/addons/event_rental/views/event_rental_request_views.xml b/addons/event_rental/views/event_rental_request_views.xml new file mode 100644 index 0000000..056e31c --- /dev/null +++ b/addons/event_rental/views/event_rental_request_views.xml @@ -0,0 +1,201 @@ + + + + + event.rental.line.form + event.rental.line + +
+ + + + + + + + +
+
+
+ + + + event.rental.request.tree + event.rental.request + + + + + + + + + + + + + + + + + event.rental.request.calendar + event.rental.request + + + + + + + + + + + + event.rental.request.form + event.rental.request + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + + event.rental.request.search + event.rental.request + + + + + + + + + + + + + + + + + + + + + + + + + + + Rental Requests + ir.actions.act_window + event.rental.request + tree,calendar,form + + +

+ Create your first Event Rental Request! +

+
+
+ + + + Rental Products + product.template + kanban,tree,form + [('is_rental', '=', True)] + {'default_is_rental': True} + + + + + + +
diff --git a/addons/event_rental/views/portal_templates.xml b/addons/event_rental/views/portal_templates.xml new file mode 100644 index 0000000..4261a80 --- /dev/null +++ b/addons/event_rental/views/portal_templates.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + diff --git a/addons/event_rental/views/product_template_views.xml b/addons/event_rental/views/product_template_views.xml new file mode 100644 index 0000000..25fed82 --- /dev/null +++ b/addons/event_rental/views/product_template_views.xml @@ -0,0 +1,41 @@ + + + + + product.template.form.inherit.rental + product.template + + + + + + + + + + + + + + + + + + + + + + + + + product.template.search.inherit.rental + product.template + + + + + + + + diff --git a/addons/event_rental/views/website_templates.xml b/addons/event_rental/views/website_templates.xml new file mode 100644 index 0000000..9dc56a3 --- /dev/null +++ b/addons/event_rental/views/website_templates.xml @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + diff --git a/addons/theme_aakriti_events/__init__.py b/addons/theme_aakriti_events/__init__.py new file mode 100644 index 0000000..d764367 --- /dev/null +++ b/addons/theme_aakriti_events/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Theme views only, no Python imports needed. diff --git a/addons/theme_aakriti_events/__manifest__.py b/addons/theme_aakriti_events/__manifest__.py new file mode 100644 index 0000000..b6bb94b --- /dev/null +++ b/addons/theme_aakriti_events/__manifest__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Aakriti Events Website Theme', + 'version': '17.0.1.0.0', + 'category': 'Theme/Creative', + 'summary': 'Premium visual theme for Aakriti Design Events website', + 'description': """ + Aakriti Events Website Theme. + Redesigns the public rental catalog, product details, booking wizard, + and customer portal to match events.aakritidesign.com design. + """, + 'author': 'Antigravity', + 'depends': ['website', 'event_rental'], + 'data': [ + 'views/website_templates.xml', + 'views/portal_templates.xml', + ], + 'installable': True, + 'application': False, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/addons/theme_aakriti_events/__pycache__/__init__.cpython-310.pyc b/addons/theme_aakriti_events/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..66818dc Binary files /dev/null and b/addons/theme_aakriti_events/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/theme_aakriti_events/views/portal_templates.xml b/addons/theme_aakriti_events/views/portal_templates.xml new file mode 100644 index 0000000..a0dc896 --- /dev/null +++ b/addons/theme_aakriti_events/views/portal_templates.xml @@ -0,0 +1,188 @@ + + + + + + + + diff --git a/addons/theme_aakriti_events/views/website_templates.xml b/addons/theme_aakriti_events/views/website_templates.xml new file mode 100644 index 0000000..021bf05 --- /dev/null +++ b/addons/theme_aakriti_events/views/website_templates.xml @@ -0,0 +1,691 @@ + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..24d695e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + db: + image: postgres:15 + container_name: odoo_client4_db + environment: + POSTGRES_DB: postgres + POSTGRES_USER: odoo + POSTGRES_PASSWORD: odoo + volumes: + - client4_pgdata:/var/lib/postgresql/data + restart: always + + odoo: + image: odoo:17.0 + container_name: odoo_client4 + depends_on: + - db + ports: + - "10004:8069" + environment: + HOST: db + USER: odoo + PASSWORD: odoo + LIST_DB: "True" + volumes: + - client4_odoo_data:/var/lib/odoo + - ./addons:/mnt/extra-addons + restart: always + +volumes: + client4_pgdata: + client4_odoo_data: + + +# backups: +# .\backup_db.ps1 + +# Team Members – Restore + # cat d:\Odoo\backups\YOUR_BACKUP_FILE.sql | docker exec -i odoo_client4_db psql -U odoo -d postgres