remove conflict

This commit is contained in:
Pawan Kumar 2020-12-23 17:02:46 +05:30
commit 81454e4494
285 changed files with 93281 additions and 39 deletions

1
.gitignore vendored Executable file
View File

@ -0,0 +1 @@
.pyc

0
cor_custom/__pycache__/__init__.cpython-36.pyc Normal file → Executable file
View File

View File

View File

View File

@ -6,4 +6,3 @@ from . import project
from . import project_overview
from . import analytic
from . import product
#from . import sale

0
cor_custom/models/__pycache__/__init__.cpython-36.pyc Normal file → Executable file
View File

0
cor_custom/models/__pycache__/analytic.cpython-36.pyc Normal file → Executable file
View File

0
cor_custom/models/__pycache__/crm_lead.cpython-36.pyc Normal file → Executable file
View File

0
cor_custom/models/__pycache__/models.cpython-36.pyc Normal file → Executable file
View File

0
cor_custom/models/__pycache__/product.cpython-36.pyc Normal file → Executable file
View File

0
cor_custom/models/__pycache__/project.cpython-36.pyc Normal file → Executable file
View File

View File

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details
from odoo import api, fields, models, _
from odoo import api, exceptions, fields, models, _
from odoo.exceptions import UserError, AccessError, ValidationError
from odoo.osv import expression
@ -11,6 +11,30 @@ class AccountAnalyticLine(models.Model):
start_time = fields.Float('Start Time', digits=(16, 2))
end_time = fields.Float('End Time', digits=(16, 2))
_sql_constraints = [
('check_start_time_lower_than_24', 'CHECK(start_time <= 24)', 'You cannot have a start hour greater than 24'),
('check_start_time_positive', 'CHECK(start_time >= 0)', 'Start hour must be a positive number'),
('check_end_time_lower_than_24', 'CHECK(end_time <= 24)', 'You cannot have a end hour greater than 24'),
('check_end_time_positive', 'CHECK(end_time >= 0)', 'End hour must be a positive number'),
]
@api.constrains('start_time', 'end_time')
def _check_validity_start_time_end_time(self):
for rec in self:
if rec.start_time and rec.end_time:
if rec.end_time < rec.start_time:
raise exceptions.ValidationError(_('End time cannot be earlier than Start time'))
@api.onchange('start_time', 'end_time')
def _onchange_start_end_time(self):
if self.start_time > 0 and self.end_time > 0:
res = self.end_time - self.start_time
if res <= 0:
raise ValidationError(_("End time cannot be earlier than Start time"))
self.unit_amount = res
@api.model
def create(self, vals):
if vals.get('unit_amount') == 0.0:

View File

@ -24,6 +24,8 @@ class Lead(models.Model):
start_date = fields.Date(string='Start Date')
close_date = fields.Date(string='Close Date')
admin_user = fields.Boolean(compute='get_admin_login')
#user_id = fields.Many2one('res.users', string='Salesperson', index=True, tracking=True, default=lambda self: self.env.user)
#set_readonly = fields.Boolean('Set Readonly', compute='_domain_check_user_group')
_sql_constraints = [
('phone_uniq', 'unique(phone)', "Phone No already exists !"),
@ -39,3 +41,9 @@ class Lead(models.Model):
else:
record.admin_user = False
"""def _domain_check_user_group(self):
print(self.user_has_groups('sales_team.group_sale_salesman'), self.user_has_groups('sales_team.group_sale_salesman_all_leads'), self.user_has_groups('group_sale_manager'))
if self.user_has_groups('sales_team.group_sale_salesman') and \
(not self.user_has_groups('sales_team.group_sale_salesman_all_leads') or not self.user_has_groups('group_sale_manager')):
self.set_readonly = True
return domain"""

View File

@ -27,8 +27,8 @@
</field>
</record>
<record id="timesheet_view_tree_user_inherit1" model="ir.ui.view">
<field name="name">account.analytic.line.tree.hr_timesheet_inherit1</field>
<!--<record id="timesheet_view_tree_user_inherit1" model="ir.ui.view">
<field name="name">account.analytic.line.tree.hr_timesheet_user_inherit1</field>
<field name="model">account.analytic.line</field>
<field name="inherit_id" ref="hr_timesheet.timesheet_view_tree_user"/>
<field name="arch" type="xml">
@ -37,6 +37,6 @@
<field name="end_time"/>
</xpath>
</field>
</record>
</record>-->
</odoo>

View File

@ -32,6 +32,11 @@
}"
/>
</xpath>
<!--<xpath expr="//field[@name='user_id']" position="replace">
<field name="set_readonly" invisible="1" />
<field name="user_id" domain="[('share', '=', False)]" attrs="{'readonly': [('set_readonly','!=',True)]}"
context="{'default_sales_team_id': team_id}" widget="many2one_avatar_user"/>
</xpath>-->
<xpath expr="//group[@name='Misc']" position="after">
<group colspan="2" col="4">
<field name="lead_no"/>

0
cor_custom/wizard/__pycache__/__init__.cpython-36.pyc Normal file → Executable file
View File

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
planning/__init__.py Executable file
View File

@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import controllers
from . import wizard
from . import report

36
planning/__manifest__.py Executable file
View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "Planning",
'summary': """Manage your employees' schedule""",
'description': """
Schedule your teams and employees with shift.
""",
'category': 'Human Resources/Planning',
'version': '1.0',
'depends': ['hr', 'web_gantt'],
'data': [
'security/planning_security.xml',
'security/ir.model.access.csv',
'wizard/planning_send_views.xml',
'views/assets.xml',
'views/hr_views.xml',
'report/planning_report_views.xml',
'views/planning_template_views.xml',
'views/planning_views.xml',
'views/res_config_settings_views.xml',
'views/planning_templates.xml',
'data/planning_cron.xml',
'data/mail_data.xml',
],
'demo': [
'data/planning_demo.xml',
],
'application': True,
'license': 'OEEL-1',
'qweb': [
'static/src/xml/planning_gantt.xml',
'static/src/xml/field_colorpicker.xml',
]
}

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main

127
planning/controllers/main.py Executable file
View File

@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licens
from odoo import http, fields, _
from odoo.http import request
from odoo.osv import expression
import pytz
from werkzeug.utils import redirect
import babel
from werkzeug.exceptions import Forbidden
from odoo import tools
class ShiftController(http.Controller):
@http.route(['/planning/<string:planning_token>/<string:employee_token>'], type='http', auth="public", website=True)
def planning(self, planning_token, employee_token, message=False, **kwargs):
""" Displays an employee's calendar and the current list of open shifts """
employee_sudo = request.env['hr.employee'].sudo().search([('employee_token', '=', employee_token)], limit=1)
if not employee_sudo:
return request.not_found()
planning_sudo = request.env['planning.planning'].sudo().search([('access_token', '=', planning_token)], limit=1)
if not planning_sudo:
return request.not_found()
employee_tz = pytz.timezone(employee_sudo.tz or 'UTC')
employee_fullcalendar_data = []
open_slots = []
planning_slots = []
# domain of slots to display
domain = planning_sudo._get_domain_slots()[planning_sudo.id]
if planning_sudo.include_unassigned:
domain = expression.AND([domain, ['|', ('employee_id', '=', employee_sudo.id), ('employee_id', '=', False)]])
else:
domain = expression.AND([domain, [('employee_id', '=', employee_sudo.id)]])
planning_slots = request.env['planning.slot'].sudo().search(domain)
# filter and format slots
for slot in planning_slots:
if slot.employee_id:
employee_fullcalendar_data.append({
'title': '%s%s' % (slot.role_id.name, u' \U0001F4AC' if slot.name else ''),
'start': str(pytz.utc.localize(slot.start_datetime).astimezone(employee_tz).replace(tzinfo=None)),
'end': str(pytz.utc.localize(slot.end_datetime).astimezone(employee_tz).replace(tzinfo=None)),
'color': self._format_planning_shifts(slot.role_id.color),
'alloc_hours': slot.allocated_hours,
'slot_id': slot.id,
'note': slot.name,
'allow_self_unassign': slot.allow_self_unassign
})
else:
open_slots.append(slot)
return request.render('planning.period_report_template', {
'employee_slots_fullcalendar_data': employee_fullcalendar_data,
'open_slots_ids': open_slots,
'planning_slots_ids': planning_slots,
'planning_planning_id': planning_sudo,
'employee': employee_sudo,
'format_datetime': lambda dt, dt_format: tools.format_datetime(request.env, dt, dt_format=dt_format),
'message_slug': message,
})
@http.route('/planning/<string:token_planning>/<string:token_employee>/assign/<int:slot_id>', type="http", auth="public", methods=['post'], website=True)
def planning_self_assign(self, token_planning, token_employee, slot_id, **kwargs):
slot_sudo = request.env['planning.slot'].sudo().browse(slot_id)
if not slot_sudo.exists():
return request.not_found()
if slot_sudo.employee_id:
raise Forbidden(_('You can not assign yourself to this shift.'))
employee_sudo = request.env['hr.employee'].sudo().search([('employee_token', '=', token_employee)], limit=1)
if not employee_sudo:
return request.not_found()
planning_sudo = request.env['planning.planning'].sudo().search([('access_token', '=', token_planning)], limit=1)
if not planning_sudo or slot_sudo.id not in planning_sudo.slot_ids._ids:
return request.not_found()
slot_sudo.write({'employee_id': employee_sudo.id})
return redirect('/planning/%s/%s?message=%s' % (token_planning, token_employee, 'assign'))
@http.route('/planning/<string:token_planning>/<string:token_employee>/unassign/<int:shift_id>', type="http", auth="public", methods=['post'], website=True)
def planning_self_unassign(self, token_planning, token_employee, shift_id, **kwargs):
slot_sudo = request.env['planning.slot'].sudo().search([('id', '=', shift_id)], limit=1)
if not slot_sudo or not slot_sudo.allow_self_unassign:
return request.not_found()
employee_sudo = request.env['hr.employee'].sudo().search([('employee_token', '=', token_employee)], limit=1)
if not employee_sudo or employee_sudo.id != slot_sudo.employee_id.id:
return request.not_found()
planning_sudo = request.env['planning.planning'].sudo().search([('access_token', '=', token_planning)], limit=1)
if not planning_sudo or slot_sudo.id not in planning_sudo.slot_ids._ids:
return request.not_found()
slot_sudo.write({'employee_id': False})
return redirect('/planning/%s/%s?message=%s' % (token_planning, token_employee, 'unassign'))
@staticmethod
def _format_planning_shifts(color_code):
"""Take a color code from Odoo's Kanban view and returns an hex code compatible with the fullcalendar library"""
switch_color = {
0: '#008784', # No color (doesn't work actually...)
1: '#EE4B39', # Red
2: '#F29648', # Orange
3: '#F4C609', # Yellow
4: '#55B7EA', # Light blue
5: '#71405B', # Dark purple
6: '#E86869', # Salmon pink
7: '#008784', # Medium blue
8: '#267283', # Dark blue
9: '#BF1255', # Fushia
10: '#2BAF73', # Green
11: '#8754B0' # Purple
}
return switch_color[color_code]

