diff --git a/addons_extensions/cwf_timesheet/__init__.py b/addons_extensions/cwf_timesheet/__init__.py new file mode 100644 index 000000000..43559cd9a --- /dev/null +++ b/addons_extensions/cwf_timesheet/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/addons_extensions/cwf_timesheet/__manifest__.py b/addons_extensions/cwf_timesheet/__manifest__.py new file mode 100644 index 000000000..64a474cf9 --- /dev/null +++ b/addons_extensions/cwf_timesheet/__manifest__.py @@ -0,0 +1,22 @@ +{ + 'name': 'CWF Timesheet Update', + 'version': '1.0', + 'category': 'Human Resources', + 'summary': 'Manage and update weekly timesheets for CWF department', + 'author': 'Your Name or Company', + 'depends': ['hr_attendance_extended','web', 'mail', 'base','hr_emp_dashboard','hr_employee_extended'], + 'data': [ + # 'views/timesheet_form.xml', + 'security/security.xml', + 'security/ir.model.access.csv', + 'views/timesheet_view.xml', + 'views/timesheet_weekly_view.xml', + 'data/email_template.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'cwf_timesheet/static/src/js/timesheet_form.js', + ], + }, + 'application': True, +} diff --git a/addons_extensions/cwf_timesheet/controllers/controller.py b/addons_extensions/cwf_timesheet/controllers/controller.py new file mode 100644 index 000000000..65377f44e --- /dev/null +++ b/addons_extensions/cwf_timesheet/controllers/controller.py @@ -0,0 +1,9 @@ +from odoo import http +from odoo.http import request + +class TimesheetController(http.Controller): + + @http.route('/timesheet/form', auth='user', website=True) + def timesheet_form(self, **kw): + # This will render the template for the timesheet form + return request.render('timesheet_form', {}) diff --git a/addons_extensions/cwf_timesheet/data/email_template.xml b/addons_extensions/cwf_timesheet/data/email_template.xml new file mode 100644 index 000000000..807927766 --- /dev/null +++ b/addons_extensions/cwf_timesheet/data/email_template.xml @@ -0,0 +1,45 @@ + + + + Timesheet Update Reminder + + {{ user.email_formatted }} + {{ object.employee_id.user_id.email }} + Reminder: Update Your Weekly Timesheet + + Reminder to employee to update their weekly timesheet. + + +

+ Dear Employee, +
+
+ I hope this message finds you in good spirits. I would like to remind you to please update your weekly timesheet for the period from + + + + to + + + . + Timely updates are crucial for maintaining accurate records and ensuring smooth processing. +
+
+ To make things easier, you can use the link below to update your timesheet: +
+ Update Timesheet +
+
+ Thank you for your attention. +
+ Best regards, +
+ Fast Track Project Pvt Ltd. +
+
+ Visit our site for more information. +

+
+
+
+
diff --git a/addons_extensions/cwf_timesheet/models/__init__.py b/addons_extensions/cwf_timesheet/models/__init__.py new file mode 100644 index 000000000..63ae5f202 --- /dev/null +++ b/addons_extensions/cwf_timesheet/models/__init__.py @@ -0,0 +1 @@ +from . import timesheet \ No newline at end of file diff --git a/addons_extensions/cwf_timesheet/models/timesheet.py b/addons_extensions/cwf_timesheet/models/timesheet.py new file mode 100644 index 000000000..c4649fc1b --- /dev/null +++ b/addons_extensions/cwf_timesheet/models/timesheet.py @@ -0,0 +1,399 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError, UserError +from datetime import datetime, timedelta +import datetime as dt +from odoo import _ +from calendar import month_name, month +from datetime import date + +class CwfTimesheetYearly(models.Model): + _name = 'cwf.timesheet.calendar' + _description = "CWF Timesheet Calendar" + _rec_name = 'name' + + name = fields.Char(string='Year Name', required=True) + week_period = fields.One2many('cwf.timesheet','cwf_calendar_id') + + _sql_constraints = [ + ('unique_year', 'unique(name)', 'The year must be unique!') + ] + + + @api.constrains('name') + def _check_year_format(self): + for record in self: + if not record.name.isdigit() or len(record.name) != 4: + raise ValidationError("Year Name must be a 4-digit number.") + + def generate_week_period(self): + for record in self: + record.week_period.unlink() + year = int(record.name) + + # Find the first Monday of the year + start_date = datetime(year, 1, 1) + while start_date.weekday() != 0: # Monday is 0 in weekday() + start_date += timedelta(days=1) + + # Generate weeks from Monday to Sunday + while start_date.year == year or (start_date - timedelta(days=1)).year == year: + end_date = start_date + timedelta(days=6) + + self.env['cwf.timesheet'].create({ + 'name': f'Week {start_date.strftime("%W")}, {year}', + 'week_start_date': start_date.date(), + 'week_end_date': end_date.date(), + 'cwf_calendar_id': record.id, + }) + + start_date += timedelta(days=7) + + def action_generate_weeks(self): + self.generate_week_period() + return { + 'type': 'ir.actions.client', + 'tag': 'reload', + } + +class CwfTimesheet(models.Model): + _name = 'cwf.timesheet' + _description = 'CWF Weekly Timesheet' + _rec_name = 'name' + + name = fields.Char(string='Week Name', required=True) + week_start_date = fields.Date(string='Week Start Date', required=True) + week_end_date = fields.Date(string='Week End Date', required=True) + status = fields.Selection([ + ('draft', 'Draft'), + ('submitted', 'Submitted') + ], default='draft', string='Status') + lines = fields.One2many('cwf.timesheet.line','week_id') + cwf_calendar_id = fields.Many2one('cwf.timesheet.calendar') + start_month = fields.Selection( + [(str(i), month_name[i]) for i in range(1, 13)], + string='Start Month', + compute='_compute_months', + store=True + ) + + end_month = fields.Selection( + [(str(i), month_name[i]) for i in range(1, 13)], + string='End Month', + compute='_compute_months', + store=True + ) + + @api.depends('week_start_date', 'week_end_date') + def _compute_months(self): + for rec in self: + if rec.week_start_date: + rec.start_month = str(rec.week_start_date.month) + else: + rec.start_month = False + + if rec.week_end_date: + rec.end_month = str(rec.week_end_date.month) + else: + rec.end_month = False + + @api.depends('name','week_start_date','week_end_date') + def _compute_display_name(self): + for rec in self: + rec.display_name = rec.name if not rec.week_start_date and rec.week_end_date else "%s (%s - %s)"%(rec.name,rec.week_start_date.strftime('%-d %b'), rec.week_end_date.strftime('%-d %b') ) + + + def send_timesheet_update_email(self): + template = self.env.ref('cwf_timesheet.email_template_timesheet_weekly_update') + # Ensure that we have a valid employee email + current_date = fields.Date.from_string(self.week_start_date) + end_date = fields.Date.from_string(self.week_end_date) + + if current_date > end_date: + raise UserError('The start date cannot be after the end date.') + + # Get all employees in the department + external_group_id = self.env.ref("hr_employee_extended.group_external_user") + users = self.env["res.users"].search([("groups_id", "=", external_group_id.id)]) + employees = self.env['hr.employee'].search([('user_id', 'in', users.ids),'|',('doj','=',False),('doj','>=', self.week_start_date)]) + print(employees) + # Loop through each day of the week and create timesheet lines for each employee + while current_date <= end_date: + for employee in employees: + existing_record = self.env['cwf.weekly.timesheet'].sudo().search([ + ('week_id', '=', self.id), + ('employee_id', '=', employee.id) + ], limit=1) + if not existing_record: + self.env['cwf.timesheet.line'].sudo().create({ + 'week_id': self.id, + 'employee_id': employee.id, + 'week_day':current_date, + }) + current_date += timedelta(days=1) + self.status = 'submitted' + for employee in employees: + weekly_timesheet_exists = self.env['cwf.weekly.timesheet'].sudo().search([('week_id','=',self.id),('employee_id','=',employee.id)]) + + if not weekly_timesheet_exists: + weekly_timesheet = self.env['cwf.weekly.timesheet'].sudo().create({ + 'week_id': self.id, + 'employee_id': employee.id, + 'status': 'draft' + }) + else: + weekly_timesheet = weekly_timesheet_exists + + # Generate the URL for the newly created weekly_timesheet + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + record_url = f"{base_url}/web#id={weekly_timesheet.id}&view_type=form&model=cwf.weekly.timesheet" + + weekly_timesheet.update_attendance() + if employee.work_email and weekly_timesheet: + email_values = { + 'email_to': employee.work_email, # Email body from template + 'subject': 'Timesheet Update Notification', + } + body_html = template.body_html.replace( + 'https://ftprotech.in/odoo/action-261', + record_url + ), + render_ctx = {'employee_name':weekly_timesheet.employee_id.name,'week_from':weekly_timesheet.week_id.week_start_date,'week_to':weekly_timesheet.week_id.week_end_date} + + + template.with_context(default_body_html=body_html,**render_ctx).send_mail(weekly_timesheet.id, email_values=email_values, force_send=True) + + +class CwfWeeklyTimesheet(models.Model): + _name = "cwf.weekly.timesheet" + _description = "CWF Weekly Timesheet" + _rec_name = 'employee_id' + + + def _default_week_id(self): + current_date = fields.Date.today() + timesheet = self.env['cwf.timesheet'].sudo().search([ + ('week_start_date', '<=', current_date), + ('week_end_date', '>=', current_date) + ], limit=1) + return timesheet.id if timesheet else False + + def _get_week_id_domain(self): + for rec in self: + return [('week_start_date.month_number','=',2)] + pass + + month_id = fields.Selection( + [(str(i), month_name[i]) for i in range(1, 13)], + string='Month' + ) + week_id = fields.Many2one('cwf.timesheet', 'Week', default=lambda self: self._default_week_id()) + + employee_id = fields.Many2one('hr.employee', default=lambda self: self.env.user.employee_id.id) + cwf_timesheet_lines = fields.One2many('cwf.timesheet.line' ,'weekly_timesheet') + status = fields.Selection([('draft','Draft'),('submitted','Submitted')], default='draft') + week_start_date = fields.Date(related='week_id.week_start_date') + week_end_date = fields.Date(related='week_id.week_end_date') + + @api.onchange('month_id') + def _onchange_month(self): + if self.month_id: + year = self.week_start_date.year if self.week_start_date else fields.Date.today().year + start = date(year, int(self.month_id), 1) + if int(self.month_id) == 12: + end = date(year + 1, 1, 1) - timedelta(days=1) + else: + end = date(year, int(self.month_id) + 1, 1) - timedelta(days=1) + self = self.with_context(month_start=start, month_end=end) + + @api.onchange('week_id') + def _onchange_week_id(self): + if self.week_id and self.week_id.week_start_date: + self.month_id = str(self.week_id.week_start_date.month) + + @api.constrains('week_id', 'employee_id') + def _check_unique_week_employee(self): + for record in self: + if record.week_id.week_start_date > fields.Date.today(): + raise ValidationError(_("You Can't select future week period")) + # Search for existing records with the same week_id and employee_id + existing_record = self.env['cwf.weekly.timesheet'].search([ + ('week_id', '=', record.week_id.id), + ('employee_id', '=', record.employee_id.id) + ], limit=1) + + # If an existing record is found and it's not the current record (in case of update), raise an error + if existing_record and existing_record.id != record.id: + raise ValidationError("A timesheet for this employee already exists for the selected week.") + + def update_attendance(self): + for rec in self: + # Get the week start and end date + week_start_date = rec.week_id.week_start_date + week_end_date = rec.week_id.week_end_date + + # Convert start and end dates to datetime objects for proper filtering + week_start_datetime = datetime.combine(week_start_date, datetime.min.time()) + week_end_datetime = datetime.combine(week_end_date, datetime.max.time()) + + # Delete timesheet lines that are outside the week range + rec.cwf_timesheet_lines.filtered(lambda line: + line.week_day < week_start_date or line.week_day > week_end_date + ).unlink() + + # Search for attendance records that fall within the week period and match the employee + hr_attendance_records = self.env['hr.attendance'].sudo().search([ + ('check_in', '>=', week_start_datetime), + ('check_out', '<=', week_end_datetime), + ('employee_id', '=', rec.employee_id.id) + ]) + + # Group the attendance records by date + attendance_by_date = {} + for attendance in hr_attendance_records: + attendance_date = attendance.check_in.date() + if attendance_date not in attendance_by_date: + attendance_by_date[attendance_date] = [] + attendance_by_date[attendance_date].append(attendance) + + # Get all the dates within the week period + all_week_dates = [week_start_date + timedelta(days=i) for i in + range((week_end_date - week_start_date).days + 1)] + + # Create or update timesheet lines for each day in the week + for date in all_week_dates: + # Check if there is attendance for this date + if date in attendance_by_date: + # If there are multiple attendance records, take the earliest check_in and latest check_out + earliest_check_in = min(attendance.check_in for attendance in attendance_by_date[date]) + latest_check_out = max(attendance.check_out for attendance in attendance_by_date[date]) + + if (earliest_check_in + timedelta(hours=5, minutes=30)).date() > date: + earliest_check_in = (datetime.combine(date, datetime.max.time()) - timedelta(hours=5, minutes=30)) + + if (latest_check_out + timedelta(hours=5, minutes=30)).date() > date: + latest_check_out = (datetime.combine(date, datetime.max.time()) - timedelta(hours=5, minutes=30)) + + # Check if a timesheet line for this employee, week, and date already exists + existing_timesheet_line = self.env['cwf.timesheet.line'].sudo().search([ + ('week_day', '=', date), + ('employee_id', '=', rec.employee_id.id), + ('week_id', '=', rec.week_id.id), + ('weekly_timesheet', '=', rec.id) + ], limit=1) + + if existing_timesheet_line: + # If it exists, update the existing record + existing_timesheet_line.write({ + 'check_in_date': earliest_check_in, + 'check_out_date': latest_check_out, + 'state_type': 'present', + }) + else: + # If it doesn't exist, create a new timesheet line with present state_type + self.env['cwf.timesheet.line'].create({ + 'weekly_timesheet': rec.id, + 'employee_id': rec.employee_id.id, + 'week_id': rec.week_id.id, + 'week_day': date, + 'check_in_date': earliest_check_in, + 'check_out_date': latest_check_out, + 'state_type': 'present', + }) + else: + # If no attendance exists for this date, create a new timesheet line with time_off state_type + existing_timesheet_line = self.env['cwf.timesheet.line'].sudo().search([ + ('week_day', '=', date), + ('employee_id', '=', rec.employee_id.id), + ('week_id', '=', rec.week_id.id), + ('weekly_timesheet', '=', rec.id) + ], limit=1) + + if not existing_timesheet_line: + if date.weekday() != 5 and date.weekday() != 6: + # If no record exists for this date, create a new timesheet line with time_off state_type + self.env['cwf.timesheet.line'].create({ + 'weekly_timesheet': rec.id, + 'employee_id': rec.employee_id.id, + 'week_id': rec.week_id.id, + 'week_day': date, + 'state_type': 'time_off', + }) + + def action_submit(self): + for rec in self: + for timesheet in rec.cwf_timesheet_lines: + timesheet.action_submit() + rec.status = 'submitted' + +class CwfTimesheetLine(models.Model): + _name = 'cwf.timesheet.line' + _description = 'CWF Weekly Timesheet Lines' + _rec_name = 'employee_id' + + weekly_timesheet = fields.Many2one('cwf.weekly.timesheet') + employee_id = fields.Many2one('hr.employee', string='Employee', related='weekly_timesheet.employee_id') + week_id = fields.Many2one('cwf.timesheet', 'Week', related='weekly_timesheet.week_id') + week_day = fields.Date(string='Date') + check_in_date = fields.Datetime(string='Checkin') + check_out_date = fields.Datetime(string='Checkout ') + is_updated = fields.Boolean('Attendance Updated') + state_type = fields.Selection([('draft','Draft'),('holiday', 'Holiday'),('time_off','Time Off'),('half_day','Half Day'),('present','Present')], default='draft', required=True) + + + + @api.constrains('week_day', 'check_in_date', 'check_out_date') + def _check_week_day_and_times(self): + for record in self: + # Ensure week_day is within the week range + if record.week_id: + if record.week_day < record.week_id.week_start_date or record.week_day > record.week_id.week_end_date: + raise ValidationError( + "The selected 'week_day' must be within the range of the week from %s to %s." % + (record.week_id.week_start_date, record.week_id.week_end_date) + ) + + # Ensure check_in_date and check_out_date are on the selected week_day + if record.check_in_date: + if record.check_in_date.date() != record.week_day: + raise ValidationError( + "The 'check_in_date' must be on the selected Date." + ) + + if record.check_out_date: + if record.check_out_date.date() != record.week_day: + raise ValidationError( + "The 'check_out_date' must be on the selected Date." + ) + + + def action_submit(self): + if self.state_type == 'draft' or not self.state_type: + raise ValidationError(_('State type should not Draft or Empty')) + if self.state_type not in ['holiday','time_off'] and not (self.check_in_date or self.check_out_date): + raise ValidationError(_('Please enter Check details')) + self._update_attendance() + + + def _update_attendance(self): + attendance_obj = self.env['hr.attendance'] + for record in self: + if record.check_in_date != False and record.check_out_date != False and record.employee_id: + first_check_in = attendance_obj.sudo().search([('check_in', '>=', record.check_in_date.date()), + ('check_out', '<=', record.check_out_date.date()),('employee_id','=',record.employee_id.id)], + limit=1, order="check_in") + last_check_out = attendance_obj.sudo().search([('check_in', '>=', record.check_in_date.date()), + ('check_out', '<=', record.check_out_date.date()),('employee_id','=',record.employee_id.id)], + limit=1, order="check_out desc") + + if first_check_in or last_check_out: + if first_check_in.sudo().check_in != record.check_in_date: + first_check_in.sudo().check_in = record.check_in_date + if last_check_out.sudo().check_out != record.check_out_date: + last_check_out.sudo().check_out = record.check_out_date + else: + attendance_obj.sudo().create({ + 'employee_id': record.employee_id.id, + 'check_in': record.check_in_date - timedelta(hours=5, minutes=30), + 'check_out': record.check_out_date - timedelta(hours=5, minutes=30), + }) + record.is_updated = True diff --git a/addons_extensions/cwf_timesheet/security/ir.model.access.csv b/addons_extensions/cwf_timesheet/security/ir.model.access.csv new file mode 100644 index 000000000..e72bcf01c --- /dev/null +++ b/addons_extensions/cwf_timesheet/security/ir.model.access.csv @@ -0,0 +1,15 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_cwf_timesheet_user,access.cwf.timesheet,model_cwf_timesheet,base.group_user,1,0,0,0 +access_cwf_timesheet_manager,access.cwf.timesheet,model_cwf_timesheet,hr_attendance.group_hr_attendance_manager,1,1,1,1 + + +access_cwf_timesheet_calendar,cwf_timesheet_calendar,model_cwf_timesheet_calendar,hr_attendance.group_hr_attendance_manager,1,1,1,1 +access_cwf_timesheet_calendar_user,cwf_timesheet_calendar_user,model_cwf_timesheet_calendar,base.group_user,1,0,0,0 + + +access_cwf_timesheet_line_manager,access.cwf.timesheet.line.manager,model_cwf_timesheet_line,hr_attendance.group_hr_attendance_manager,1,1,1,1 +access_cwf_timesheet_line_user,access.cwf.timesheet.line,model_cwf_timesheet_line,hr_employee_extended.group_external_user,1,1,1,1 + + +access_cwf_weekly_timesheet_manager,cwf.weekly.timesheet.manager access,model_cwf_weekly_timesheet,hr_attendance.group_hr_attendance_manager,1,1,1,1 +access_cwf_weekly_timesheet_user,cwf.weekly.timesheet access,model_cwf_weekly_timesheet,hr_employee_extended.group_external_user,1,1,1,0 diff --git a/addons_extensions/cwf_timesheet/security/security.xml b/addons_extensions/cwf_timesheet/security/security.xml new file mode 100644 index 000000000..3d18c8786 --- /dev/null +++ b/addons_extensions/cwf_timesheet/security/security.xml @@ -0,0 +1,36 @@ + + + + + CWF Weekly Timesheet User Rule + + [('employee_id.user_id.id','=',user.id)] + + + + + CWF Timesheet Line User Rule + + [('employee_id.user_id.id','=',user.id)] + + + + + CWF Weekly Timesheet manager Rule + + ['|',('employee_id.user_id.id','!=',user.id),('employee_id.user_id.id','=',user.id)] + + + + + CWF Timesheet Line manager Rule + + ['|',('employee_id.user_id.id','!=',user.id),('employee_id.user_id.id','=',user.id)] + + + + + + + + \ No newline at end of file diff --git a/addons_extensions/cwf_timesheet/static/src/js/timesheet_form.js b/addons_extensions/cwf_timesheet/static/src/js/timesheet_form.js new file mode 100644 index 000000000..ad62fe0fe --- /dev/null +++ b/addons_extensions/cwf_timesheet/static/src/js/timesheet_form.js @@ -0,0 +1,54 @@ +/** @odoo-module **/ + +import { patch } from "@web/core/utils/patch"; +import { NetflixProfileContainer } from "@hr_emp_dashboard/js/profile_component"; +import { user } from "@web/core/user"; + +// Apply patch to NetflixProfileContainer prototype +patch(NetflixProfileContainer.prototype, { + /** + * @override + */ + setup() { + // Call parent setup method + + super.setup(...arguments); + + // Log the department of the logged-in employee (check if data is available) +// if (this.state && this.state.login_employee) { +// console.log(this.state.login_employee['department_id']); +// } else { +// console.error('Employee or department data is unavailable.'); +// } + }, + + /** + * Override the hr_timesheets method + */ + async hr_timesheets() { + const isExternalUser = await user.hasGroup("hr_employee_extended.group_external_user"); + // Check the department of the logged-in employee + console.log(isExternalUser); + console.log("is external user"); + debugger; + if (isExternalUser && this.state.login_employee.department_id) { + console.log("hello external"); + // If the department is 'CWF', perform the action to open the timesheets + this.action.doAction({ + name: "Timesheets", + type: 'ir.actions.act_window', + res_model: 'cwf.timesheet.line', // Ensure this model exists + view_mode: 'list,form', + views: [[false, 'list'], [false, 'form']], + context: { + 'search_default_week_id': true, + }, + domain: [['employee_id.user_id','=', this.props.action.context.user_id]], + target: 'current', + }); + } else { + // If not 'CWF', call the base functionality + return super.hr_timesheets(); + } + }, +}); diff --git a/addons_extensions/cwf_timesheet/views/timesheet_form.xml b/addons_extensions/cwf_timesheet/views/timesheet_form.xml new file mode 100644 index 000000000..03a3b1d4f --- /dev/null +++ b/addons_extensions/cwf_timesheet/views/timesheet_form.xml @@ -0,0 +1,27 @@ + + + +
+

Weekly Timesheet

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
diff --git a/addons_extensions/cwf_timesheet/views/timesheet_view.xml b/addons_extensions/cwf_timesheet/views/timesheet_view.xml new file mode 100644 index 000000000..048fd5570 --- /dev/null +++ b/addons_extensions/cwf_timesheet/views/timesheet_view.xml @@ -0,0 +1,104 @@ + + + cwf.timesheet.calendar.form + cwf.timesheet.calendar + +
+ + + + + + + diff --git a/addons_extensions/documents/static/src/components/documents_permission_panel/documents_access_settings.js b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_access_settings.js new file mode 100644 index 000000000..9c56b4900 --- /dev/null +++ b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_access_settings.js @@ -0,0 +1,67 @@ +/** @odoo-module **/ + +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { _t } from "@web/core/l10n/translation"; +import { DocumentsPermissionSelect } from "./documents_permission_select"; +import { Component } from "@odoo/owl"; + +const accessProps = { + access_internal: String, + access_via_link: String, + is_access_via_link_hidden: Boolean, +}; + +export class DocumentsAccessSettings extends Component { + static components = { + CheckBox, + DocumentsPermissionSelect, + }; + static props = { + access: accessProps, + baseAccess: accessProps, + disabled: Boolean, + onChangeAccessInternal: Function, + onChangeAccessLink: Function, + onChangeIsAccessLinkHidden: Function, + selections: Object, + }; + static template = "documents.AccessSettings"; + + /** + * Return additional label corresponding to internal access setting + */ + get accessInternalHelper() { + if (this.props.access.access_internal !== "none") { + return _t("Internal users can %(action)s", { + action: this.props.access.access_internal, + }); + } + return _t("Only people with access can open with the link"); + } + + /** + * Return additional label corresponding to link access setting + */ + get accessLinkHelper() { + if (this.props.access.access_via_link !== "none") { + return _t("Anyone on the internet with the link can %(action)s", { + action: this.props.access.access_via_link, + }); + } + return _t("No one on the internet can access"); + } + + /** + * Return an error message to disable the edit mode. + */ + get errorAccessLinkEdit() { + return undefined; + } + + /** + * Return an error message to disable the edit mode. + */ + get errorAccessInternalEdit() { + return undefined; + } +} diff --git a/addons_extensions/documents/static/src/components/documents_permission_panel/documents_access_settings.xml b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_access_settings.xml new file mode 100644 index 000000000..2576168d7 --- /dev/null +++ b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_access_settings.xml @@ -0,0 +1,43 @@ + + + +

General access

+
+ +
+
+ +
+
+
+ +
+ + diff --git a/addons_extensions/documents/static/src/components/documents_permission_panel/documents_member_invite.js b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_member_invite.js new file mode 100644 index 000000000..a8d2506fc --- /dev/null +++ b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_member_invite.js @@ -0,0 +1,264 @@ +/** @odoo-module **/ + +import { + DocumentsPermissionSelect, + DocumentsPermissionSelectMenu, +} from "./documents_permission_select"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { rpcBus } from "@web/core/network/rpc"; +import { useBus, useService } from "@web/core/utils/hooks"; +import { isEmail } from "@web/core/utils/strings"; +import { isEmpty } from "@html_editor/utils/dom_info"; +import { Wysiwyg } from "@html_editor/wysiwyg"; +import { HtmlMailField } from "@mail/views/web/fields/html_mail_field/html_mail_field"; +import { _t } from "@web/core/l10n/translation"; +import { Component, onWillUpdateProps, useState } from "@odoo/owl"; + +const cssRulesByElement = new WeakMap(); + +export class DocumentsMemberInvite extends Component { + static components = { + DocumentsPermissionSelect, + DocumentsPermissionSelectMenu, + DropdownItem, + Wysiwyg, + }; + static props = { + access: Object, + accessPartners: Array, + autoSave: Function, + close: Function, + selectedPartnersRole: String, + invitePage: Boolean, + disabled: Boolean, + roles: Array, + pendingSave: Boolean, + showMainPage: Function, + updateAccessRights: Function, + }; + static template = "documents.MemberInvite"; + + setup() { + this.actionService = useService("action"); + this.createdPartners = { choices: [], values: [] }; + this.orm = useService("orm"); + this.state = useState({ + notify: true, + fetchedPartners: [], + selectedPartners: [], + selectedPartnersRole: this.props.selectedPartnersRole, + sharing: false, + }); + const wysiwygPlaceholder = _t("Optional message..."); + this.wysiwyg = { + config: { + content: `


`, + placeholder: wysiwygPlaceholder, + disableVideo: true, + }, + editor: undefined, + message: "", + }; + useBus(rpcBus, "RPC:RESPONSE", this.responseCall); + + onWillUpdateProps((nextProps) => { + if (!nextProps.invitePage) { + this.state.selectedPartners = []; + this.state.selectedPartnersRole = this.props.selectedPartnersRole; + } + }); + } + + /** + * @returns {string|undefined} + */ + get noEditorMessage() { + return undefined; + } + + /** + * @param {String} query + * @param {Array} domain + * @param {Number} limit + * @returns Array[Object] + */ + async getPartners(domain = [], limit = 4) { + const partners = await this.orm.call("res.partner", "web_search_read", [], { + domain: domain, + specification: this._getPartnersSpecification(), + limit: limit, + }); + return partners.records; + } + + _getPartnersSpecification() { + return { display_name: 1, email: 1 }; + } + + /** + * Call new contact form + * @param {String} defaultName + * @param {Boolean} andEdit + */ + async createPartners(defaultName, andEdit = false) { + const onClose = async () => { + if (this.createdPartners.choices) { + this.state.fetchedPartners = this.state.fetchedPartners.concat( + this.createdPartners.choices + ); + this.state.selectedPartners = this.state.selectedPartners.concat( + this.createdPartners.values + ); + this.props.showMainPage(false); + } + }; + if (andEdit) { + return this.actionService.doActionButton({ + name: "action_create_members_to_invite", + type: "object", + resModel: "res.partner", + buttonContext: { + default_name: defaultName, + dialog_size: "medium", + }, + onClose, + }); + } + const partnerId = await this.orm.call("res.partner", "create", [ + { + name: defaultName, + email: defaultName, + }, + ]); + if (partnerId) { + const createdPartners = await this.orm.webRead("res.partner", [partnerId], { + specification: { name: 1, email: 1 }, + }); + this._addCreatedPartners(createdPartners); + } + await onClose(); + } + + /** + * Provides a selection of partners based on user input + * while keeping selected partners visible + * @param {String} search + */ + async onSearchPartners(search) { + const selectedPartners = this.state.fetchedPartners.filter((p) => + this.state.selectedPartners.includes(p.id) + ); + const partners = await this.getPartners([ + [ + "id", + "!=", + [ + ...this.props.accessPartners.flatMap((a) => (a.role ? [a.partner_id.id] : [])), + ...selectedPartners.map((s) => s.id), + ], + ], + "|", + ["name", "ilike", search.trim()], + ["email", "ilike", search.trim()], + ]); + this.state.fetchedPartners = [...partners, ...selectedPartners]; + } + + /** + * @param {Event} event + */ + onChangeRoleForMemberToInvite(event) { + this.state.selectedPartnersRole = event.target.selectedOptions[0].value; + } + + /** + * @param {Number[]} values + */ + onSelectPartnerToInvite(values) { + this.state.selectedPartners = values; + if (this.props.pendingSave) { + this.props.autoSave(); + } + if (!this.props.invitePage) { + this.props.showMainPage(false); + } + } + + onClickNotify() { + this.state.notify = !this.state.notify; + } + + /** + * Add new partner access to a document/folder + */ + async onShare() { + const partners = {}; + this.state.selectedPartners.forEach( + (p) => (partners[p] = [this.state.selectedPartnersRole, false]) + ); + if (this.state.notify) { + await this.getFormattedWysiwygContent(); + } + this.state.sharing = true; + await this.props.updateAccessRights(partners, this.state.notify, this.wysiwyg.message); + await this.props.close(); + } + + /** + * Switch back to the main page + */ + onDiscard() { + this.state.notify = true; + this.props.showMainPage(true); + } + + isEmail(value) { + return isEmail(value); + } + + /** + * @param {Editor} editor + */ + onLoadWysiwyg(editor) { + this.wysiwyg.editor = editor; + } + + /** + * Format message content for email notification + */ + async getFormattedWysiwygContent() { + const el = this.wysiwyg.editor.getElContent(); + await HtmlMailField.getInlinedEditorContent(cssRulesByElement, this.wysiwyg.editor, el); + this.wysiwyg.message = isEmpty(el.firstChild) ? "" : this.wysiwyg.editor.getContent(); + } + + /** + * Catch rpc response to new contact creation request + */ + responseCall({ detail }) { + if (detail.result) { + if ( + detail.data.params.method === "web_save" && + detail.data.params.model === "res.partner" + ) { + this._addCreatedPartners(detail.result); + } + } + } + + _addCreatedPartners(createdPartners) { + this.createdPartners = { choices: [], values: [] }; + for (const partner of createdPartners) { + this.createdPartners.choices.push(this._addCreatedPartnersValues(partner)); + this.createdPartners.values.push(partner.id); + } + } + + _addCreatedPartnersValues(partner) { + return { + id: partner.id, + display_name: partner.name, + email: partner.email, + }; + } +} diff --git a/addons_extensions/documents/static/src/components/documents_permission_panel/documents_member_invite.xml b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_member_invite.xml new file mode 100644 index 000000000..c709c541e --- /dev/null +++ b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_member_invite.xml @@ -0,0 +1,83 @@ + + + + +
+ + + + + + + + + + + + + + Create new contact "" + + + Create and edit new contact "" + + + + +
+ +
+
+ + +
+
+ +
+
+ + + +
+ + + + + + + + + + diff --git a/addons_extensions/documents/static/src/components/documents_permission_panel/documents_partner_access.js b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_partner_access.js new file mode 100644 index 000000000..0a26abf20 --- /dev/null +++ b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_partner_access.js @@ -0,0 +1,47 @@ +/** @odoo-module **/ + +import { DocumentsAccessExpirationDateBtn } from "./documents_access_expiration_date_btn"; +import { DocumentsPermissionSelect } from "./documents_permission_select"; +import { DocumentsRemovePartnerButton } from "./documents_remove_partner_button"; +import { deserializeDateTime } from "@web/core/l10n/dates"; +import { user } from "@web/core/user"; +import { Component } from "@odoo/owl"; + +export class DocumentsPartnerAccess extends Component { + static components = { + DocumentsAccessExpirationDateBtn, + DocumentsPermissionSelect, + DocumentsRemovePartnerButton, + }; + static props = { + access: Object, + accessPartners: Array, + basePartnersRole: Object, + basePartnersAccessExpDate: Object, + isAdmin: Boolean, + isInternalUser: Boolean, + isCurrentUser: Function, + disabled: Boolean, + ownerUser: [Object, Boolean], + onChangePartnerRole: Function, + removeDocumentAccess: Function, + selections: Array, + setExpirationDate: Function, + }; + static template = "documents.PartnerAccess"; + + /** + * @returns {string|undefined} + */ + noEditorMessage(partner) { + return undefined; + } + + getFormattedLocalExpirationDate(accessPartner) { + return deserializeDateTime(accessPartner.expiration_date, { + tz: user.context.tz, + }) + .setLocale(user.context.lang.replaceAll("_", "-")) + .toLocaleString(luxon.DateTime.DATETIME_SHORT); + } +} diff --git a/addons_extensions/documents/static/src/components/documents_permission_panel/documents_partner_access.xml b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_partner_access.xml new file mode 100644 index 000000000..b7689c78c --- /dev/null +++ b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_partner_access.xml @@ -0,0 +1,70 @@ + + + +

People with access

+ +
+ + + +
+ Owner +
+
+ + +
+ + + +
+
+ + + +
+
+
+
+
+ + +
+ +
+
+
+ + You +
+
+ +
+
+ Exp: + + +
+
+
+
diff --git a/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_panel.js b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_panel.js new file mode 100644 index 000000000..7dd17ef40 --- /dev/null +++ b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_panel.js @@ -0,0 +1,277 @@ +/** @odoo-module **/ + +import { CopyButton } from "@web/core/copy_button/copy_button"; +import { Dialog } from "@web/core/dialog/dialog"; +import { DocumentsAccessSettings } from "./documents_access_settings"; +import { DocumentsMemberInvite } from "./documents_member_invite"; +import { DocumentsPartnerAccess } from "./documents_partner_access"; +import { serializeDateTime } from "@web/core/l10n/dates"; +import { _t } from "@web/core/l10n/translation"; +import { useBus, useService } from "@web/core/utils/hooks"; +import { rpcBus } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; +import { Component, onWillStart, useState } from "@odoo/owl"; + +export class DocumentsPermissionPanel extends Component { + static components = { + CopyButton, + Dialog, + DocumentsAccessSettings, + DocumentsMemberInvite, + DocumentsPartnerAccess, + }; + static props = { + document: { + type: Object, + shape: { + id: Number, + name: { type: String, optional: true }, + }, + }, + close: Function, + onChangesSaved: { type: Function, optional: true }, + }; + static template = "documents.PermissionPanel"; + + setup() { + this.actionService = useService("action"); + this.basePartnersRole = {}; + this.createdPartners = { choices: [], values: [] }; + this.dialog = useService("dialog"); + this.isAdmin = user.isAdmin; + this.orm = useService("orm"); + this.documentService = useService("document.document"); + this.isInternalUser = this.documentService.userIsInternal; + this.state = useState({ + loading: true, + mainPage: true, + didSave: false, + }); + + useBus(rpcBus, "RPC:RESPONSE", this.responseCall); + + onWillStart(async () => { + this.state.loading = true; + await this.loadMainPage(); + this.state.loading = false; + }); + } + + get panelTitle() { + return _t("Share: %(documentName)s", { documentName: this.state.access.display_name }); + } + + get pendingSave() { + return ( + Object.entries(this.baseAccess).some( + ([fieldName, value]) => this.state.access[fieldName] !== value + ) || + this.partnersRoleIsDirty || + this.partnersAccessExpDateIsDirty + ); + } + + get copyText() { + return this.pendingSave ? _t("Save or discard changes first") : _t("Copy Link"); + } + + /** + * Check whether there is an alert message to display, and which one + * + * @returns {string|null} + */ + get warningMessage() { + if ( + this.state.access.access_via_link === "edit" && + (this.state.access.access_internal === "view" || + this.state.access.access_ids.some((a) => a.role === "view")) + ) { + const documentType = + this.state.access.type === "folder" ? _t("folder") : _t("document"); + return _t( + "All users with access to this %(documentType)s or its parent will have edit permissions.", + { documentType } + ); + } + return null; + } + + get partnersRoleIsDirty() { + return this.state.access.access_ids.some((a) => a.role !== this.basePartnersRole[a.id]); + } + + get partnersAccessExpDateIsDirty() { + return this.state.access.access_ids.some( + (a) => a.expiration_date !== this.basePartnersAccessExpDate[a.id] + ); + } + + revertChanges() { + Object.assign(this.state.access, this.baseAccess); + for (const [id, role] of Object.entries(this.basePartnersRole)) { + const accessPartner = this.state.access.access_ids.find((a) => a.id === parseInt(id)); + accessPartner.role = role; + accessPartner.expiration_date = this.basePartnersAccessExpDate[id]; + } + } + + showMainPage(value = true) { + this.state.mainPage = value; + } + + async save() { + this.state.loading = true; + const userPermission = await this.updateAccessRights(); + if (userPermission === "none") { + // Don't crash when current user just removed their own access + await this.actionService.restore(this.actionService.currentController.jsId); + return; + } + await this.loadMainPage(); + this.state.loading = false; + } + + async loadMainPage() { + const permissionsData = await this.orm.call("documents.document", "permission_panel_data", [ + this.props.document.id, + ]); + this.state.access = permissionsData.record; + this.state.selections = permissionsData.selections; + this.selectedPartnersRole = this.state.selections.doc_access_roles + ? this.state.selections.doc_access_roles[0][0] + : ""; + this.baseAccess = Object.fromEntries( + ["access_internal", "access_via_link", "is_access_via_link_hidden"].map((fieldName) => [ + fieldName, + this.state.access[fieldName], + ]) + ); + this.basePartnersRole = {}; + (this.state.access.access_ids || []).forEach((a) => (this.basePartnersRole[a.id] = a.role)); + this.basePartnersAccessExpDate = {}; + (this.state.access.access_ids || []).forEach( + (a) => (this.basePartnersAccessExpDate[a.id] = a.expiration_date) + ); + } + + /** + * Create/Update/Unlink access to document/folder. + * @param {Object} partners Partners to be added. + * @param {Boolean} notify Whether to notify the `partners` + * @param {String} message Optional customized message + */ + async updateAccessRights(partners= undefined, notify = false, message = "") { + const accessValuesToSend = Object.fromEntries( + Object.entries(this.baseAccess).map(([field, oldValue]) => [ + field, + oldValue !== this.state.access[field] ? this.state.access[field] : null, + ]) + ); + let partnersToUpdate = partners; + if (this.partnersRoleIsDirty || this.partnersAccessExpDateIsDirty) { + partnersToUpdate = partnersToUpdate || {}; + this.state.access.access_ids.forEach((a) => { + const roleUpdated = a.role !== this.basePartnersRole[a.id]; + const expirationUpdated = + a.expiration_date !== this.basePartnersAccessExpDate[a.id]; + if (roleUpdated || expirationUpdated) { + partnersToUpdate[a.partner_id.id] = [ + a.role, + expirationUpdated ? a.expiration_date : null, + ]; + } + }); + } + const userPermission = (await this.orm.call("documents.document", "action_update_access_rights", [ + [this.props.document.id], + accessValuesToSend.access_internal, + accessValuesToSend.access_via_link, + accessValuesToSend.is_access_via_link_hidden, + partnersToUpdate, + notify, + message, + ]))[0]; + this.state.didSave = true; + return userPermission; + } + + /** + * Unset partner access to a document/folder + * @param {Proxy} accessPartner + */ + removeDocumentAccess(accessPartner) { + accessPartner.role = false; + this.state.partnersRoleIsDirty = true; + } + + /** + * @param {Proxy} accessPartner + * @returns {Boolean} + */ + isCurrentUser(accessPartner) { + return accessPartner.partner_id.id === user.partnerId; + } + + close() { + if (this.state.didSave) { + this.props.onChangesSaved?.(); + } + this.props.close(); + } + + onDiscard() { + return this.pendingSave ? this.revertChanges() : this.close(); + } + + onShare() { + return this.pendingSave && this.state.access.user_permission === "edit" + ? this.save() + : this.close(); + } + + /** + * @param {Event} event + */ + onChangeDocumentAccessInternal(event) { + this.state.access.access_internal = event.target.selectedOptions[0].value; + } + + /** + * @param {Event} event + */ + onChangeDocumentAccessLink(event) { + this.state.access.access_via_link = event.target.selectedOptions[0].value; + } + + onChangeDocumentIsAccessLinkHidden(event) { + this.state.access.is_access_via_link_hidden = !!parseInt(event.target.value); + } + + /** + * @param {Event} event + * @param {Proxy} accessPartner + */ + onChangePartnerRole(event, accessPartner) { + accessPartner.role = event.target.selectedOptions[0].value; + } + + /** + * @param {Proxy} accessPartner + * @param {luxon.DateTime | Boolean } value + */ + setExpirationDate(accessPartner, value) { + accessPartner.expiration_date = value ? serializeDateTime(value) : value; + } + + /** + * Catch rpc response on update: error on document/folder or its access, + * result on document tag creation + */ + responseCall({ detail }) { + if (detail.error && detail.data.params.model === "documents.document") { + this.state.mainPage = true; + this.revertChanges(); + this.state.loading = false; + } + } +} diff --git a/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_panel.xml b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_panel.xml new file mode 100644 index 000000000..f0b726b1b --- /dev/null +++ b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_panel.xml @@ -0,0 +1,77 @@ + + + + + + +
+ + +
+ +
+
+
diff --git a/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_select.js b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_select.js new file mode 100644 index 000000000..73d933a1b --- /dev/null +++ b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_select.js @@ -0,0 +1,74 @@ +/** @odoo-module **/ + +import { SelectMenu } from "@web/core/select_menu/select_menu"; +import { Component } from "@odoo/owl"; + +export class DocumentsPermissionSelect extends Component { + static defaultProps = { + ariaLabel: "Document permission select", + disabled: false, + }; + static props = { + ariaLabel: { type: String, optional: true }, + disabled: { type: Boolean, optional: true }, + label: { type: String, optional: true }, + labelHelper: { type: String, optional: true }, + options: Array, + onChange: { type: Function, optional: true }, + selectClass: { type: String, optional: true }, + showFeedbackChange: { type: Boolean, optional: true }, + value: [String, Number, Boolean], + noEditorMessage: { type: String, optional: true }, + }; + static template = "documents.PermissionSelect"; + + get selectClass() { + return `${ + this.props.selectClass ? this.props.selectClass : this.props.label ? "w-75" : "w-50" + } ${this.props.showFeedbackChange ? "border-primary" : ""}`; + } +} + +export class DocumentsPermissionSelectMenu extends SelectMenu { + static defaultProps = { + ...super.defaultProps, + hasColor: false, + }; + static props = { + ...super.props, + buttonText: { type: String, optional: true }, + hasColor: { type: Boolean, optional: true }, + onOpen: { type: Function, optional: true }, + }; + static template = "documents.PermissionSelectMenu"; + + onStateChanged(open) { + super.onStateChanged(open); + if (open) { + this.menuRef.el.querySelector("input").focus(); + this.props.onOpen?.(); + } + } + + get multiSelectChoices() { + if (this.props.hasColor) { + const choices = [ + ...this.props.choices, + ...this.props.groups.flatMap((g) => g.choices), + ].filter((c) => this.props.value.includes(c.value)); + return choices.map((c) => { + return { + id: c.value, + text: c.label, + colorIndex: c.colorIndex, + onDelete: () => { + const values = [...this.props.value]; + values.splice(values.indexOf(c.value), 1); + this.props.onSelect(values); + }, + }; + }); + } + return super.multiSelectChoices; + } +} diff --git a/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_select.xml b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_select.xml new file mode 100644 index 000000000..11e4db8b4 --- /dev/null +++ b/addons_extensions/documents/static/src/components/documents_permission_panel/documents_permission_select.xml @@ -0,0 +1,32 @@ + + + + +
+