118
planning/data/mail_data.xml Executable file
View File

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data>
<record id="email_template_slot_single" model="mail.template">
<field name="name">Planning: new schedule (single shift)</field>
<field name="email_from">${(object.company_id.email or '')}</field>
<field name="subject">Planning: new schedule (single shift)</field>
<field name="email_to">${object.employee_id.work_email}</field>
<field name="model_id" ref="model_planning_slot"/>
<field name="auto_delete" eval="False"/>
<field name="body_html" type="html">
<div>
<p>Dear ${object.employee_id.name or ''},</p><br/>
<p>You have been assigned the following schedule:</p><br/>
<table style="table-layout: fixed; width: 80%; margin: auto;">
<tr>
<th style="padding: 5px;text-align: left; width: 15%;">From</th>
<td style="padding: 5px;">${format_datetime(object.start_datetime, tz=object.employee_id.tz)}</td>
</tr>
<tr>
<th style="padding: 5px;text-align: left; width: 15%;">To</th>
<td style="padding: 5px;">${format_datetime(object.end_datetime, tz=object.employee_id.tz)}</td>
</tr>
% if object.role_id
<tr>
<th style="padding: 5px;text-align: left; width: 15%;">Role</th>
<td style="padding: 5px;">${object.role_id.name or ''}</td>
</tr>
% endif
% if object.project_id
<tr>
<th style="padding: 5px;text-align: left; width: 15%;">Project</th>
<td style="padding: 5px;">${object.project_id.name or ''}</td>
</tr>
% endif
% if object.name
<tr>
<th style="padding: 5px;text-align: left; width: 15%;">Note</th>
<td style="padding: 5px;">${object.name or ''}</td>
</tr>
% endif
</table>
<div style="text-align: center">
% if ctx.get('render_link')
<div style="display: inline-block; margin: 15px; text-align: center">
<a href="${ctx.unavailable_path}${object.employee_id.employee_token}" target="_blank"
style="padding: 5px 10px; color: #875A7B; text-decoration: none; background-color: #FFFFFF; border: 1px solid #FFFFFF; border-radius: 3px"
>I am unavailable</a>
</div>
% endif
% if ctx.get('render_link')
<div style="display: inline-block; margin: 15px; text-align: center">
<a href="/web?#action=${ctx.get('action_id')}&amp;model=planning.slot&amp;menu_id=${ctx.get('menu_id')}&amp;db=${'dbname' in ctx and ctx['dbname'] or '' }" target="_blank"
style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px"
>View Planning</a>
</div>
% endif
</div>
</div>
</field>
</record>
<record id="email_template_planning_planning" model="mail.template">
<field name="name">Planning: new schedule (multiple shifts)</field>
<field name="email_from">${(object.company_id.email or '')}</field>
<field name="subject">Your planning from ${format_datetime(object.start_datetime, tz=ctx.get('employee').tz or 'UTC', dt_format='short')} to ${format_datetime(object.end_datetime, tz=employee.tz if employee else 'UTC', dt_format='short')}</field>
<field name="email_to"></field><!-- Set in the code -->
<field name="model_id" ref="model_planning_planning"/>
<field name="auto_delete" eval="False"/><!-- TODO JEM change this as we are testing -->
<field name="body_html" type="html">
<div>
% if ctx.get('employee'):
<p>Dear ${ctx['employee'].name},</p>
% else:
<p>Hello,</p>
% endif
<p>
You have been assigned new shifts:
</p>
<table style="table-layout: fixed; width: 80%; margin: auto;">
<tr>
<th style="padding: 5px;text-align: left; width: 15%;">From</th>
<td style="padding: 5px;">${format_datetime(object.start_datetime, tz=ctx.get('employee').tz or 'UTC', dt_format='short')}</td>
</tr>
<tr>
<th style="padding: 5px;text-align: left; width: 15%;">To</th>
<td style="padding: 5px;">${format_datetime(object.end_datetime, tz=ctx.get('employee').tz or 'UTC', dt_format='short')}</td>
</tr>
% if object.project_id
<tr>
<th style="padding: 5px;text-align: left; width: 15%;">Project</th>
<td style="padding: 5px;">${object.project_id.name or ''}</td>
</tr>
% endif
</table>
% if ctx.get('planning_url'):
<div style="margin: 15px;">
<a href="${ctx.get('planning_url')}" target="_blank"
style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px">View Your Planning</a>
</div>
% endif
% if ctx.get('slot_unassigned_count'):
<p>There are new open shifts available. Please assign yourself if you are available.</p>
% endif
% if ctx.get('message'):
<p>${ctx['message']}</p>
% endif
</div>
</field>
</record>
</data>
</odoo>

13
planning/data/planning_cron.xml Executable file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_forecast_schedule" model="ir.cron">
<field name="name">Planning: generate next recurring shifts</field>
<field name="model_id" ref="model_planning_recurrency"/>
<field name="state">code</field>
<field name="code">model._cron_schedule_next()</field>
<field name="interval_type">weeks</field>
<field name="numbercall">-1</field>
</record>
</data>
</odoo>

547
planning/data/planning_demo.xml Executable file
View File

@ -0,0 +1,547 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Project forecast will warn if the user assigned to a forecast doesn't have a timezone -->
<record id="base.user_demo" model="res.users">
<field name="tz">Europe/Brussels</field>
<field name="groups_id" eval="[(4,ref('planning.group_planning_user'))]"/>
</record>
<!-- Roles -->
<record id="planning_role_bartender" model="planning.role">
<field name="name">Bartender</field>
<field name="color">2</field>
</record>
<record id="planning_role_waiter" model="planning.role">
<field name="name">Waiter</field>
<field name="color">3</field>
</record>
<record id="planning_role_chef" model="planning.role">
<field name="name">Chef</field>
<field name="color">4</field>
</record>
<!-- Shift templates (morning shifts) -->
<record id="planning_template_chef_morning" model="planning.slot.template">
<field name="role_id" ref="planning_role_chef"/>
<field name="start_time" eval="8"/>
<field name="duration" eval="6"/>
</record>
<record id="planning_template_bartender_morning" model="planning.slot.template">
<field name="role_id" ref="planning_role_bartender"/>
<field name="start_time" eval="8"/>
<field name="duration" eval="8"/>
</record>
<record id="planning_template_waiter_morning" model="planning.slot.template">
<field name="role_id" ref="planning_role_waiter"/>
<field name="start_time" eval="8"/>
<field name="duration" eval="8"/>
</record>
<!-- Shift templates (evening shifts) -->
<record id="planning_template_chef_evening" model="planning.slot.template">
<field name="role_id" ref="planning_role_chef"/>
<field name="start_time" eval="14"/>
<field name="duration" eval="8"/>
</record>
<record id="planning_template_bartender_evening" model="planning.slot.template">
<field name="role_id" ref="planning_role_bartender"/>
<field name="start_time" eval="16"/>
<field name="duration" eval="8"/>
</record>
<record id="planning_template_waiter_evening" model="planning.slot.template">
<field name="role_id" ref="planning_role_waiter"/>
<field name="start_time" eval="16"/>
<field name="duration" eval="8"/>
</record>
<!-- Recurrencies -->
<record id="planning_recurrency_1" model="planning.recurrency">
<field name="repeat_interval" eval="1"/>
<field name="repeat_type">forever</field>
</record>
<record id="planning_recurrency_2" model="planning.recurrency">
<field name="repeat_interval" eval="1"/>
<field name="repeat_type">forever</field>
</record>
<record id="planning_recurrency_3" model="planning.recurrency">
<field name="repeat_interval" eval="1"/>
<field name="repeat_type">forever</field>
</record>
<!-- Week 1 -->
<!-- Monday morning shifts -->
<record id="planning_slot_111" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_qdp"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_112" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 12:00:00')"/>
<field name="employee_id" ref="hr.employee_stw"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_113" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_mit"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Monday evening shifts -->
<record id="planning_slot_114" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 20:00:00')"/>
<field name="employee_id" ref="hr.employee_jth"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_115" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_chs"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_116" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday())).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_niv"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Tuesday morning shifts -->
<record id="planning_slot_121" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_qdp"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_122" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 12:00:00')"/>
<field name="employee_id" eval="False"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="name" eval="'Check with Katy from the employment agency if they have someone to replace Randall this day'"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_123" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_jep"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Tuesday evening shifts -->
<record id="planning_slot_124" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 20:00:00')"/>
<field name="employee_id" ref="hr.employee_jth"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_125" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_chs"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_126" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 1)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_niv"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Wednesday morning shifts -->
<record id="planning_slot_131" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_qdp"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_132" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 12:00:00')"/>
<field name="employee_id" ref="hr.employee_stw"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_133" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_mit"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Wednesday evening shifts -->
<record id="planning_slot_134" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 20:00:00')"/>
<field name="employee_id" ref="hr.employee_jth"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_135" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_admin"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="name" eval="'Mitchel is replacing Jennie until the end of the week'"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_136" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 2)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_niv"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Thursday morning shifts -->
<record id="planning_slot_141" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_qdp"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_142" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 12:00:00')"/>
<field name="employee_id" ref="hr.employee_stw"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_143" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_mit"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Thursday evening shifts -->
<record id="planning_slot_144" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 20:00:00')"/>
<field name="employee_id" ref="hr.employee_jth"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_145" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_admin"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_146" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 3)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_niv"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Friday morning shifts -->
<record id="planning_slot_151" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" eval="False"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_152" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 12:00:00')"/>
<field name="employee_id" ref="hr.employee_stw"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_153" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_mit"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Friday evening shifts -->
<record id="planning_slot_154" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 20:00:00')"/>
<field name="employee_id" ref="hr.employee_jth"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_155" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_admin"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_156" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 4)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_niv"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Week 2 -->
<!-- Monday morning shifts -->
<record id="planning_slot_211" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_qdp"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_212" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 12:00:00')"/>
<field name="employee_id" ref="hr.employee_stw"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_213" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_mit"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Monday evening shifts -->
<record id="planning_slot_214" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 20:00:00')"/>
<field name="employee_id" ref="hr.employee_jth"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_215" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_chs"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_216" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_niv"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Tuesday morning shifts -->
<record id="planning_slot_221" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_qdp"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_222" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 12:00:00')"/>
<field name="employee_id" ref="hr.employee_stw"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_223" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_jep"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Tuesday evening shifts -->
<record id="planning_slot_224" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 20:00:00')"/>
<field name="employee_id" ref="hr.employee_jth"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_225" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_chs"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_226" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 1)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" eval="False"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Wednesday morning shifts -->
<record id="planning_slot_231" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_qdp"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_232" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 12:00:00')"/>
<field name="employee_id" ref="hr.employee_stw"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_233" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_mit"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Wednesday evening shifts -->
<record id="planning_slot_234" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 20:00:00')"/>
<field name="employee_id" ref="hr.employee_jth"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_235" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_chs"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_236" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 2)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" eval="False"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Thursday morning shifts -->
<record id="planning_slot_241" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_qdp"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_242" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 12:00:00')"/>
<field name="employee_id" ref="hr.employee_stw"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_243" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_mit"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Thursday evening shifts -->
<record id="planning_slot_244" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 20:00:00')"/>
<field name="employee_id" ref="hr.employee_admin"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="name" eval="'Ask Toni if she can show Mitchel how things are done in the kitchen because it is her first time working as a chef'"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_245" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_chs"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_246" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 3)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_niv"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Friday morning shifts -->
<record id="planning_slot_251" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" ref="hr.employee_qdp"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="recurrency_id" ref="planning_recurrency_1"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_252" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 12:00:00')"/>
<field name="employee_id" ref="hr.employee_stw"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="recurrency_id" ref="planning_recurrency_2"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_253" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 06:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="employee_id" eval="False"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Friday evening shifts -->
<record id="planning_slot_254" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 20:00:00')"/>
<field name="employee_id" ref="hr.employee_admin"/>
<field name="role_id" ref="planning_role_chef"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_255" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_chs"/>
<field name="role_id" ref="planning_role_waiter"/>
<field name="recurrency_id" ref="planning_recurrency_3"/>
<field name="publication_warning" eval="False"/>
</record>
<record id="planning_slot_256" model="planning.slot">
<field name="start_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 14:00:00')"/>
<field name="end_datetime" eval="(datetime.today() - timedelta(days=datetime.today().weekday() - 7 - 4)).strftime('%Y-%m-%d 22:00:00')"/>
<field name="employee_id" ref="hr.employee_niv"/>
<field name="role_id" ref="planning_role_bartender"/>
<field name="publication_warning" eval="False"/>
</record>
<!-- Duplicate shifts that have a recurrence set -->
<function model="planning.recurrency" name="_repeat_slot" eval="[[ref('planning_recurrency_1'), ref('planning_recurrency_2'), ref('planning_recurrency_3')]]"/>
</data>
</odoo>

1175
planning/i18n/ar.po Executable file

File diff suppressed because it is too large Load Diff

1166
planning/i18n/az.po Executable file

File diff suppressed because it is too large Load Diff

1162
planning/i18n/az_AZ.po Executable file

File diff suppressed because it is too large Load Diff

1172
planning/i18n/bg.po Executable file

File diff suppressed because it is too large Load Diff

1212
planning/i18n/cs.po Executable file

File diff suppressed because it is too large Load Diff

1175
planning/i18n/da.po Executable file

File diff suppressed because it is too large Load Diff

1175
planning/i18n/de.po Executable file

File diff suppressed because it is too large Load Diff

1168
planning/i18n/el.po Executable file

File diff suppressed because it is too large Load Diff

1193
planning/i18n/es.po Executable file

File diff suppressed because it is too large Load Diff

1176
planning/i18n/fi.po Executable file

File diff suppressed because it is too large Load Diff

1288
planning/i18n/fr.po Executable file

File diff suppressed because it is too large Load Diff

1271
planning/i18n/he.po Executable file

File diff suppressed because it is too large Load Diff

1175
planning/i18n/hr.po Executable file

File diff suppressed because it is too large Load Diff

1174
planning/i18n/hu.po Executable file

File diff suppressed because it is too large Load Diff

1174
planning/i18n/id.po Executable file

File diff suppressed because it is too large Load Diff

1172
planning/i18n/it.po Executable file

File diff suppressed because it is too large Load Diff

1170
planning/i18n/ja.po Executable file

File diff suppressed because it is too large Load Diff

1262
planning/i18n/ko.po Executable file

File diff suppressed because it is too large Load Diff

1166
planning/i18n/lb.po Executable file

File diff suppressed because it is too large Load Diff

1173
planning/i18n/lt.po Executable file

File diff suppressed because it is too large Load Diff

1170
planning/i18n/lv.po Executable file

File diff suppressed because it is too large Load Diff

1166
planning/i18n/ml.po Executable file

File diff suppressed because it is too large Load Diff

1172
planning/i18n/mn.po Executable file

File diff suppressed because it is too large Load Diff

1169
planning/i18n/my.po Executable file

File diff suppressed because it is too large Load Diff

1169
planning/i18n/nb.po Executable file

File diff suppressed because it is too large Load Diff

1288
planning/i18n/nl.po Executable file

File diff suppressed because it is too large Load Diff

1176
planning/i18n/pl.po Executable file

File diff suppressed because it is too large Load Diff

1162
planning/i18n/planning.pot Executable file

File diff suppressed because it is too large Load Diff

1174
planning/i18n/pt.po Executable file

File diff suppressed because it is too large Load Diff

1176
planning/i18n/pt_BR.po Executable file

File diff suppressed because it is too large Load Diff

1168
planning/i18n/ro.po Executable file

File diff suppressed because it is too large Load Diff

1172
planning/i18n/ru.po Executable file

File diff suppressed because it is too large Load Diff

1171
planning/i18n/sk.po Executable file

File diff suppressed because it is too large Load Diff

1172
planning/i18n/sl.po Executable file

File diff suppressed because it is too large Load Diff

1167
planning/i18n/sr.po Executable file

File diff suppressed because it is too large Load Diff

1174
planning/i18n/sv.po Executable file

File diff suppressed because it is too large Load Diff

1176
planning/i18n/tr.po Executable file

File diff suppressed because it is too large Load Diff

1279
planning/i18n/uk.po Executable file

File diff suppressed because it is too large Load Diff

1162
planning/i18n/uz.po Executable file

File diff suppressed because it is too large Load Diff

1279
planning/i18n/vi.po Executable file

File diff suppressed because it is too large Load Diff

1176
planning/i18n/zh_CN.po Executable file

File diff suppressed because it is too large Load Diff

1171
planning/i18n/zh_TW.po Executable file

File diff suppressed because it is too large Load Diff

9
planning/models/__init__.py Executable file
View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr
from . import planning_recurrency
from . import planning
from . import planning_template
from . import res_company
from . import res_config_settings

44
planning/models/hr.py Executable file
View File

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import uuid
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class Employee(models.Model):
_inherit = "hr.employee"
def _default_employee_token(self):
return str(uuid.uuid4())
planning_role_id = fields.Many2one('planning.role', string="Default Planning Role", groups='hr.group_hr_user')
employee_token = fields.Char('Security Token', default=_default_employee_token, copy=False, groups='hr.group_hr_user', readonly=True)
_sql_constraints = [
('employee_token_unique', 'unique(employee_token)', 'Error: each employee token must be unique')
]
def _init_column(self, column_name):
# to avoid generating a single default employee_token when installing the module,
# we need to set the default row by row for this column
if column_name == "employee_token":
_logger.debug("Table '%s': setting default value of new column %s to unique values for each row", self._table, column_name)
self.env.cr.execute("SELECT id FROM %s WHERE employee_token IS NULL" % self._table)
acc_ids = self.env.cr.dictfetchall()
query_list = [{'id': acc_id['id'], 'employee_token': self._default_employee_token()} for acc_id in acc_ids]
query = 'UPDATE ' + self._table + ' SET employee_token = %(employee_token)s WHERE id = %(id)s;'
self.env.cr._obj.executemany(query, query_list)
else:
super(Employee, self)._init_column(column_name)
def _planning_get_url(self, planning):
result = {}
for employee in self:
if employee.user_id and employee.user_id.has_group('planning.group_planning_user'):
result[employee.id] = '/web?#action=planning.planning_action_open_shift'
else:
result[employee.id] = '/planning/%s/%s' % (planning.access_token, employee.employee_token)
return result

680
planning/models/planning.py Executable file
View File

@ -0,0 +1,680 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from ast import literal_eval
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import json
import logging
import pytz
import uuid
from math import ceil
from odoo import api, fields, models, _
from odoo.exceptions import UserError, AccessError
from odoo.osv import expression
from odoo.tools.safe_eval import safe_eval
from odoo.tools import format_time
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
_logger = logging.getLogger(__name__)
def days_span(start_datetime, end_datetime):
if not isinstance(start_datetime, datetime):
raise ValueError
if not isinstance(end_datetime, datetime):
raise ValueError
end = datetime.combine(end_datetime, datetime.min.time())
start = datetime.combine(start_datetime, datetime.min.time())
duration = end - start
return duration.days + 1
class Planning(models.Model):
_name = 'planning.slot'
_description = 'Planning Shift'
_order = 'start_datetime,id desc'
_rec_name = 'name'
_check_company_auto = True
def _default_employee_id(self):
return self.env['hr.employee'].search([('user_id', '=', self.env.uid), ('company_id', '=', self.env.company.id)])
def _default_start_datetime(self):
return fields.Datetime.to_string(datetime.combine(fields.Datetime.now(), datetime.min.time()))
def _default_end_datetime(self):
return fields.Datetime.to_string(datetime.combine(fields.Datetime.now(), datetime.max.time()))
name = fields.Text('Note')
employee_id = fields.Many2one('hr.employee', "Employee", default=_default_employee_id, group_expand='_read_group_employee_id', check_company=True)
user_id = fields.Many2one('res.users', string="User", related='employee_id.user_id', store=True, readonly=True)
company_id = fields.Many2one('res.company', string="Company", required=True, compute="_compute_planning_slot_company_id", store=True, readonly=False)
role_id = fields.Many2one('planning.role', string="Role")
color = fields.Integer("Color", related='role_id.color')
was_copied = fields.Boolean("This shift was copied from previous week", default=False, readonly=True)
start_datetime = fields.Datetime("Start Date", required=True, default=_default_start_datetime)
end_datetime = fields.Datetime("End Date", required=True, default=_default_end_datetime)
# UI fields and warnings
allow_self_unassign = fields.Boolean('Let employee unassign themselves', related='company_id.planning_allow_self_unassign')
is_assigned_to_me = fields.Boolean('Is this shift assigned to the current user', compute='_compute_is_assigned_to_me')
overlap_slot_count = fields.Integer('Overlapping slots', compute='_compute_overlap_slot_count')
# time allocation
allocation_type = fields.Selection([
('planning', 'Planning'),
('forecast', 'Forecast')
], compute='_compute_allocation_type')
allocated_hours = fields.Float("Allocated hours", default=0, compute='_compute_allocated_hours', store=True)
allocated_percentage = fields.Float("Allocated Time (%)", default=100, help="Percentage of time the employee is supposed to work during the shift.")
working_days_count = fields.Integer("Number of working days", compute='_compute_working_days_count', store=True)
# publication and sending
is_published = fields.Boolean("Is the shift sent", default=False, readonly=True, help="If checked, this means the planning entry has been sent to the employee. Modifying the planning entry will mark it as not sent.")
publication_warning = fields.Boolean("Modified since last publication", default=False, readonly=True, help="If checked, it means that the shift contains has changed since its last publish.", copy=False)
# template dummy fields (only for UI purpose)
template_creation = fields.Boolean("Save as a Template", default=False, store=False, inverse='_inverse_template_creation')
template_autocomplete_ids = fields.Many2many('planning.slot.template', store=False, compute='_compute_template_autocomplete_ids')
template_id = fields.Many2one('planning.slot.template', string='Planning Templates', store=False)
# Recurring (`repeat_` fields are none stored, only used for UI purpose)
recurrency_id = fields.Many2one('planning.recurrency', readonly=True, index=True, ondelete="set null", copy=False)
repeat = fields.Boolean("Repeat", compute='_compute_repeat', inverse='_inverse_repeat')
repeat_interval = fields.Integer("Repeat every", default=1, compute='_compute_repeat', inverse='_inverse_repeat')
repeat_type = fields.Selection([('forever', 'Forever'), ('until', 'Until')], string='Repeat Type', default='forever', compute='_compute_repeat', inverse='_inverse_repeat')
repeat_until = fields.Date("Repeat Until", compute='_compute_repeat', inverse='_inverse_repeat', help="If set, the recurrence stop at that date. Otherwise, the recurrence is applied indefinitely.")
_sql_constraints = [
('check_start_date_lower_end_date', 'CHECK(end_datetime > start_datetime)', 'Shift end date should be greater than its start date'),
('check_allocated_hours_positive', 'CHECK(allocated_hours >= 0)', 'You cannot have negative shift'),
]
@api.depends('employee_id')
def _compute_planning_slot_company_id(self):
if self.employee_id:
self.company_id = self.employee_id.company_id.id
if not self.company_id.id:
self.company_id = self.env.company
@api.depends('user_id')
def _compute_is_assigned_to_me(self):
for slot in self:
slot.is_assigned_to_me = slot.user_id == self.env.user
@api.depends('start_datetime', 'end_datetime')
def _compute_allocation_type(self):
for slot in self:
if slot.start_datetime and slot.end_datetime and (slot.end_datetime - slot.start_datetime).total_seconds() / 3600.0 < 24:
slot.allocation_type = 'planning'
else:
slot.allocation_type = 'forecast'
@api.depends('start_datetime', 'end_datetime', 'employee_id.resource_calendar_id', 'allocated_percentage')
def _compute_allocated_hours(self):
for slot in self:
if slot.start_datetime and slot.end_datetime:
percentage = slot.allocated_percentage / 100.0 or 1
if slot.allocation_type == 'planning' and slot.start_datetime and slot.end_datetime:
slot.allocated_hours = (slot.end_datetime - slot.start_datetime).total_seconds() * percentage / 3600.0
else:
if slot.employee_id:
slot.allocated_hours = slot.employee_id._get_work_days_data(slot.start_datetime, slot.end_datetime, compute_leaves=True)['hours'] * percentage
else:
slot.allocated_hours = 0.0
@api.depends('start_datetime', 'end_datetime', 'employee_id')
def _compute_working_days_count(self):
for slot in self:
if slot.employee_id:
slot.working_days_count = ceil(slot.employee_id._get_work_days_data(slot.start_datetime, slot.end_datetime, compute_leaves=True)['days'])
else:
slot.working_days_count = 0
@api.depends('start_datetime', 'end_datetime', 'employee_id')
def _compute_overlap_slot_count(self):
if self.ids:
self.flush(['start_datetime', 'end_datetime', 'employee_id'])
query = """
SELECT S1.id,count(*) FROM
planning_slot S1, planning_slot S2
WHERE
S1.start_datetime < S2.end_datetime and S1.end_datetime > S2.start_datetime and S1.id <> S2.id and S1.employee_id = S2.employee_id
GROUP BY S1.id;
"""
self.env.cr.execute(query, (tuple(self.ids),))
overlap_mapping = dict(self.env.cr.fetchall())
for slot in self:
slot.overlap_slot_count = overlap_mapping.get(slot.id, 0)
else:
self.overlap_slot_count = 0
@api.depends('role_id')
def _compute_template_autocomplete_ids(self):
domain = []
if self.role_id:
domain = [('role_id', '=', self.role_id.id)]
self.template_autocomplete_ids = self.env['planning.slot.template'].search(domain, order='start_time', limit=10)
@api.depends('recurrency_id')
def _compute_repeat(self):
for slot in self:
if slot.recurrency_id:
slot.repeat = True
slot.repeat_interval = slot.recurrency_id.repeat_interval
slot.repeat_until = slot.recurrency_id.repeat_until
slot.repeat_type = slot.recurrency_id.repeat_type
else:
slot.repeat = False
slot.repeat_interval = False
slot.repeat_until = False
slot.repeat_type = False
def _inverse_repeat(self):
for slot in self:
if slot.repeat and not slot.recurrency_id.id: # create the recurrence
recurrency_values = {
'repeat_interval': slot.repeat_interval,
'repeat_until': slot.repeat_until if slot.repeat_type == 'until' else False,
'repeat_type': slot.repeat_type,
'company_id': slot.company_id.id,
}
recurrence = self.env['planning.recurrency'].create(recurrency_values)
slot.recurrency_id = recurrence
slot.recurrency_id._repeat_slot()
# user wants to delete the recurrence
# here we also check that we don't delete by mistake a slot of which the repeat parameters have been changed
elif not slot.repeat and slot.recurrency_id.id and (
slot.repeat_type == slot.recurrency_id.repeat_type and
slot.repeat_until == slot.recurrency_id.repeat_until and
slot.repeat_interval == slot.recurrency_id.repeat_interval
):
slot.recurrency_id._delete_slot(slot.end_datetime)
slot.recurrency_id.unlink() # will set recurrency_id to NULL
def _inverse_template_creation(self):
values_list = []
existing_values = []
for slot in self:
if slot.template_creation:
values_list.append(slot._prepare_template_values())
# Here we check if there's already a template w/ the same data
existing_templates = self.env['planning.slot.template'].read_group([], ['role_id', 'start_time', 'duration'], ['role_id', 'start_time', 'duration'], limit=None, lazy=False)
if len(existing_templates):
for element in existing_templates:
role_id = element['role_id'][0] if element.get('role_id') else False
existing_values.append({'role_id': role_id, 'start_time': element['start_time'], 'duration': element['duration']})
self.env['planning.slot.template'].create([x for x in values_list if x not in existing_values])
@api.onchange('employee_id')
def _onchange_employee_id(self):
if self.employee_id:
start = self.start_datetime or datetime.combine(fields.Datetime.now(), datetime.min.time())
end = self.end_datetime or datetime.combine(fields.Datetime.now(), datetime.max.time())
work_interval = self.employee_id.resource_id._get_work_interval(start, end)
start_datetime, end_datetime = work_interval[self.employee_id.resource_id]
#start_datetime, end_datetime = work_interval[self.employee_id.resource_id.id]
if start_datetime:
self.start_datetime = start_datetime.astimezone(pytz.utc).replace(tzinfo=None)
if end_datetime:
self.end_datetime = end_datetime.astimezone(pytz.utc).replace(tzinfo=None)
# Set default role if the role field is empty
if not self.role_id and self.employee_id.sudo().planning_role_id:
self.role_id = self.employee_id.sudo().planning_role_id
@api.onchange('start_datetime', 'end_datetime', 'employee_id')
def _onchange_dates(self):
if self.employee_id and self.is_published:
self.publication_warning = True
@api.onchange('template_creation')
def _onchange_template_autocomplete_ids(self):
templates = self.env['planning.slot.template'].search([], order='start_time', limit=10)
if templates:
if not self.template_creation:
self.template_autocomplete_ids = templates
else:
self.template_autocomplete_ids = False
else:
self.template_autocomplete_ids = False
@api.onchange('template_id')
def _onchange_template_id(self):
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
if self.template_id and self.start_datetime:
h, m = divmod(self.template_id.start_time, 1)
start = pytz.utc.localize(self.start_datetime).astimezone(user_tz)
start = start.replace(hour=int(h), minute=int(m * 60))
self.start_datetime = start.astimezone(pytz.utc).replace(tzinfo=None)
h, m = divmod(self.template_id.duration, 1)
delta = timedelta(hours=int(h), minutes=int(m * 60))
self.end_datetime = fields.Datetime.to_string(self.start_datetime + delta)
self.role_id = self.template_id.role_id
@api.onchange('repeat')
def _onchange_default_repeat_values(self):
""" When checking the `repeat` flag on an existing record, the values of recurring fields are `False`. This onchange
restore the default value for usability purpose.
"""
recurrence_fields = ['repeat_interval', 'repeat_until', 'repeat_type']
default_values = self.default_get(recurrence_fields)
for fname in recurrence_fields:
self[fname] = default_values.get(fname)
# ----------------------------------------------------
# ORM overrides
# ----------------------------------------------------
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
result = super(Planning, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
view_type = self.env.context.get('view_type')
if 'employee_id' in groupby and view_type == 'gantt':
# Always prepend 'Undefined Employees' (will be printed 'Open Shifts' when called by the frontend)
d = {}
for field in fields:
d.update({field: False})
result.insert(0, d)
return result
def name_get(self):
group_by = self.env.context.get('group_by', [])
field_list = [fname for fname in self._name_get_fields() if fname not in group_by][:2] # limit to 2 labels
result = []
for slot in self:
# label part, depending on context `groupby`
name = ' - '.join([self._fields[fname].convert_to_display_name(slot[fname], slot) for fname in field_list if slot[fname]])
# date / time part
destination_tz = pytz.timezone(self.env.user.tz or 'UTC')
start_datetime = pytz.utc.localize(slot.start_datetime).astimezone(destination_tz).replace(tzinfo=None)
end_datetime = pytz.utc.localize(slot.end_datetime).astimezone(destination_tz).replace(tzinfo=None)
if slot.end_datetime - slot.start_datetime <= timedelta(hours=24): # shift on a single day
name = '%s - %s %s' % (
format_time(self.env, start_datetime.time(), time_format='short'),
format_time(self.env, end_datetime.time(), time_format='short'),
name
)
else:
name = '%s - %s %s' % (
start_datetime.date(),
end_datetime.date(),
name
)
# add unicode bubble to tell there is a note
if slot.name:
name = u'%s \U0001F4AC' % name
result.append([slot.id, name])
return result
@api.model
def create(self, vals):
if not vals.get('company_id') and vals.get('employee_id'):
vals['company_id'] = self.env['hr.employee'].browse(vals.get('employee_id')).company_id.id
if not vals.get('company_id'):
vals['company_id'] = self.env.company.id
return super().create(vals)
def write(self, values):
# detach planning entry from recurrency
if any(fname in values.keys() for fname in self._get_fields_breaking_recurrency()) and not values.get('recurrency_id'):
values.update({'recurrency_id': False})
# warning on published shifts
if 'publication_warning' not in values and (set(values.keys()) & set(self._get_fields_breaking_publication())):
values['publication_warning'] = True
result = super(Planning, self).write(values)
# recurrence
if any(key in ('repeat', 'repeat_type', 'repeat_until', 'repeat_interval') for key in values):
# User is trying to change this record's recurrence so we delete future slots belonging to recurrence A
# and we create recurrence B from now on w/ the new parameters
for slot in self:
if slot.recurrency_id and values.get('repeat') is None:
recurrency_values = {
'repeat_interval': values.get('repeat_interval') or slot.recurrency_id.repeat_interval,
'repeat_until': values.get('repeat_until') if values.get('repeat_type') == 'until' else False,
'repeat_type': values.get('repeat_type'),
'company_id': slot.company_id.id,
}
# Kill recurrence A
slot.recurrency_id.repeat_type = 'until'
slot.recurrency_id.repeat_until = slot.start_datetime
slot.recurrency_id._delete_slot(slot.end_datetime)
# Create recurrence B
recurrence = slot.env['planning.recurrency'].create(recurrency_values)
slot.recurrency_id = recurrence
slot.recurrency_id._repeat_slot()
return result
# ----------------------------------------------------
# Actions
# ----------------------------------------------------
def action_unlink(self):
self.unlink()
return {'type': 'ir.actions.act_window_close'}
def action_see_overlaping_slots(self):
domain_map = self._get_overlap_domain()
return {
'type': 'ir.actions.act_window',
'res_model': 'planning.slot',
'name': _('Shifts in conflict'),
'view_mode': 'gantt,list,form',
'domain': domain_map[self.id],
'context': {
'initialDate': min([slot.start_datetime for slot in self.search(domain_map[self.id])])
}
}
def action_self_assign(self):
""" Allow planning user to self assign open shift. """
self.ensure_one()
# user must at least 'read' the shift to self assign (Prevent any user in the system (portal, ...) to assign themselves)
if not self.check_access_rights('read', raise_exception=False):
raise AccessError(_("You don't the right to self assign."))
if self.employee_id:
raise UserError(_("You can not assign yourself to an already assigned shift."))
return self.sudo().write({'employee_id': self.env.user.employee_id.id if self.env.user.employee_id else False})
def action_self_unassign(self):
""" Allow planning user to self unassign from a shift, if the feature is activated """
self.ensure_one()
# The following condition will check the read access on planning.slot, and that user must at least 'read' the
# shift to self unassign. Prevent any user in the system (portal, ...) to unassign any shift.
if not self.allow_self_unassign:
raise UserError(_("The company does not allow you to self unassign."))
if self.employee_id != self.env.user.employee_id:
raise UserError(_("You can not unassign another employee than yourself."))
return self.sudo().write({'employee_id': False})
# ----------------------------------------------------
# Gantt view
# ----------------------------------------------------
@api.model
def gantt_unavailability(self, start_date, end_date, scale, group_bys=None, rows=None):
start_datetime = fields.Datetime.from_string(start_date)
end_datetime = fields.Datetime.from_string(end_date)
employee_ids = set()
# function to "mark" top level rows concerning employees
# the propagation of that item to subrows is taken care of in the traverse function below
def tag_employee_rows(rows):
for row in rows:
group_bys = row.get('groupedBy')
res_id = row.get('resId')
if group_bys:
# if employee_id is the first grouping attribute, we mark the row
if group_bys[0] == 'employee_id' and res_id:
employee_id = res_id
employee_ids.add(employee_id)
row['employee_id'] = employee_id
# else we recursively traverse the rows where employee_id appears in the group_by
elif 'employee_id' in group_bys:
tag_employee_rows(row.get('rows'))
tag_employee_rows(rows)
employees = self.env['hr.employee'].browse(employee_ids)
leaves_mapping = employees.mapped('resource_id')._get_unavailable_intervals(start_datetime, end_datetime)
# function to recursively replace subrows with the ones returned by func
def traverse(func, row):
new_row = dict(row)
if new_row.get('employee_id'):
for sub_row in new_row.get('rows'):
sub_row['employee_id'] = new_row['employee_id']
new_row['rows'] = [traverse(func, row) for row in new_row.get('rows')]
return func(new_row)
cell_dt = timedelta(hours=1) if scale in ['day', 'week'] else timedelta(hours=12)
# for a single row, inject unavailability data
def inject_unavailability(row):
new_row = dict(row)
if row.get('employee_id'):
employee_id = self.env['hr.employee'].browse(row.get('employee_id'))
if employee_id:
# remove intervals smaller than a cell, as they will cause half a cell to turn grey
# ie: when looking at a week, a employee start everyday at 8, so there is a unavailability
# like: 2019-05-22 20:00 -> 2019-05-23 08:00 which will make the first half of the 23's cell grey
notable_intervals = filter(lambda interval: interval[1] - interval[0] >= cell_dt, leaves_mapping[employee_id.resource_id.id])
new_row['unavailabilities'] = [{'start': interval[0], 'stop': interval[1]} for interval in notable_intervals]
return new_row
return [traverse(inject_unavailability, row) for row in rows]
# ----------------------------------------------------
# Period Duplication
# ----------------------------------------------------
@api.model
def action_copy_previous_week(self, date_start_week):
date_end_copy = datetime.strptime(date_start_week, DEFAULT_SERVER_DATETIME_FORMAT)
date_start_copy = date_end_copy - relativedelta(days=7)
domain = [
('start_datetime', '>=', date_start_copy),
('end_datetime', '<=', date_end_copy),
('recurrency_id', '=', False),
('was_copied', '=', False)
]
slots_to_copy = self.search(domain)
new_slot_values = []
for slot in slots_to_copy:
if not slot.was_copied:
values = slot.copy_data()[0]
if values.get('start_datetime'):
values['start_datetime'] += relativedelta(days=7)
if values.get('end_datetime'):
values['end_datetime'] += relativedelta(days=7)
values['is_published'] = False
new_slot_values.append(values)
slots_to_copy.write({'was_copied': True})
return self.create(new_slot_values)
# ----------------------------------------------------
# Sending Shifts
# ----------------------------------------------------
def action_send(self):
group_planning_user = self.env.ref('planning.group_planning_user')
template = self.env.ref('planning.email_template_slot_single')
# update context to build a link for view in the slot
view_context = dict(self._context)
view_context.update({
'menu_id': str(self.env.ref('planning.planning_menu_root').id),
'action_id': str(self.env.ref('planning.planning_action_open_shift').id),
'dbname': self.env.cr.dbname,
'render_link': self.employee_id.user_id and self.employee_id.user_id in group_planning_user.users,
'unavailable_path': '/planning/myshifts/',
})
slot_template = template.with_context(view_context)
mails_to_send = self.env['mail.mail']
for slot in self:
if slot.employee_id and slot.employee_id.work_email:
mail_id = slot_template.with_context(view_context).send_mail(slot.id, notif_layout='mail.mail_notification_light')
current_mail = self.env['mail.mail'].browse(mail_id)
mails_to_send |= current_mail
if mails_to_send:
mails_to_send.send()
self.write({
'is_published': True,
'publication_warning': False,
})
return mails_to_send
def action_publish(self):
self.write({
'is_published': True,
'publication_warning': False,
})
return True
# ----------------------------------------------------
# Business Methods
# ----------------------------------------------------
def _name_get_fields(self):
""" List of fields that can be displayed in the name_get """
return ['employee_id', 'role_id']
def _get_fields_breaking_publication(self):
""" Fields list triggering the `publication_warning` to True when updating shifts """
return [
'employee_id',
'start_datetime',
'end_datetime',
'role_id',
]
def _get_fields_breaking_recurrency(self):
"""Returns the list of field which when changed should break the relation of the forecast
with it's recurrency
"""
return [
'employee_id',
'role_id',
]
def _get_overlap_domain(self):
""" get overlapping domain for current shifts
:returns dict : map with slot id as key and domain as value
"""
domain_mapping = {}
for slot in self:
domain_mapping[slot.id] = [
'&',
'&',
('employee_id', '!=', False),
('employee_id', '=', slot.employee_id.id),
'&',
('start_datetime', '<', slot.end_datetime),
('end_datetime', '>', slot.start_datetime)
]
return domain_mapping
def _prepare_template_values(self):
""" extract values from shift to create a template """
# compute duration w/ tzinfo otherwise DST will not be taken into account
destination_tz = pytz.timezone(self.env.user.tz or 'UTC')
start_datetime = pytz.utc.localize(self.start_datetime).astimezone(destination_tz)
end_datetime = pytz.utc.localize(self.end_datetime).astimezone(destination_tz)
# convert time delta to hours and minutes
total_seconds = (end_datetime - start_datetime).total_seconds()
m, s = divmod(total_seconds, 60)
h, m = divmod(m, 60)
return {
'start_time': start_datetime.hour + start_datetime.minute / 60.0,
'duration': h + (m / 60.0),
'role_id': self.role_id.id
}
def _read_group_employee_id(self, employees, domain, order):
if self._context.get('planning_expand_employee'):
return self.env['planning.slot'].search([('create_date', '>', datetime.now() - timedelta(days=30))]).mapped('employee_id')
return employees
class PlanningRole(models.Model):
_name = 'planning.role'
_description = "Planning Role"
_order = 'name,id desc'
_rec_name = 'name'
name = fields.Char('Name', required=True)
color = fields.Integer("Color", default=0)
class PlanningPlanning(models.Model):
_name = 'planning.planning'
_description = 'Planning sent by email'
@api.model
def _default_access_token(self):
return str(uuid.uuid4())
start_datetime = fields.Datetime("Start Date", required=True)
end_datetime = fields.Datetime("Stop Date", required=True)
include_unassigned = fields.Boolean("Includes Open shifts", default=True)
access_token = fields.Char("Security Token", default=_default_access_token, required=True, copy=False, readonly=True)
last_sent_date = fields.Datetime("Last sent date")
slot_ids = fields.Many2many('planning.slot', "Shifts", compute='_compute_slot_ids')
company_id = fields.Many2one('res.company', "Company", required=True, default=lambda self: self.env.company)
_sql_constraints = [
('check_start_date_lower_stop_date', 'CHECK(end_datetime > start_datetime)', 'Planning end date should be greater than its start date'),
]
@api.depends('start_datetime', 'end_datetime')
def _compute_display_name(self):
""" This override is need to have a human readable string in the email light layout header (`message.record_name`) """
for planning in self:
number_days = (planning.end_datetime - planning.start_datetime).days
planning.display_name = _('Planning of %s days') % (number_days,)
@api.depends('start_datetime', 'end_datetime', 'include_unassigned')
def _compute_slot_ids(self):
domain_map = self._get_domain_slots()
for planning in self:
domain = domain_map[planning.id]
if not planning.include_unassigned:
domain = expression.AND([domain, [('employee_id', '!=', False)]])
planning.slot_ids = self.env['planning.slot'].search(domain)
# ----------------------------------------------------
# Business Methods
# ----------------------------------------------------
def _get_domain_slots(self):
result = {}
for planning in self:
domain = ['&', '&', ('start_datetime', '<=', planning.end_datetime), ('end_datetime', '>', planning.start_datetime), ('company_id', '=', planning.company_id.id)]
result[planning.id] = domain
return result
def send_planning(self, message=None):
email_from = self.env.user.email or self.env.user.company_id.email or ''
sent_slots = self.env['planning.slot']
for planning in self:
# prepare planning urls, recipient employees, ...
slots = planning.slot_ids
slots_open = slots.filtered(lambda slot: not slot.employee_id)
# extract planning URLs
employees = slots.mapped('employee_id')
employee_url_map = employees.sudo()._planning_get_url(planning)
# send planning email template with custom domain per employee
template = self.env.ref('planning.email_template_planning_planning', raise_if_not_found=False)
template_context = {
'slot_unassigned_count': len(slots_open),
'slot_total_count': len(slots),
'message': message,
}
if template:
# /!\ For security reason, we only given the public employee to render mail template
for employee in self.env['hr.employee.public'].browse(employees.ids):
if employee.work_email:
template_context['employee'] = employee
template_context['planning_url'] = employee_url_map[employee.id]
template.with_context(**template_context).send_mail(planning.id, email_values={'email_to': employee.work_email, 'email_from': email_from}, notif_layout='mail.mail_notification_light')
sent_slots |= slots
# mark as sent
self.write({'last_sent_date': fields.Datetime.now()})
sent_slots.write({
'is_published': True,
'publication_warning': False
})
return True

View File

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from odoo import api, fields, models, _
from odoo.tools import get_timedelta
from odoo.exceptions import ValidationError
class PlanningRecurrency(models.Model):
_name = 'planning.recurrency'
_description = "Planning Recurrence"
slot_ids = fields.One2many('planning.slot', 'recurrency_id', string="Related planning entries")
repeat_interval = fields.Integer("Repeat every", default=1, required=True)
repeat_type = fields.Selection([('forever', 'Forever'), ('until', 'Until')], string='weeks', default='forever')
repeat_until = fields.Datetime(string="Repeat until", help="Up to which date should the plannings be repeated")
last_generated_end_datetime = fields.Datetime("Last Generated End Date", readonly=True)
company_id = fields.Many2one('res.company', string="Company", readonly=True, required=True, default=lambda self: self.env.company)
_sql_constraints = [
('check_repeat_interval_positive', 'CHECK(repeat_interval >= 1)', 'Recurrency repeat interval should be at least 1'),
('check_until_limit', "CHECK((repeat_type = 'until' AND repeat_until IS NOT NULL) OR (repeat_type != 'until'))", 'A recurrence repeating itself until a certain date must have its limit set'),
]
@api.constrains('company_id', 'slot_ids')
def _check_multi_company(self):
for recurrency in self:
if not all(recurrency.company_id == planning.company_id for planning in recurrency.slot_ids):
raise ValidationError(_('An shift must be in the same company as its recurrency.'))
def name_get(self):
result = []
for recurrency in self:
if recurrency.repeat_type == 'forever':
name = _('Forever, every %s week(s)') % (recurrency.repeat_interval,)
else:
name = _('Every %s week(s) until %s') % (recurrency.repeat_interval, recurrency.repeat_until)
result.append([recurrency.id, name])
return result
@api.model
def _cron_schedule_next(self):
companies = self.env['res.company'].search([])
now = fields.Datetime.now()
stop_datetime = None
for company in companies:
delta = get_timedelta(company.planning_generation_interval, 'month')
recurrencies = self.search([
'&',
'&',
('company_id', '=', company.id),
('last_generated_end_datetime', '<', now + delta),
'|',
('repeat_until', '=', False),
('repeat_until', '>', now - delta),
])
recurrencies._repeat_slot(now + delta)
def _repeat_slot(self, stop_datetime=False):
for recurrency in self:
slot = self.env['planning.slot'].search([('recurrency_id', '=', recurrency.id)], limit=1, order='start_datetime DESC')
if slot:
# find the end of the recurrence
recurrence_end_dt = False
if recurrency.repeat_type == 'until':
recurrence_end_dt = recurrency.repeat_until
# find end of generation period (either the end of recurrence (if this one ends before the cron period), or the given `stop_datetime` (usually the cron period))
if not stop_datetime:
stop_datetime = fields.Datetime.now() + get_timedelta(recurrency.company_id.planning_generation_interval, 'month')
range_limit = min([dt for dt in [recurrence_end_dt, stop_datetime] if dt])
# generate recurring slots
recurrency_delta = get_timedelta(recurrency.repeat_interval, 'week')
next_start = slot.start_datetime + recurrency_delta
slot_values_list = []
while next_start < range_limit:
slot_values = slot.copy_data({
'start_datetime': next_start,
'end_datetime': next_start + (slot.end_datetime - slot.start_datetime),
'recurrency_id': recurrency.id,
'company_id': recurrency.company_id.id,
'repeat': True,
'is_published': False
})[0]
slot_values_list.append(slot_values)
next_start = next_start + recurrency_delta
self.env['planning.slot'].create(slot_values_list)
recurrency.write({'last_generated_end_datetime': next_start - recurrency_delta})
else:
recurrency.unlink()
def _delete_slot(self, start_datetime):
slots = self.env['planning.slot'].search([('recurrency_id', 'in', self.ids), ('start_datetime', '>=', start_datetime), ('is_published', '=', False)])
slots.unlink()

View File

@ -0,0 +1,35 @@
import math
from datetime import datetime, timedelta, time, date
from odoo import api, fields, models, _
from odoo.tools import format_time
class PlanningTemplate(models.Model):
_name = 'planning.slot.template'
_description = "Shift Template"
role_id = fields.Many2one('planning.role', string="Role")
start_time = fields.Float('Start hour', default=0)
duration = fields.Float('Duration (hours)', default=0)
_sql_constraints = [
('check_start_time_lower_than_24', 'CHECK(start_time <= 24)', 'You cannot have a start hour greater than 24'),
('check_start_time_positive', 'CHECK(start_time >= 0)', 'Start hour must be a positive number'),
('check_duration_positive', 'CHECK(duration >= 0)', 'You cannot have a negative duration')
]
def name_get(self):
result = []
for shift_template in self:
start_time = time(hour=int(shift_template.start_time), minute=round(math.modf(shift_template.start_time)[0] / (1 / 60.0)))
duration = timedelta(hours=int(shift_template.duration), minutes=round(math.modf(shift_template.duration)[0] / (1 / 60.0)))
end_time = datetime.combine(date.today(), start_time) + duration
name = '%s - %s %s %s' % (
format_time(shift_template.env, start_time, time_format='h a' if start_time.minute == 0 else 'h:mm a'),
format_time(shift_template.env, end_time.time(), time_format='h a' if end_time.minute == 0 else 'h:mm a'),
'(%s days span)' % (duration.days + 1) if duration.days > 0 else '',
shift_template.role_id.name if shift_template.role_id.name is not False else ''
)
result.append([shift_template.id, name])
return result

13
planning/models/res_company.py Executable file
View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class Company(models.Model):
_inherit = 'res.company'
planning_generation_interval = fields.Integer("Rate of shift generation", required=True, readonly=False, default=3, help="Delay for the rate at which recurring shift should be generated in month")
planning_allow_self_unassign = fields.Boolean("Can employee un-assign themselves?", default=False,
help="Let your employees un-assign themselves from shifts when unavailable")

View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
planning_generation_interval = fields.Integer("Rate of shift generation", required=True,
related="company_id.planning_generation_interval", readonly=False, help="Delay for the rate at which recurring shifts should be generated")
planning_allow_self_unassign = fields.Boolean("Allow Unassignment", default=False, readonly=False,
related="company_id.planning_allow_self_unassign", help="Let your employees un-assign themselves from shifts when unavailable")

4
planning/report/__init__.py Executable file
View File

@ -0,0 +1,4 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import planning_report

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import tools
from odoo import fields, models
class PlanningReport(models.Model):
_name = "planning.slot.report.analysis"
_description = "Planning Statistics"
_auto = False
_rec_name = 'entry_date'
_order = 'entry_date desc'
entry_date = fields.Date('Date', readonly=True)
employee_id = fields.Many2one('hr.employee', 'Employee', readonly=True)
role_id = fields.Many2one('planning.role', string='Role', readonly=True)
company_id = fields.Many2one('res.company', string='Company', readonly=True)
number_hours = fields.Float("Allocated Hours", readonly=True)
def init(self):
tools.drop_view_if_exists(self.env.cr, self._table)
# We dont take ressource into account as we would need to move
# the generate_series() in the FROM and use a condition on the
# join to exclude holidays like it's done in timesheet.
self.env.cr.execute("""
CREATE or REPLACE VIEW %s as (
(
SELECT
p.id,
generate_series(start_datetime,end_datetime,'1 day'::interval) entry_date,
p.role_id AS role_id,
p.company_id AS company_id,
p.employee_id AS employee_id,
p.allocated_hours / ((p.end_datetime::date - p.start_datetime::date)+1) AS number_hours
FROM
planning_slot p
)
)
""" % (self._table,))

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="planning_slot_report_view_pivot" model="ir.ui.view">
<field name="name">planning.slot.report.pivot</field>
<field name="model">planning.slot.report.analysis</field>
<field name="arch" type="xml">
<pivot string="Planning Analysis">
<field name="entry_date" interval="month" type="col"/>
<field name="employee_id" type="row"/>
<field name="number_hours" type="measure"/>
</pivot>
</field>
</record>
<record id="planning_slot_report_view_graph" model="ir.ui.view">
<field name="name">planning.slot.report.graph</field>
<field name="model">planning.slot.report.analysis</field>
<field name="arch" type="xml">
<graph string="Planning Analysis" type="bar">
<field name="entry_date" type="row"/>
<field name="number_hours" type="measure"/>
</graph>
</field>
</record>
<record id="planning_slot_report_view_search" model="ir.ui.view">
<field name="name">planning.slot.report.search</field>
<field name="model">planning.slot.report.analysis</field>
<field name="arch" type="xml">
<search string="Planning Analysis">
<field name="employee_id" filter_domain="[('employee_id', 'ilike', self)]"/>
<field name="role_id" filter_domain="[('role_id', 'ilike', self)]"/>
<field name="entry_date"/>
<filter string="Date" name="year" date="entry_date"/>
<separator/>
<group expand="1" string="Group By">
<filter string="Employee" name="resource_employee" context="{'group_by':'employee_id'}"/>
<filter string="Role" name="resource_role" context="{'group_by':'role_id'}"/>
<filter string="Company" name="resource_company" context="{'group_by':'company_id'}"
groups="base.group_multi_company"/>
<separator/>
<filter string="Date" name="date_month" context="{'group_by':'entry_date:month'}"/>
</group>
</search>
</field>
</record>
<record id="planning_action_analysis" model="ir.actions.act_window">
<field name="name">Shifts Analysis</field>
<field name="res_model">planning.slot.report.analysis</field>
<field name="view_mode">pivot,graph</field>
</record>
<!-- Filter for graph view -->
<record id="planning_filter_by_employee" model="ir.filters">
<field name="name">Hours per Employee</field>
<field name="model_id">planning.slot.report.analysis</field>
<field name="user_id" eval="False"/>
<field name="is_default" eval="True"/>
<field name="context">{
'pivot_measures': ['number_hours'],
'pivot_column_groupby': ['entry_date:month'],
'pivot_row_groupby': ['employee_id'],
'graph_measures': ['number_hours'],
'graph_column_groupby': ['entry_date:month'],
'graph_row_groupby': ['employee_id']
}</field>
<field name="action_id" ref="planning_action_analysis"/>
</record>
</odoo>

View File

@ -0,0 +1,11 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_planning_slot_user,planning.slot.user,planning.model_planning_slot,planning.group_planning_user,1,0,0,0
access_planning_slot_manager,planning.slot.manager,planning.model_planning_slot,planning.group_planning_manager,1,1,1,1
access_planning_role_user,planning.role.user,planning.model_planning_role,planning.group_planning_user,1,0,0,0
access_planning_role_manager,planning.role.manager,planning.model_planning_role,planning.group_planning_manager,1,1,1,1
access_planning_recurrency_user,planning.recurrency.user,planning.model_planning_recurrency,planning.group_planning_user,1,0,0,0
access_planning_recurrency_manager,planning.recurrency.manager,planning.model_planning_recurrency,planning.group_planning_manager,1,1,1,1
access_planning_planning,access_planning_planning,model_planning_planning,planning.group_planning_manager,1,1,1,1
access_planning_slot_template_user,planning.slot.template.user,planning.model_planning_slot_template,planning.group_planning_user,1,0,0,0
access_planning_slot_template_manager,planning.slot.template.manager,planning.model_planning_slot_template,planning.group_planning_manager,1,1,1,1
access_planning_slot_report_analysis,access_planning_slot_report_analysis,model_planning_slot_report_analysis,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_planning_slot_user planning.slot.user planning.model_planning_slot planning.group_planning_user 1 0 0 0
3 access_planning_slot_manager planning.slot.manager planning.model_planning_slot planning.group_planning_manager 1 1 1 1
4 access_planning_role_user planning.role.user planning.model_planning_role planning.group_planning_user 1 0 0 0
5 access_planning_role_manager planning.role.manager planning.model_planning_role planning.group_planning_manager 1 1 1 1
6 access_planning_recurrency_user planning.recurrency.user planning.model_planning_recurrency planning.group_planning_user 1 0 0 0
7 access_planning_recurrency_manager planning.recurrency.manager planning.model_planning_recurrency planning.group_planning_manager 1 1 1 1
8 access_planning_planning access_planning_planning model_planning_planning planning.group_planning_manager 1 1 1 1
9 access_planning_slot_template_user planning.slot.template.user planning.model_planning_slot_template planning.group_planning_user 1 0 0 0
10 access_planning_slot_template_manager planning.slot.template.manager planning.model_planning_slot_template planning.group_planning_manager 1 1 1 1
11 access_planning_slot_report_analysis access_planning_slot_report_analysis model_planning_slot_report_analysis base.group_user 1 1 1 1

View File

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<record id="group_planning_user" model="res.groups">
<field name="name">User</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="category_id" ref="base.module_category_human_resources_planning"/>
</record>
<record id="group_planning_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="base.module_category_human_resources_planning"/>
<field name="implied_ids" eval="[(4, ref('group_planning_user'))]"/>
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
<record id="group_planning_show_percentage" model="res.groups">
<field name="name">Show Allocated Percentage</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
</data>
<data noupdate="1">
<record id="planning_rule_user_modify" model="ir.rule">
<field name="name">Planning: user can modify their own shifts</field>
<field name="model_id" ref="planning.model_planning_slot"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
<field name="groups" eval="[(4,ref('group_planning_user'))]"/>
</record>
<record id="planning_rule_user_is_published" model="ir.rule">
<field name="name">Planning: user can only see published shifts</field>
<field name="model_id" ref="planning.model_planning_slot"/>
<field name="domain_force">[('is_published', '=', True)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
<field name="groups" eval="[(4,ref('group_planning_user'))]"/>
</record>
<record id="planning_rule_manager" model="ir.rule">
<field name="name">Planning: manager can create/update/delete all planning entries</field>
<field name="model_id" ref="planning.model_planning_slot"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
<field name="groups" eval="[(4,ref('group_planning_manager'))]"/>
</record>
<record id="planning_slot_rule_multi_company" model="ir.rule">
<field name="name">Planning Shift multi-company</field>
<field name="model_id" ref="planning.model_planning_slot"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
<record id="planning_recurrency_rule_multi_company" model="ir.rule">
<field name="name">Planning Recurrence multi-company</field>
<field name="model_id" ref="planning.model_planning_recurrency"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
<record id="planning_planning_rule_multi_company" model="ir.rule">
<field name="name">Planning Planning multi-company</field>
<field name="model_id" ref="planning.model_planning_planning"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
<record id="planning_planning_rule_multi_company" model="ir.rule">
<field name="name">Planning Analysis multi-company</field>
<field name="model_id" ref="planning.model_planning_slot_report_analysis"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#269396"/><stop offset="100%" stop-color="#218689"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><g><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/></g><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><path fill="#393939" d="M3.935 69c-1.3 0-4-.4-4-3.5L0 27.1l13.33-9.8 36.4 5.9L40 28.7l5.6.1 11.8 4.4-3.825 7.42 3.025 9.807L44.235 69h-40.3z" opacity=".32"/><g fill="#000" fill-rule="nonzero" opacity=".3"><path d="M48.1 40.6c-5.3 0-9.6 4.3-9.6 9.7 0 5.4 4.3 9.7 9.6 9.7s9.6-4.3 9.6-9.7c0-5.4-4.3-9.7-9.6-9.7zm2 10.8c0 .1-.1.3-.2.4l-3.4 2.5c-.2.2-.5.1-.7-.1l-1.1-1.5c-.2-.2-.1-.5.1-.7l2.5-1.8v-5.4c0-.3.2-.5.5-.5h1.9c.3 0 .5.2.5.5v6.6h-.1zm-.7-25.5H14c-.6 0-1.2-.5-1.2-1.2v-3.8c0-.6.5-1.2 1.2-1.2h35.4c.6 0 1.2.5 1.2 1.2v3.8c-.1.6-.6 1.2-1.2 1.2zm6.8 11.2H20.8c-.6 0-1.2-.5-1.2-1.2v-3.8c0-.6.5-1.2 1.2-1.2h35.4c.6 0 1.2.5 1.2 1.2v3.8c-.1.7-.6 1.2-1.2 1.2zM35.473 47.3c0-1.9.41-3.6 1.227-5.1H15.518c-.49 0-.818.5-.818 1v4.1c0 .6.409 1 .818 1h20.037c-.082-.4-.082-.7-.082-1z"/><path d="M42.5 43.2v-1.3l-26.8 1.3c-.6 0-1 .5-1 1v4.1c0 .6.5 1 1 1h24.5c0-2.4.9-4.5 2.3-6.1z"/></g><path fill="#FFF" fill-rule="nonzero" d="M49.4 23.2H14c-.6 0-1.2-.5-1.2-1.2v-3.8c0-.6.5-1.2 1.2-1.2h35.4c.6 0 1.2.5 1.2 1.2V22c-.1.7-.6 1.2-1.2 1.2zm6.8 11.2H20.8c-.6 0-1.2-.5-1.2-1.2v-3.8c0-.6.5-1.2 1.2-1.2h35.4c.6 0 1.2.5 1.2 1.2v3.8c-.1.7-.6 1.2-1.2 1.2zm-18.5 6.1H15.527c-.496 0-.827.5-.827 1v4.1c0 .6.414 1 .827 1h20.27c0-2.3.745-4.5 1.903-6.1zm10.4-3.3c-5.3 0-9.6 4.3-9.6 9.7 0 5.4 4.3 9.7 9.6 9.7s9.6-4.3 9.6-9.7c0-5.4-4.3-9.7-9.6-9.7zm2 10.8c0 .1-.1.3-.2.4l-3.4 2.5c-.2.2-.5.1-.7-.1l-1.1-1.5c-.2-.2-.1-.5.1-.7l2.5-1.8v-5.4c0-.3.2-.5.5-.5h1.9c.3 0 .5.2.5.5V48h-.1z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,109 @@
odoo.define('web.FieldColorPicker', function (require) {
"use strict";
var basic_fields = require('web.basic_fields');
var FieldInteger = basic_fields.FieldInteger;
var field_registry = require('web.field_registry');
var core = require('web.core');
var QWeb = core.qweb;
var FieldColorPicker = FieldInteger.extend({
/**
* Prepares the rendering, since we are based on an input but not using it
* setting tagName after parent init force the widget to not render an input
*
* @override
*/
init: function () {
this._super.apply(this, arguments);
this.tagName = 'div';
},
/**
* Render the widget when it is edited.
*
* @override
*/
_renderEdit: function () {
this.$el.html(QWeb.render('web.ColorPicker'));
this._setupColorPicker();
this._highlightSelectedColor();
},
/**
* Render the widget when it is NOT edited.
*
* @override
*/
_renderReadonly: function () {
this.$el.html(QWeb.render('web.ColorPickerReadonly', {active_color: this.value,}));
this.$el.on('click', 'a', function(ev){ ev.preventDefault(); });
},
/**
* Render the kanban colors inside first ul element.
* This is the same template as in KanbanRecord.
*
* <a> elements click are bound to _onColorChanged
*
*/
_setupColorPicker: function () {
var $colorpicker = this.$('ul');
if (!$colorpicker.length) {
return;
}
$colorpicker.html(QWeb.render('KanbanColorPicker'));
$colorpicker.on('click', 'a', this._onColorChanged.bind(this));
},
/**
* Returns the widget value.
* Since NumericField is based on an input, but we don't use it,
* we override this function to use the internal value of the widget.
*
*
* @override
* @returns {string}
*/
_getValue: function (){
return this.value;
},
/**
* Listener in edit mode for click on a color.
* The actual color can be found in the data-color
* attribute of the target element.
*
* We re-render the widget after the update because
* the selected color has changed and it should
* be reflected in the ui.
*
* @param ev
*/
_onColorChanged: function(ev) {
ev.preventDefault();
var color = null;
if(ev.currentTarget && ev.currentTarget.dataset && ev.currentTarget.dataset.color){
color = ev.currentTarget.dataset.color;
}
if(color){
this.value = color;
this._onChange();
this._renderEdit();
}
},
/**
* Helper to modify the active color's style
* while in edit mode.
*
*/
_highlightSelectedColor: function(){
try{
$(this.$('li')[parseInt(this.value)]).css('border', '2px solid teal');
} catch(err) {
}
},
});
field_registry.add('color_picker', FieldColorPicker);
return FieldColorPicker;
});

View File

@ -0,0 +1,142 @@
odoo.define('planning.PlanningGanttController', function (require) {
'use strict';
var GanttController = require('web_gantt.GanttController');
var core = require('web.core');
var _t = core._t;
var confirmDialog = require('web.Dialog').confirm;
var dialogs = require('web.view_dialogs');
var QWeb = core.qweb;
var PlanningGanttController = GanttController.extend({
events: _.extend({}, GanttController.prototype.events, {
'click .o_gantt_button_copy_previous_week': '_onCopyWeekClicked',
'click .o_gantt_button_send_all': '_onSendAllClicked',
}),
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
* @param {jQueryElement} $node to which the buttons will be appended
*/
renderButtons: function ($node) {
if ($node) {
var state = this.model.get();
this.$buttons = $(QWeb.render('PlanningGanttView.buttons', {
groupedBy: state.groupedBy,
widget: this,
SCALES: this.SCALES,
activateScale: state.scale,
allowedScales: this.allowedScales,
activeActions: this.activeActions,
}));
this.$buttons.appendTo($node);
}
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Opens dialog to add/edit/view a record
* Override required to execute the reload of the gantt view when an action is performed on a
* single record.
*
* @private
* @param {integer|undefined} resID
* @param {Object|undefined} context
*/
_openDialog: function (resID, context) {
var self = this;
var record = resID ? _.findWhere(this.model.get().records, {id: resID,}) : {};
var title = resID ? record.display_name : _t("Open");
var dialog = new dialogs.FormViewDialog(this, {
title: _.str.sprintf(title),
res_model: this.modelName,
view_id: this.dialogViews[0][0],
res_id: resID,
readonly: !this.is_action_enabled('edit'),
deletable: this.is_action_enabled('edit') && resID,
context: _.extend({}, this.context, context),
on_saved: this.reload.bind(this, {}),
on_remove: this._onDialogRemove.bind(this, resID),
});
dialog.on('closed', this, function(ev){
// we reload as record can be created or modified (sent, unpublished, ...)
self.reload();
});
return dialog.open();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} ev
*/
_onCopyWeekClicked: function (ev) {
ev.preventDefault();
var state = this.model.get();
var self = this;
self._rpc({
model: self.modelName,
method: 'action_copy_previous_week',
args: [
self.model.convertToServerTime(state.startDate),
],
context: _.extend({}, self.context || {}),
})
.then(function(){
self.reload();
});
},
/**
* @private
* @param {MouseEvent} ev
*/
_onSendAllClicked: function (ev) {
ev.preventDefault();
var self = this;
var state = this.model.get();
var additional_context = _.extend({}, this.context, {
'default_start_datetime': this.model.convertToServerTime(state.startDate),
'default_end_datetime': this.model.convertToServerTime(state.stopDate),
'scale': state.scale,
'active_domain': this.model.domain,
'active_ids': this.model.get().records
});
return this.do_action('planning.planning_send_action', {
additional_context: additional_context,
on_close: function () {
self.reload();
}
});
},
/**
* @private
* @override
* @param {MouseEvent} ev
*/
_onScaleClicked: function (ev) {
this._super.apply(this, arguments);
var $button = $(ev.currentTarget);
var scale = $button.data('value');
if (scale !== 'week') {
this.$('.o_gantt_button_copy_previous_week').hide();
} else {
this.$('.o_gantt_button_copy_previous_week').show();
}
},
});
return PlanningGanttController;
});

View File

@ -0,0 +1,34 @@
odoo.define('planning.PlanningGanttModel', function (require) {
"use strict";
var GanttModel = require('web_gantt.GanttModel');
var _t = require('web.core')._t;
var PlanningGanttModel = GanttModel.extend({
/**
* @private
* @override
* @returns {Object[]}
*/
_generateRows: function (params) {
var rows = this._super(params);
// is the data grouped by?
if(params.groupedBy && params.groupedBy.length){
// in the last row is the grouped by field is null
if(rows && rows.length && rows[rows.length - 1] && !rows[rows.length - 1].resId){
// then make it the first one
rows.unshift(rows.pop());
}
}
// rename 'Undefined Employee' into 'Open Shifts'
_.each(rows, function(row){
if(row.groupedByField === 'employee_id' && !row.resId){
row.name = _t('Open Shifts');
}
});
return rows;
},
});
return PlanningGanttModel;
});

View File

@ -0,0 +1,21 @@
odoo.define('planning.PlanningGanttView', function (require) {
'use strict';
var GanttView = require('web_gantt.GanttView');
var PlanningGanttController = require('planning.PlanningGanttController');
var PlanningGanttModel = require('planning.PlanningGanttModel');
var view_registry = require('web.view_registry');
var PlanningGanttView = GanttView.extend({
config: _.extend({}, GanttView.prototype.config, {
Controller: PlanningGanttController,
Model: PlanningGanttModel,
}),
});
view_registry.add('planning_gantt', PlanningGanttView);
return PlanningGanttView;
});

View File

@ -0,0 +1,41 @@
odoo.define('planning.tour', function (require) {
"use strict";
var core = require('web.core');
var tour = require('web_tour.tour');
var _t = core._t;
tour.register('planning_tour', {
'skip_enabled': true,
}, [{
trigger: '.o_app[data-menu-xmlid="planning.planning_menu_root"]',
content: _t("Let's start managing your employees' schedule!"),
position: 'bottom',
}, {
trigger: ".o_menu_header_lvl_1[data-menu-xmlid='planning.planning_menu_schedule']",
content: _t("Use this menu to visualize and schedule shifts"),
position: "bottom"
}, {
trigger: ".o_menu_entry_lvl_2[data-menu-xmlid='planning.planning_menu_schedule_by_employee']",
content: _t("Use this menu to visualize and schedule shifts"),
position: "bottom"
}, {
trigger: ".o_gantt_button_add",
content: _t("Create your first shift by clicking on Add. Alternatively, you can use the (+) on the Gantt view."),
position: "bottom",
}, {
trigger: "button[special='save']",
content: _t("Save this shift as a template to reuse it, or make it recurrent. This will greatly ease your encoding process."),
position: "bottom",
}, {
trigger: ".o_gantt_button_send_all",
content: _t("Send the schedule to your employees once it is ready."),
position: "right",
},{
trigger: "button[name='action_send']",
content: _t("Send the schedule and mark the shifts as published. Congratulations!"),
position: "right",
},
]);
});

View File

@ -0,0 +1,53 @@
.o_field_color_picker {
display: flex;
ul {
display: flex;
flex-wrap: wrap;
width: 100%;
@include o-kanban-colorpicker;
@include o-kanban-record-color;
max-width: unset;
> li {
border: 2px solid white;
box-shadow: 0 0 0 1px gray('300');
}
}
}
.o_field_color_picker_preview {
@include o-kanban-record-color;
> li {
display: inline-block;
margin: $o-kanban-inner-hmargin $o-kanban-inner-hmargin 0 0;
border: 1px solid white;
box-shadow: 0 0 0 1px gray('300');
> a {
display: block;
&::after {
content: "";
display: block;
width: 20px;
height: 15px;
}
}
// No Color
a.oe_kanban_color_0 {
position: relative;
&::before {
content: "";
@include o-position-absolute(-2px, $left: 10px);
display: block;
width: 1px;
height: 20px;
transform: rotate(45deg);
background-color: red;
}
&::after {
background-color: white;
}
}
}
}

View File

@ -0,0 +1,26 @@
.o_planning_calendar_container {
#calendar_employee {
flex-direction: column;
max-width: 100%;
height: auto;
}
.fc-day-grid-event {
margin: 5px 2px 0;
padding: 5px 10px;
}
.fc-day-grid-event .fc-content {
white-space: normal;
}
}
.o_planning_calendar_open_shifts {
.close {
outline: none !important;
}
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="web.ColorPicker">
<div class="o_field_color_picker">
<ul>
</ul>
</div>
</t>
<t t-name="web.ColorPickerReadonly">
<div class="o_field_color_picker_preview">
<li><a role="menuitem" href="#" t-att-data-color="active_color" t-att-class="'oe_kanban_color_'+active_color" title="No color" aria-label="No color"/></li>
</div>
</t>
</templates>

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="PlanningGanttView.buttons">
<button t-if="widget.is_action_enabled('create')" class="o_gantt_button_add btn btn-primary mr-3" title="Add record">
Add
</button>
<div class="d-inline-block mr-3">
<button class="o_gantt_button_prev btn btn-primary" title="Previous">
<span class="fa fa-arrow-left"/>
</button>
<button class="o_gantt_button_today btn btn-primary">
Today
</button>
<button class="o_gantt_button_next btn btn-primary" title="Next">
<span class="fa fa-arrow-right"/>
</button>
</div>
<button t-foreach="allowedScales" t-as="scale" t-attf-class="o_gantt_button_scale btn btn-secondary #{activateScale == scale ? 'active' : ''}" type="button" t-att-data-value="scale">
<t t-esc="SCALES[scale].string"/>
</button>
<div class="btn-group">
<button class="o_gantt_button_expand_rows btn btn-secondary" title="Expand rows">
<i class="fa fa-expand"/>
</button>
<button class="o_gantt_button_collapse_rows btn btn-secondary" title="Collapse rows">
<i class="fa fa-compress"/>
</button>
</div>
<button t-if="activeActions.create &amp;&amp; activateScale == 'week'" class="o_gantt_button_copy_previous_week btn btn-secondary mr-3" title="Copy previous week">
Copy previous week
</button>
<button t-if="activeActions.edit" class="o_gantt_button_send_all btn btn-primary" title="Send schedule">
Send schedule
</button>
</div>
<t t-name="PlanningGanttView.Row" t-extend="GanttView.Row">
<t t-jquery=".o_gantt_row_title" t-operation="prepend">
<t t-if="widget.groupedByField == 'employee_id'">
<img t-att-src="'/web/image?model=hr.employee&amp;field=image_128&amp;id='+widget.resId+'&amp;unique='" style="height: 30px; width: 30px;" class="o_object_fit_cover rounded-circle"/>
</t>
</t>
</t>
</templates>

6
planning/tests/__init__.py Executable file
View File

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details
from . import test_recurrency
from . import test_publication
# from . import test_period_duplication

48
planning/tests/common.py Executable file
View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details
from contextlib import contextmanager
from odoo import fields
from odoo.tests.common import SavepointCase
class TestCommonPlanning(SavepointCase):
@contextmanager
def _patch_now(self, datetime_str):
datetime_now_old = getattr(fields.Datetime, 'now')
datetime_today_old = getattr(fields.Datetime, 'today')
def new_now():
return fields.Datetime.from_string(datetime_str)
def new_today():
return fields.Datetime.from_string(datetime_str).replace(hour=0, minute=0, second=0)
try:
setattr(fields.Datetime, 'now', new_now)
setattr(fields.Datetime, 'today', new_today)
yield
finally:
# back
setattr(fields.Datetime, 'now', datetime_now_old)
setattr(fields.Datetime, 'today', datetime_today_old)
def get_by_employee(self, employee):
return self.env['planning.slot'].search([('employee_id', '=', employee.id)])
@classmethod
def setUpEmployees(cls):
cls.employee_joseph = cls.env['hr.employee'].create({
'name': 'joseph',
'work_email': 'joseph@a.be',
'tz': 'UTC'
})
cls.employee_bert = cls.env['hr.employee'].create({
'name': 'bert',
'work_email': 'bert@a.be',
'tz': 'UTC'
})

View File

@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details
from datetime import datetime, date
from odoo.addons.planning.tests.common import TestCommonPlanning
class TestRecurrencySlotGeneration(TestCommonPlanning):
@classmethod
def setUpClass(cls):
super(TestRecurrencySlotGeneration, cls).setUpClass()
cls.setUpEmployees()
cls.recurrency = cls.env['planning.recurrency'].create({
'repeat_interval': 1,
'repeat_unit': 'week',
})
cls.env['planning.slot'].create({
'employee_id': cls.employee_bert.id,
'start_datetime': datetime(2019, 6, 2, 8, 0),
'end_datetime': datetime(2019, 6, 2, 17, 0),
})
cls.env['planning.slot'].create({
'employee_id': cls.employee_bert.id,
'start_datetime': datetime(2019, 6, 4, 8, 0),
'end_datetime': datetime(2019, 6, 5, 17, 0),
})
cls.env['planning.slot'].create({
'employee_id': cls.employee_bert.id,
'start_datetime': datetime(2019, 6, 3, 8, 0),
'end_datetime': datetime(2019, 6, 3, 17, 0),
'recurrency_id': cls.recurrency.id
})
def test_dont_duplicate_recurring_slots(self):
"""Original week : 6/2/2019 -> 6/8/2019
Destination week : 6/9/2019 -> 6/15/2019
slots:
6/2/2019 08:00 -> 6/2/2019 17:00
6/4/2019 08:00 -> 6/5/2019 17:00
6/3/2019 08:00 -> 6/3/2019 17:00 --> this one should be recurrent therefore not duplicated
"""
employee = self.employee_bert
self.assertEqual(len(self.get_by_employee(employee)), 3)
self.env['planning.slot'].action_copy_previous_week(date(2019, 6, 9))
self.assertEqual(len(self.get_by_employee(employee)), 5, 'duplicate has only duplicated slots that fit entirely in the period')
duplicated_slots = self.env['planning.slot'].search([
('employee_id', '=', employee.id),
('start_datetime', '>', datetime(2019, 6, 9, 0, 0)),
('end_datetime', '<', datetime(2019, 6, 15, 23, 59)),
])
self.assertEqual(len(duplicated_slots), 2, 'duplicate has only duplicated slots that fit entirely in the period')

View File

@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details
from datetime import datetime
from .common import TestCommonPlanning
class TestPlanningPublishing(TestCommonPlanning):
@classmethod
def setUpClass(cls):
super(TestPlanningPublishing, cls).setUpClass()
cls.setUpEmployees()
# employee without work email
cls.employee_dirk_no_mail = cls.env['hr.employee'].create({
'user_id': False,
'name': 'Dirk',
'work_email': False,
'tz': 'UTC'
})
values = {
'employee_id': cls.employee_joseph.id,
'allocated_hours': 8,
'start_datetime': datetime(2019, 6, 6, 8, 0, 0),
'end_datetime': datetime(2019, 6, 6, 17, 0, 0)
}
cls.shift = cls.env['planning.slot'].create(values)
def test_planning_publication(self):
self.shift.write({
'allocated_hours': 10
})
Mails = self.env['mail.mail']
before_mails = Mails.search([])
self.shift.action_send()
self.assertTrue(self.shift.is_published, 'planning is is_published when we call its action_send')
shift_mails = set(Mails.search([])) ^ set(before_mails)
self.assertEqual(len(shift_mails), 1, 'only one mail is created when publishing planning')
def test_sending_planning_do_not_create_mail_if_employee_has_no_email(self):
self.shift.write({'employee_id': self.employee_dirk_no_mail.id})
self.assertFalse(self.employee_dirk_no_mail.work_email) # if no work_email
Mails = self.env['mail.mail']
before_mails = Mails.search([])
self.shift.action_send()
shift_mails = set(Mails.search([])) ^ set(before_mails)
self.assertEqual(len(shift_mails), 0, 'no mail should be sent when the employee has no work email')

409
planning/tests/test_recurrency.py Executable file
View File

@ -0,0 +1,409 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details
from datetime import datetime, date
from .common import TestCommonPlanning
import unittest
from odoo.exceptions import UserError
class TestRecurrencySlotGeneration(TestCommonPlanning):
@classmethod
def setUpClass(cls):
super(TestRecurrencySlotGeneration, cls).setUpClass()
cls.setUpEmployees()
def configure_recurrency_span(self, span_qty):
self.env.company.write({
'planning_generation_interval': span_qty,
})
# ---------------------------------------------------------
# Repeat "until" mode
# ---------------------------------------------------------
def test_repeat_until(self):
""" Normal case: Test slots get repeated at the right time with company limit
company_span: 2 weeks
first run:
now : 6/27/2019
initial_start : 6/27/2019
repeat_end : 7/11/2019 now + 2 weeks
generated slots:
6/27/2019
7/4/2019
NOT 7/11/2019 because it hits the soft limit
1st cron
now : 7/11/2019 2 weeks later
last generated start : 7/4/2019
repeat_end : 7/25/2019 now + 2 weeks
generated_slots:
7/11/2019
7/18/2019
NOT 7/25/2019 because it hits the soft limit
"""
with self._patch_now('2019-06-27 08:00:00'):
self.configure_recurrency_span(1)
self.assertFalse(self.get_by_employee(self.employee_joseph))
# repeat once, since repeat span is two week and there's no repeat until, we should have 2 slot
# because we hit the 'soft_limit'
slot = self.env['planning.slot'].create({
'start_datetime': datetime(2019, 6, 27, 8, 0, 0),
'end_datetime': datetime(2019, 6, 27, 17, 0, 0),
'employee_id': self.employee_joseph.id,
'repeat': True,
'repeat_type': 'until',
'repeat_until': datetime(2022, 6, 27, 17, 0, 0),
'repeat_interval': 1,
})
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 5, 'initial run should have created 2 slots')
# TODO JEM: check same employee, attached to same reccurrence, same role, ...
# now run cron two weeks later, should yield two more slots
with self._patch_now('2019-07-11 08:00:00'):
self.env['planning.recurrency']._cron_schedule_next()
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 7, 'first cron run should have generated 2 more forecasts')
def test_repeat_until_no_repeat(self):
"""create a recurrency with repeat until set which is less than next cron span, should
stop repeating upon creation
company_span: 2 weeks
first run:
now : 6/27/2019
initial_start : 6/27/2019
repeat_end : 6/29/2019 recurrency's repeat_until
generated slots:
6/27/2019
NOT 7/4/2019 because it hits the recurrency's repeat_until
"""
with self._patch_now('2019-06-27 08:00:00'):
self.configure_recurrency_span(1)
self.assertFalse(self.get_by_employee(self.employee_joseph))
slot = self.env['planning.slot'].create({
'start_datetime': datetime(2019, 6, 27, 8, 0, 0),
'end_datetime': datetime(2019, 6, 27, 17, 0, 0),
'employee_id': self.employee_joseph.id,
'repeat': True,
'repeat_type': 'until',
'repeat_interval': 1,
'repeat_until': datetime(2019, 6, 29, 8, 0, 0),
})
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 1, 'first run should only have created 1 slot since repeat until is set at 1 week')
def test_repeat_until_cron_idempotent(self):
"""Create a recurrency with repeat_until set, it allows a full first run, but not on next cron
first run:
now : 6/27/2019
initial_start : 6/27/2019
repeat_end : 7/11/2019 recurrency's repeat_until
generated slots:
6/27/2019
7/4/2019
NOT 7/11/2019 because it hits the recurrency's repeat_until
first cron:
now: 7/12/2019
last generated start: 7/4/2019
repeat_end: 7/11/2019 still recurrency's repeat_until
generated slots:
NOT 7/11/2019 because it still hits the repeat end
"""
with self._patch_now('2019-06-27 08:00:00'):
self.configure_recurrency_span(1)
self.assertFalse(self.get_by_employee(self.employee_joseph))
# repeat until is big enough for the first pass to generate all 2 slots
slot = self.env['planning.slot'].create({
'start_datetime': datetime(2019, 6, 27, 8, 0, 0),
'end_datetime': datetime(2019, 6, 27, 17, 0, 0),
'employee_id': self.employee_joseph.id,
'repeat': True,
'repeat_type': 'until',
'repeat_interval': 1,
'repeat_until': datetime(2019, 7, 11, 8, 0, 0),
})
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 2, 'initial run should have generated 2 slots')
# run the cron, since last generated slot almost hits the repeat until, there won't be more. still two left
self.env['planning.recurrency']._cron_schedule_next()
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 2, 'runing the cron right after do not generate new slots because of repeat until')
def test_repeat_until_cron_generation(self):
"""Generate a recurrence with repeat_until that allow first run, then first cron, but shouldn't
keep generating slots on the second
first run:
now : 8/31/2019
initial_start : 9/1/2019
repeat_end : forever
generated slots:
9/1/2019
9/8/2019
9/15/2019
9/22/2019
9/29/2019
first cron:
now: 9/14/2019 two weeks later
repeat_end: forever
generated slots:
9/1/2019
9/8/2019
9/15/2019
9/22/2019
9/29/2019
10/6/2019
10/13/2019
second cron:
now: 9/16/2019 two days later
last generated start: 10/13/2019
repeat_end: forever
generated slots:
NOT 10/20/2019 because all recurring slots are already generated in the company interval
"""
with self._patch_now('2019-08-31 08:00:00'):
self.configure_recurrency_span(1)
self.assertFalse(self.get_by_employee(self.employee_joseph))
# first run, two slots generated
slot = self.env['planning.slot'].create({
'start_datetime': datetime(2019, 9, 1, 8, 0, 0),
'end_datetime': datetime(2019, 9, 1, 17, 0, 0),
'employee_id': self.employee_joseph.id,
'repeat': True,
'repeat_type': 'forever',
'repeat_interval': 1,
'repeat_until': False,
})
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 5, 'first run should have geenrated 2 slots')
# run the cron, since last generated slot do not hit the repeat until, there will be 2 more
with self._patch_now('2019-09-14 08:00:00'):
self.env['planning.recurrency']._cron_schedule_next()
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 7, 'first cron should have generated 2 more slot')
# run the cron again, since last generated slot do hit the repeat until, there won't be more
with self._patch_now('2019-09-16 08:00:00'):
self.env['planning.recurrency']._cron_schedule_next()
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 7, 'second cron has not generated any foreasts because of repeat until')
def test_repeat_until_long_limit(self):
"""Since the recurrency cron is meant to run every week, make sure generation works accordingly when
the company's repeat span is much larger
first run:
now : 6/1/2019
initial_start : 6/1/2019
repeat_end : 12/1/2019 initial_start + 6 months
generated slots:
6/1/2019
...
11/30/2019 (27 items)
first cron:
now: 6/8/2019
last generated start 11/30/2019
repeat_end 12/8/2019
generated slots:
12/7/2019
only one slot generated: since we are one week later, repeat_end is only one week later and slots are generated every week.
So there is just enough room for one.
This ensure slots are always generated up to x time in advance with x being the company's repeat span
"""
with self._patch_now('2019-06-01 08:00:00'):
self.configure_recurrency_span(6)
self.assertFalse(self.get_by_employee(self.employee_joseph))
slot = self.env['planning.slot'].create({
'start_datetime': datetime(2019, 6, 1, 8, 0, 0),
'end_datetime': datetime(2019, 6, 1, 17, 0, 0),
'employee_id': self.employee_joseph.id,
'repeat': True,
'repeat_type': 'until',
'repeat_interval': 1,
'repeat_until': datetime(2020, 7, 25, 8, 0, 0),
})
# over 6 month, we should have generated 27 slots
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 27, 'first run has generated 27 slots')
# one week later, always having the slots generated 6 months in advance means we
# have generated one more, which makes 28
with self._patch_now('2019-06-08 08:00:00'):
self.env['planning.recurrency']._cron_schedule_next()
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 28, 'second cron only has generated 1 more slot because of company span')
def test_repeat_forever(self):
""" Since the recurrency cron is meant to run every week, make sure generation works accordingly when
both the company's repeat span and the repeat interval are much larger
Company's span is 6 months and repeat_interval is 1 month
first run:
now : 6/1/2019
initial_start : 6/1/2019
repeat_end : 12/1/2019 initial_start + 6 months
generated slots:
6/1/2019
...
11/1/2019 (27 items)
first cron:
now: 6/8/2019
last generated start 11/30/2019
repeat_end 12/8/2019
generated slots:
12/1/2019
second cron:
now: 6/15/2019
last generated start 12/1/2019
repeat_end 12/15/2019
generated slots:
N/A (we are still 6 months in advance)
"""
with self._patch_now('2019-06-01 08:00:00'):
self.configure_recurrency_span(6)
self.assertFalse(self.get_by_employee(self.employee_joseph))
slot = self.env['planning.slot'].create({
'start_datetime': datetime(2019, 6, 1, 8, 0, 0),
'end_datetime': datetime(2019, 6, 1, 17, 0, 0),
'employee_id': self.employee_joseph.id,
'repeat': True,
'repeat_type': 'forever',
'repeat_interval': 1,
})
# over 6 month, we should have generated 6 slots
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 27, 'first run has generated 6 slots')
# one week later, always having the slots generated 6 months in advance means we
# have generated one more, which makes 7
with self._patch_now('2019-06-08 08:00:00'):
self.env['planning.recurrency']._cron_schedule_next()
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 28, 'first cron generated one more slot')
# again one week later, we are now up-to-date so there should still be 7 slots
with self._patch_now('2019-06-15 08:00:00'):
self.env['planning.recurrency']._cron_schedule_next()
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 29, 'second run has not generated any forecats because of company span')
@unittest.skip
def kkktest_slot_remove_all(self):
with self._patch_now('2019-06-01 08:00:00'):
self.configure_recurrency_span(6)
initial_start_dt = datetime(2019, 6, 1, 8, 0, 0)
initial_end_dt = datetime(2019, 6, 1, 17, 0, 0)
slot_values = {
'employee_id': self.employee_joseph.id,
}
recurrency = self.env['planning.recurrency'].create({
'repeat_interval': 1,
})
self.assertFalse(self.get_by_employee(self.employee_joseph))
recurrency.create_slot(initial_start_dt, initial_end_dt, slot_values)
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 27, 'first run has generated 27 slots')
recurrency.action_remove_all()
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 0, 'calling remove after on any slot from the recurrency remove all slots linked to the recurrency')
# ---------------------------------------------------------
# Recurring Slot Misc
# ---------------------------------------------------------
def test_recurring_slot_company(self):
with self._patch_now('2019-06-01 08:00:00'):
initial_company = self.env['res.company'].create({'name': 'original'})
initial_company.write({
'planning_generation_interval': 2,
})
with self.assertRaises(UserError, msg="The employee should be in the same company as the shift"), self.cr.savepoint():
slot1 = self.env['planning.slot'].create({
'start_datetime': datetime(2019, 6, 1, 8, 0, 0),
'end_datetime': datetime(2019, 6, 1, 17, 0, 0),
'employee_id': self.employee_bert.id,
'repeat': True,
'repeat_type': 'forever',
'repeat_interval': 1,
'company_id': initial_company.id,
})
# put the employee in the second company
self.employee_bert.write({'company_id': initial_company.id})
slot1 = self.env['planning.slot'].create({
'start_datetime': datetime(2019, 6, 1, 8, 0, 0),
'end_datetime': datetime(2019, 6, 1, 17, 0, 0),
'employee_id': self.employee_bert.id,
'repeat': True,
'repeat_type': 'forever',
'repeat_interval': 1,
'company_id': initial_company.id,
})
other_company = self.env['res.company'].create({'name': 'other'})
other_company.write({
'planning_generation_interval': 1,
})
self.employee_joseph.write({'company_id': other_company.id})
slot2 = self.env['planning.slot'].create({
'start_datetime': datetime(2019, 6, 1, 8, 0, 0),
'end_datetime': datetime(2019, 6, 1, 17, 0, 0),
'employee_id': self.employee_joseph.id,
'repeat': True,
'repeat_type': 'forever',
'repeat_interval': 1,
'company_id': other_company.id,
})
# initial company's recurrency should have created 9 slots since it's span is two month
# other company's recurrency should have create 5 slots since it's span is one month
self.assertEqual(len(self.get_by_employee(self.employee_bert)), 9, 'initial company\'s span is two month, so 9 slots')
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 5, 'other company\'s span is one month, so only 5 slots')
self.assertEqual(slot1.company_id, slot1.recurrency_id.company_id, "Recurrence and slots (1) must have the same company")
self.assertEqual(slot1.recurrency_id.company_id, slot1.recurrency_id.slot_ids.mapped('company_id'), "All slots in the same recurrence (1) must have the same company")
self.assertEqual(slot2.company_id, slot2.recurrency_id.company_id, "Recurrence and slots (2) must have the same company")
self.assertEqual(slot2.recurrency_id.company_id, slot2.recurrency_id.slot_ids.mapped('company_id'), "All slots in the same recurrence (1) must have the same company")
def test_slot_detach_if_some_fields_change(self):
with self._patch_now('2019-06-27 08:00:00'):
self.configure_recurrency_span(1)
self.assertFalse(self.get_by_employee(self.employee_joseph))
slot = self.env['planning.slot'].create({
'start_datetime': datetime(2019, 6, 27, 8, 0, 0),
'end_datetime': datetime(2019, 6, 27, 17, 0, 0),
'employee_id': self.employee_joseph.id,
'repeat': True,
'repeat_type': 'until',
'repeat_until': datetime(2019, 9, 27, 17, 0, 0), # 3 months
'repeat_interval': 1,
})
recurrence = slot.recurrency_id
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), 5)
self.assertEqual(len(self.get_by_employee(self.employee_joseph)), len(recurrence.slot_ids), 'the recurrency has generated 5 slots')
self.get_by_employee(self.employee_joseph)[0].write({'employee_id': self.employee_bert.id})
self.assertEqual(len(recurrence.slot_ids), 4, 'writing on the slot detach it from the recurrency')
def test_empty_recurrency(self):
""" Check empty recurrency is removed by cron """
with self._patch_now('2020-06-27 08:00:00'):
self.env['planning.recurrency'].create({
'repeat_interval': 1,
'repeat_type': 'forever',
'repeat_until': False,
'last_generated_end_datetime': datetime(2019, 6, 27, 8, 0, 0)
})
self.assertEqual(len(self.env['planning.recurrency'].search([])), 1)
self.env['planning.recurrency']._cron_schedule_next()
self.assertFalse(len(self.env['planning.recurrency'].search([])), 'cron with no slot gets deleted (there is no original slot to copy from)')

Some files were not shown because too many files have changed in this diff Show More