portal"
This commit is contained in:
parent
df5fc408de
commit
7ca1c1ee02
54 changed files with 8302 additions and 0 deletions
4
employee_portal/__init__.py
Normal file
4
employee_portal/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
24
employee_portal/__manifest__.py
Normal file
24
employee_portal/__manifest__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Employee Portal Access',
|
||||
'version': '18.0.1.0',
|
||||
'category': 'Human Resources',
|
||||
'summary': 'Link portal users to employees',
|
||||
'description': """
|
||||
Employee Portal Access
|
||||
======================
|
||||
This module allows to give portal access to employees, similar to how it's done for contacts.
|
||||
It adds a button on the employee form to create a portal user and ensures proper synchronization
|
||||
between the employee and the portal user.
|
||||
""",
|
||||
'depends': ['hr', 'portal', 'mail'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/hr_employee_views.xml',
|
||||
'views/portal_templates.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
3
employee_portal/controllers/__init__.py
Normal file
3
employee_portal/controllers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import portal
|
||||
27
employee_portal/controllers/portal.py
Normal file
27
employee_portal/controllers/portal.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
|
||||
class EmployeePortal(CustomerPortal):
|
||||
|
||||
def _prepare_portal_layout_values(self):
|
||||
"""Add employee to portal values."""
|
||||
values = super(EmployeePortal, self)._prepare_portal_layout_values()
|
||||
|
||||
if request.env.user.has_group('base.group_portal'):
|
||||
employee = request.env.user.employee_id
|
||||
values['employee'] = employee
|
||||
|
||||
return values
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
"""Add employee-related counters to portal home."""
|
||||
values = super(EmployeePortal, self)._prepare_home_portal_values(counters)
|
||||
|
||||
# Only proceed if user is a portal user with an employee
|
||||
if not request.env.user.has_group('base.group_portal') or not request.env.user.employee_id:
|
||||
return values
|
||||
|
||||
return values
|
||||
4
employee_portal/models/__init__.py
Normal file
4
employee_portal/models/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import hr_employee
|
||||
from . import res_users
|
||||
127
employee_portal/models/hr_employee.py
Normal file
127
employee_portal/models/hr_employee.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
portal_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Portal User',
|
||||
groups="hr.group_hr_user",
|
||||
help="Portal user linked to this employee"
|
||||
)
|
||||
|
||||
has_portal_access = fields.Boolean(
|
||||
string='Has Portal Access',
|
||||
compute='_compute_has_portal_access',
|
||||
store=True,
|
||||
help="Indicates if this employee has portal access"
|
||||
)
|
||||
|
||||
@api.depends('user_id', 'portal_user_id')
|
||||
def _compute_has_portal_access(self):
|
||||
"""Determine if the employee has portal access through a portal user."""
|
||||
for employee in self:
|
||||
employee.has_portal_access = bool(employee.portal_user_id) or (
|
||||
employee.user_id and employee.user_id.has_group('base.group_portal')
|
||||
)
|
||||
|
||||
def action_grant_portal_access(self):
|
||||
"""Create a portal user for the employee."""
|
||||
self.ensure_one()
|
||||
|
||||
# Check if employee already has a portal user
|
||||
if self.portal_user_id:
|
||||
raise UserError(_("This employee already has portal access."))
|
||||
|
||||
# Check if employee has an email
|
||||
if not self.work_email:
|
||||
raise UserError(_("You must set a work email address for this employee to create a portal user."))
|
||||
|
||||
# Check if the email is already used by another user
|
||||
existing_user = self.env['res.users'].sudo().search([('login', '=', self.work_email)], limit=1)
|
||||
if existing_user:
|
||||
if existing_user.has_group('base.group_portal'):
|
||||
# Link the existing portal user to this employee
|
||||
self.portal_user_id = existing_user.id
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Portal Access'),
|
||||
'message': _('This employee is now linked to the existing portal user with the same email.'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
else:
|
||||
raise UserError(_("The email address is already used by another user who is not a portal user."))
|
||||
|
||||
# Create a new portal user
|
||||
values = {
|
||||
'name': self.name,
|
||||
'login': self.work_email,
|
||||
'email': self.work_email,
|
||||
'groups_id': [(6, 0, [self.env.ref('base.group_portal').id])],
|
||||
'employee_id': self.id,
|
||||
}
|
||||
|
||||
# Add company if set on employee
|
||||
if self.company_id:
|
||||
values['company_ids'] = [(6, 0, [self.company_id.id])]
|
||||
values['company_id'] = self.company_id.id
|
||||
|
||||
# Create the user and generate a random password
|
||||
user = self.env['res.users'].sudo().create(values)
|
||||
user.partner_id.write({
|
||||
'phone': self.work_phone,
|
||||
'mobile': self.mobile_phone,
|
||||
})
|
||||
|
||||
# Send reset password email
|
||||
user.action_reset_password()
|
||||
|
||||
# Link the portal user to the employee
|
||||
self.portal_user_id = user.id
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Portal Access'),
|
||||
'message': _('Portal access has been granted to %s. An invitation email has been sent.') % self.name,
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
def action_revoke_portal_access(self):
|
||||
"""Revoke portal access for the employee."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.portal_user_id:
|
||||
raise UserError(_("This employee doesn't have portal access."))
|
||||
|
||||
portal_user = self.portal_user_id
|
||||
|
||||
# Unlink the portal user from the employee
|
||||
self.portal_user_id = False
|
||||
|
||||
# Archive the portal user
|
||||
portal_user.sudo().active = False
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Portal Access'),
|
||||
'message': _('Portal access has been revoked from %s.') % self.name,
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
72
employee_portal/models/res_users.py
Normal file
72
employee_portal/models/res_users.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
# Add a computed field to easily access the employee
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Related Employee',
|
||||
compute='_compute_employee_id',
|
||||
store=True,
|
||||
help="Employee linked to this user"
|
||||
)
|
||||
|
||||
employee_ids = fields.One2many(
|
||||
'hr.employee',
|
||||
'portal_user_id',
|
||||
string='Portal Employees',
|
||||
help="Employees linked to this portal user"
|
||||
)
|
||||
|
||||
@api.depends('employee_ids', 'groups_id')
|
||||
def _compute_employee_id(self):
|
||||
"""Compute the related employee for this user."""
|
||||
for user in self:
|
||||
# First check if there's an employee with this user as portal_user_id
|
||||
if user.employee_ids:
|
||||
user.employee_id = user.employee_ids[0]
|
||||
else:
|
||||
# Then check if there's an employee with this user as user_id
|
||||
employee = self.env['hr.employee'].sudo().search([
|
||||
('user_id', '=', user.id)
|
||||
], limit=1)
|
||||
user.employee_id = employee.id if employee else False
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Override create to handle employee synchronization."""
|
||||
users = super(ResUsers, self).create(vals_list)
|
||||
|
||||
# For each new portal user, check if there's an employee with the same email
|
||||
for user in users:
|
||||
if user.has_group('base.group_portal') and not user.employee_id and user.email:
|
||||
employee = self.env['hr.employee'].sudo().search([
|
||||
('work_email', '=', user.email),
|
||||
'|', ('portal_user_id', '=', False), ('portal_user_id', '=', user.id)
|
||||
], limit=1)
|
||||
|
||||
if employee:
|
||||
employee.sudo().write({'portal_user_id': user.id})
|
||||
|
||||
return users
|
||||
|
||||
def write(self, vals):
|
||||
"""Override write to handle employee synchronization."""
|
||||
res = super(ResUsers, self).write(vals)
|
||||
|
||||
# If email is changed, update the link with employees
|
||||
if 'email' in vals and vals.get('email'):
|
||||
for user in self.filtered(lambda u: u.has_group('base.group_portal')):
|
||||
if not user.employee_id:
|
||||
employee = self.env['hr.employee'].sudo().search([
|
||||
('work_email', '=', user.email),
|
||||
'|', ('portal_user_id', '=', False), ('portal_user_id', '=', user.id)
|
||||
], limit=1)
|
||||
|
||||
if employee:
|
||||
employee.sudo().write({'portal_user_id': user.id})
|
||||
|
||||
return res
|
||||
1
employee_portal/security/ir.model.access.csv
Normal file
1
employee_portal/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
49
employee_portal/views/hr_employee_views.xml
Normal file
49
employee_portal/views/hr_employee_views.xml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add Portal Access button to employee form view -->
|
||||
<record id="hr_employee_view_form_inherit_portal" model="ir.ui.view">
|
||||
<field name="name">hr.employee.view.form.inherit.portal</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_grant_portal_access"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-user-plus"
|
||||
invisible="has_portal_access"
|
||||
groups="hr.group_hr_user">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Portal Access</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_revoke_portal_access"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-user-times"
|
||||
invisible="not has_portal_access"
|
||||
groups="hr.group_hr_user">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Revoke Portal</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
<field name="user_id" position="after">
|
||||
<field name="portal_user_id" readonly="1" invisible="not portal_user_id" groups="hr.group_hr_user"/>
|
||||
<field name="has_portal_access" invisible="1"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Add Portal Access filter to employee search view -->
|
||||
<record id="hr_employee_view_search_inherit_portal" model="ir.ui.view">
|
||||
<field name="name">hr.employee.view.search.inherit.portal</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<filter name="group_manager" position="after">
|
||||
<filter string="Portal Access" name="portal_access" domain="[('has_portal_access', '=', True)]"/>
|
||||
</filter>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
39
employee_portal/views/portal_templates.xml
Normal file
39
employee_portal/views/portal_templates.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add employee information to portal my account page -->
|
||||
<template id="portal_my_details_fields_employee" name="Employee Details" inherit_id="portal.portal_my_details_fields">
|
||||
<xpath expr="." position="inside">
|
||||
<div t-if="employee" class="mb-3 col-xl-6">
|
||||
<h5 class="mb-3">Informations employé</h5>
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Département</label>
|
||||
<div t-esc="employee.department_id.name or ''" class="form-control-plaintext"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Poste</label>
|
||||
<div t-esc="employee.job_id.name or ''" class="form-control-plaintext"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Responsable</label>
|
||||
<div t-esc="employee.parent_id.name or ''" class="form-control-plaintext"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Entreprise</label>
|
||||
<div t-esc="employee.company_id.name or ''" class="form-control-plaintext"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
99
portal_partner_manager/doc/suggestion.md
Normal file
99
portal_partner_manager/doc/suggestion.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
Suggestions pour l'amélioration du module portal_partner_manager
|
||||
1. Vue d'ensemble
|
||||
Ce document présente des améliorations techniques et fonctionnelles pour le contrôleur portal.py du module portal_partner_manager, qui gère l'accès au portail pour les utilisateurs et leurs sociétés parentes.
|
||||
|
||||
2. Problèmes identifiés
|
||||
🔒 Sécurité
|
||||
Utilisation excessive de sudo() : Risque de contourner les contrôles d'accès (ex. portal_my_contacts_grant_access).
|
||||
Validation manquante des identifiants : Aucun contrôle sur les valeurs de contact_id ou company_id.
|
||||
🧩 Redondance
|
||||
Répétition du code : Mêmes requêtes pour les pays/provinces dans plusieurs méthodes (portal_my_contacts_add, portal_my_contacts_edit).
|
||||
📝 Gestion des erreurs
|
||||
Messages d'erreur génériques : Ex. "Erreur lors de la création du contact" sans détails.
|
||||
Validation de l'email absente : Aucun contrôle de format (ex. contact@domain.com).
|
||||
📊 Journalisation
|
||||
Logs de débogage en production : Ex. _logger.info("Provinces found: %r", provinces).
|
||||
3. Améliorations suggérées
|
||||
✅ Sécurité
|
||||
Remplacer sudo() par des accès sécurisés :
|
||||
|
||||
Exemple :
|
||||
|
||||
# Avant
|
||||
contact = request.env['res.partner'].sudo().browse(contact_id)
|
||||
# Après
|
||||
contact = request.env['res.partner'].browse(contact_id)
|
||||
if contact.parent_id != request.env.user.partner_id.commercial_partner_id:
|
||||
raise AccessError(_("You don't have access to this contact."))
|
||||
|
||||
python
|
||||
|
||||
|
||||
Valider les identifiants :
|
||||
|
||||
def _check_valid_id(self, record_id, model):
|
||||
record = model.browse(record_id)
|
||||
if not record.exists():
|
||||
raise ValidationError(_("Invalid record ID."))
|
||||
return record
|
||||
|
||||
python
|
||||
|
||||
|
||||
🛠️ Réduction des redondances
|
||||
Méthode utilitaire pour les pays/provinces :
|
||||
def _get_location_data(self):
|
||||
countries = request.env['res.country'].search([])
|
||||
canada = countries.filtered(lambda c: c.code == 'CA')
|
||||
canadian_provinces = request.env['res.country.state'].search([('country_id', '=', canada.id)])
|
||||
return {
|
||||
'countries': countries,
|
||||
'canadian_provinces': canadian_provinces,
|
||||
}
|
||||
|
||||
python
|
||||
|
||||
|
||||
🧪 Gestion des erreurs
|
||||
Validation de l'email :
|
||||
|
||||
import re
|
||||
def _validate_email(self, email):
|
||||
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
|
||||
if not re.match(pattern, email):
|
||||
raise ValidationError(_("Invalid email format."))
|
||||
|
||||
python
|
||||
|
||||
|
||||
Messages d'erreur explicites :
|
||||
|
||||
return request.redirect('/my/company?error_message=' + _('Email already exists for another user.'))
|
||||
|
||||
python
|
||||
|
||||
|
||||
📋 Journalisation
|
||||
Supprimer les logs de débogage en production :
|
||||
Remplacer _logger.info(...) par _logger.debug(...) ou les supprimer.
|
||||
4. Exemple de refactorisation
|
||||
Avant
|
||||
countries = request.env['res.country'].sudo().search([])
|
||||
canada = request.env['res.country'].sudo().search([('code', '=', 'CA')], limit=1)
|
||||
canadian_provinces = request.env['res.country.state'].sudo().search([('country_id', '=', canada.id)])
|
||||
|
||||
python
|
||||
|
||||
|
||||
Après
|
||||
location_data = self._get_location_data()
|
||||
countries = location_data['countries']
|
||||
canadian_provinces = location_data['canadian_provinces']
|
||||
|
||||
python
|
||||
|
||||
|
||||
5. Tests à ajouter
|
||||
Test d'archivage d'un contact avec accès portail actif.
|
||||
Test de création de contact avec email invalide.
|
||||
Test de modification de société avec champs non autorisés.
|
||||
2
portal_planning/__init__.py
Normal file
2
portal_planning/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import controllers
|
||||
from . import models
|
||||
46
portal_planning/__manifest__.py
Normal file
46
portal_planning/__manifest__.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Portal Planning',
|
||||
'version': '18.0.1.0',
|
||||
'category': 'Human Resources/Planning',
|
||||
'summary': 'Allow portal users to access their planning',
|
||||
'description': """
|
||||
Portal Planning
|
||||
===============
|
||||
This module extends the Planning module to allow employees with portal access to view and manage their planning.
|
||||
""",
|
||||
'depends': [
|
||||
'employee_portal',
|
||||
'planning',
|
||||
'mail',
|
||||
'analytic',
|
||||
'hr_timesheet',
|
||||
'hr_skills'
|
||||
],
|
||||
'data': [
|
||||
'security/portal_planning_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/portal_planning_data.xml',
|
||||
'data/ir_sequence_data.xml',
|
||||
'views/portal_planning_templates.xml',
|
||||
'views/portal_planning_slot_templates.xml',
|
||||
'views/portal_planning_slot_create_template.xml',
|
||||
'views/portal_planning_exchange_templates.xml',
|
||||
'views/portal_planning_views.xml',
|
||||
'views/planning_slot_views.xml',
|
||||
'views/portal_planning_modification_views.xml',
|
||||
'views/portal_planning_exchange_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'portal_planning/static/src/css/portal_planning.css',
|
||||
'portal_planning/static/src/scss/portal_planning.scss',
|
||||
'portal_planning/static/src/js/portal_planning.js',
|
||||
'portal_planning/static/src/js/portal_planning_calendar.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'OEEL-1',
|
||||
}
|
||||
173
portal_planning/analyse.md
Normal file
173
portal_planning/analyse.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# Spécifications du module Portal Planning
|
||||
|
||||
## Analyse des modules de planning Odoo Enterprise
|
||||
|
||||
### enterprise/planning
|
||||
|
||||
Le module `planning` est le module de base pour la gestion des plannings dans Odoo Enterprise. Il offre les fonctionnalités suivantes :
|
||||
|
||||
- Création et gestion des créneaux de planning (planning.slot)
|
||||
- Affectation des employés aux créneaux
|
||||
- Définition des rôles et des compétences requises
|
||||
- Visualisation des plannings sous forme de calendrier ou de gantt
|
||||
- Envoi de notifications par email aux employés
|
||||
- Gestion des récurrences pour les créneaux réguliers
|
||||
- Possibilité pour les employés de confirmer leur présence
|
||||
|
||||
Le modèle principal est `planning.slot` qui contient toutes les informations relatives à un créneau de planning (date de début, date de fin, employé affecté, rôle, etc.). Les employés sont représentés par le modèle `resource.resource` qui est lié au modèle `hr.employee`.
|
||||
|
||||
Le module inclut également un système de demandes de congés et d'échanges de créneaux entre employés, mais ces fonctionnalités ne sont accessibles qu'aux utilisateurs ayant les droits appropriés (pas aux utilisateurs du portail).
|
||||
|
||||
### enterprise/planning_contract
|
||||
|
||||
Le module `planning_contract` étend le module `planning` pour prendre en compte les contrats des employés lors de la planification. Il ajoute les fonctionnalités suivantes :
|
||||
|
||||
- Vérification des heures de travail contractuelles
|
||||
- Alertes en cas de dépassement des heures contractuelles
|
||||
- Prise en compte des jours de repos contractuels
|
||||
- Intégration avec le module `hr_contract` pour récupérer les informations contractuelles
|
||||
|
||||
Ce module ajoute des contraintes supplémentaires lors de la création des créneaux de planning pour s'assurer que les employés ne travaillent pas plus que prévu dans leur contrat.
|
||||
|
||||
### enterprise/planning_holidays
|
||||
|
||||
Le module `planning_holidays` intègre les congés des employés dans le planning. Il offre les fonctionnalités suivantes :
|
||||
|
||||
- Prise en compte des congés validés lors de la création des créneaux
|
||||
- Alertes en cas de conflit entre un créneau et un congé
|
||||
- Visualisation des congés dans le planning
|
||||
- Intégration avec le module `hr_holidays` pour récupérer les informations de congés
|
||||
|
||||
Ce module permet d'éviter les conflits entre les créneaux de planning et les congés des employés, en empêchant l'affectation d'un employé à un créneau pendant ses congés.
|
||||
|
||||
### enterprise/planning_hr_skills
|
||||
|
||||
Le module `planning_hr_skills` intègre les compétences des employés dans le planning. Il offre les fonctionnalités suivantes :
|
||||
|
||||
- Définition des compétences requises pour un créneau
|
||||
- Filtrage des employés disponibles en fonction de leurs compétences
|
||||
- Suggestions d'employés basées sur leurs compétences
|
||||
- Intégration avec le module `hr_skills` pour récupérer les informations de compétences
|
||||
|
||||
Ce module permet d'optimiser l'affectation des employés aux créneaux en fonction de leurs compétences, en suggérant les employés les plus qualifiés pour chaque tâche.
|
||||
|
||||
## Alternatives pour l'accès au planning via le portail
|
||||
|
||||
### 1. Extension du contrôleur portal.CustomerPortal
|
||||
|
||||
Cette approche consiste à étendre le contrôleur `portal.CustomerPortal` pour ajouter des routes spécifiques au planning. Cela permet de réutiliser les fonctionnalités existantes du portail tout en ajoutant des pages dédiées au planning.
|
||||
|
||||
**Avantages :**
|
||||
- Intégration transparente avec le portail existant
|
||||
- Réutilisation des mécanismes d'authentification du portail
|
||||
- Cohérence de l'interface utilisateur
|
||||
|
||||
**Inconvénients :**
|
||||
- Nécessite de gérer manuellement les droits d'accès
|
||||
- Peut nécessiter des modifications importantes en cas de mise à jour du module portal
|
||||
|
||||
**Implémentation :**
|
||||
- Créer un contrôleur qui hérite de `portal.CustomerPortal`
|
||||
- Ajouter des routes pour afficher et modifier les plannings
|
||||
- Créer des templates QWeb pour l'affichage des plannings
|
||||
- Définir des règles de sécurité pour limiter l'accès aux données
|
||||
|
||||
### 2. Utilisation de l'API REST d'Odoo
|
||||
|
||||
Cette approche consiste à créer une API REST dédiée au planning, accessible aux utilisateurs du portail. Cela permet une plus grande flexibilité dans l'interface utilisateur, notamment pour les applications mobiles.
|
||||
|
||||
**Avantages :**
|
||||
- Séparation claire entre le backend et le frontend
|
||||
- Possibilité de créer des interfaces utilisateur riches (SPA, applications mobiles)
|
||||
- Facilité d'intégration avec des systèmes tiers
|
||||
|
||||
**Inconvénients :**
|
||||
- Complexité accrue du développement
|
||||
- Nécessité de gérer l'authentification et les sessions
|
||||
- Peut nécessiter des connaissances en développement frontend
|
||||
|
||||
**Implémentation :**
|
||||
- Créer un contrôleur REST qui expose les fonctionnalités du planning
|
||||
- Définir des endpoints pour les différentes opérations (GET, POST, PUT, DELETE)
|
||||
- Implémenter l'authentification via des tokens
|
||||
- Développer une interface utilisateur qui consomme cette API
|
||||
|
||||
### 3. Extension des modèles existants avec des champs et méthodes spécifiques
|
||||
|
||||
Cette approche consiste à étendre les modèles existants (`planning.slot`, `resource.resource`, etc.) pour ajouter des champs et des méthodes spécifiques aux utilisateurs du portail.
|
||||
|
||||
**Avantages :**
|
||||
- Modification minimale de la structure existante
|
||||
- Réutilisation des fonctionnalités existantes
|
||||
- Facilité de maintenance
|
||||
|
||||
**Inconvénients :**
|
||||
- Limitations dans les fonctionnalités disponibles
|
||||
- Dépendance forte aux modèles existants
|
||||
- Risque de conflits avec d'autres modules
|
||||
|
||||
**Implémentation :**
|
||||
- Étendre les modèles `planning.slot` et `resource.resource`
|
||||
- Ajouter des champs pour stocker les informations spécifiques au portail
|
||||
- Créer des méthodes pour les opérations accessibles via le portail
|
||||
- Définir des règles de sécurité pour limiter l'accès aux données
|
||||
|
||||
### 4. Création de modèles dédiés pour le portail
|
||||
|
||||
Cette approche consiste à créer des modèles dédiés pour le portail, qui servent d'interface entre les utilisateurs du portail et les modèles de planning existants.
|
||||
|
||||
**Avantages :**
|
||||
- Séparation claire entre les fonctionnalités du portail et celles du backend
|
||||
- Contrôle précis des données accessibles aux utilisateurs du portail
|
||||
- Facilité d'ajout de fonctionnalités spécifiques au portail
|
||||
|
||||
**Inconvénients :**
|
||||
- Duplication potentielle de données
|
||||
- Nécessité de synchroniser les données entre les modèles
|
||||
- Complexité accrue de l'architecture
|
||||
|
||||
**Implémentation :**
|
||||
- Créer des modèles dédiés (`portal.planning.slot`, `portal.planning.request`, etc.)
|
||||
- Implémenter des méthodes pour synchroniser les données avec les modèles existants
|
||||
- Créer des vues et des actions spécifiques pour ces modèles
|
||||
- Définir des règles de sécurité adaptées
|
||||
|
||||
### 5. Utilisation de modèles transitoires (wizards)
|
||||
|
||||
Cette approche consiste à utiliser des modèles transitoires (wizards) pour permettre aux utilisateurs du portail d'interagir avec leur planning sans avoir accès direct aux modèles principaux.
|
||||
|
||||
**Avantages :**
|
||||
- Sécurité renforcée (pas d'accès direct aux données)
|
||||
- Contrôle précis des actions possibles
|
||||
- Facilité d'implémentation des workflows complexes
|
||||
|
||||
**Inconvénients :**
|
||||
- Limitations dans la persistance des données
|
||||
- Interface utilisateur potentiellement moins intuitive
|
||||
- Complexité accrue pour certaines opérations
|
||||
|
||||
**Implémentation :**
|
||||
- Créer des modèles transitoires pour les différentes actions (demande de congé, échange de créneau, etc.)
|
||||
- Implémenter des méthodes pour valider et traiter ces actions
|
||||
- Créer des vues et des actions spécifiques pour ces modèles
|
||||
- Définir des règles de sécurité adaptées
|
||||
|
||||
## Recommandations
|
||||
|
||||
Pour l'implémentation du module `portal_planning`, nous recommandons une approche hybride combinant :
|
||||
|
||||
1. **Extension du contrôleur portal.CustomerPortal** pour l'interface utilisateur
|
||||
2. **Extension des modèles existants** pour les fonctionnalités de base
|
||||
3. **Création de modèles dédiés** pour les fonctionnalités spécifiques au portail
|
||||
|
||||
Cette approche permet de bénéficier des avantages de chaque méthode tout en minimisant leurs inconvénients. Elle offre une intégration transparente avec le portail existant, une réutilisation des fonctionnalités de planning, et une séparation claire des préoccupations.
|
||||
|
||||
Les principales fonctionnalités à implémenter sont :
|
||||
|
||||
- Visualisation du planning personnel
|
||||
- Confirmation des créneaux affectés
|
||||
- Demande d'échange de créneaux
|
||||
- Notification des modifications de planning
|
||||
- Visualisation des statistiques personnelles (heures travaillées, etc.)
|
||||
|
||||
Les règles de sécurité doivent être soigneusement définies pour s'assurer que les utilisateurs du portail n'ont accès qu'à leurs propres données et ne peuvent pas modifier les données critiques.
|
||||
5
portal_planning/controllers/__init__.py
Normal file
5
portal_planning/controllers/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from . import portal
|
||||
from . import planning_slot
|
||||
from . import planning_exchange
|
||||
from . import planning_month
|
||||
from . import api
|
||||
253
portal_planning/controllers/api.py
Normal file
253
portal_planning/controllers/api.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request, Response
|
||||
from odoo.exceptions import AccessError, MissingError, UserError
|
||||
import json
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class PlanningPortalAPI(http.Controller):
|
||||
"""Contrôleur pour l'API JSON du planning dans le portail."""
|
||||
|
||||
def _get_employee_from_user(self):
|
||||
"""Récupère l'employé correspondant à l'utilisateur connecté."""
|
||||
return request.env['hr.employee'].sudo().search([('user_id', '=', request.env.user.id)], limit=1)
|
||||
|
||||
def _check_slot_access(self, slot_id):
|
||||
"""Vérifie que l'utilisateur a accès au créneau de planning."""
|
||||
employee = self._get_employee_from_user()
|
||||
if not employee:
|
||||
return False, "Aucun employé associé à cet utilisateur"
|
||||
|
||||
slot = request.env['planning.slot'].sudo().browse(slot_id)
|
||||
if not slot.exists():
|
||||
return False, "Créneau de planning introuvable"
|
||||
|
||||
if slot.employee_id.id != employee.id:
|
||||
return False, "Vous n'avez pas accès à ce créneau de planning"
|
||||
|
||||
return True, slot
|
||||
|
||||
@http.route(['/planning/api/slot/update'], type='json', auth="user", methods=['POST'], website=True)
|
||||
def update_slot(self, **kw):
|
||||
"""Endpoint pour mettre à jour un créneau de planning."""
|
||||
slot_id = kw.get('slot_id')
|
||||
if not slot_id:
|
||||
return {'success': False, 'error': "ID du créneau manquant"}
|
||||
|
||||
access, result = self._check_slot_access(slot_id)
|
||||
if not access:
|
||||
return {'success': False, 'error': result}
|
||||
|
||||
slot = result
|
||||
|
||||
# Récupérer les données de mise à jour
|
||||
start_datetime = kw.get('start_datetime')
|
||||
end_datetime = kw.get('end_datetime')
|
||||
role_id = kw.get('role_id')
|
||||
notes = kw.get('notes')
|
||||
|
||||
# Valider les données
|
||||
if start_datetime and end_datetime:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_datetime.replace('Z', '+00:00'))
|
||||
end_dt = datetime.fromisoformat(end_datetime.replace('Z', '+00:00'))
|
||||
|
||||
if start_dt >= end_dt:
|
||||
return {'success': False, 'error': "La date de début doit être antérieure à la date de fin"}
|
||||
except ValueError:
|
||||
return {'success': False, 'error': "Format de date invalide"}
|
||||
|
||||
# Préparer les valeurs pour la modification
|
||||
vals = {}
|
||||
if start_datetime:
|
||||
vals['start_datetime'] = start_datetime
|
||||
if end_datetime:
|
||||
vals['end_datetime'] = end_datetime
|
||||
if role_id:
|
||||
vals['role_id'] = int(role_id)
|
||||
|
||||
try:
|
||||
# Créer une modification via le portail
|
||||
modification_vals = {
|
||||
'slot_id': slot.id,
|
||||
'user_id': request.env.user.id,
|
||||
'start_datetime': start_datetime if start_datetime else slot.start_datetime,
|
||||
'end_datetime': end_datetime if end_datetime else slot.end_datetime,
|
||||
'role_id': int(role_id) if role_id else slot.role_id.id if slot.role_id else False,
|
||||
'notes': notes,
|
||||
'state': 'draft'
|
||||
}
|
||||
|
||||
modification = request.env['portal.planning.modification'].sudo().create(modification_vals)
|
||||
# Soumettre la modification
|
||||
if hasattr(modification, 'action_submit'):
|
||||
modification.action_submit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': "Demande de modification soumise avec succès",
|
||||
'modification_id': modification.id,
|
||||
'state': modification.state
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Erreur lors de la mise à jour du créneau: %s", str(e))
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@http.route(['/planning/api/slot/confirm'], type='json', auth="user", methods=['POST'], website=True)
|
||||
def confirm_slot(self, **kw):
|
||||
"""Endpoint pour confirmer un créneau de planning."""
|
||||
slot_id = kw.get('slot_id')
|
||||
if not slot_id:
|
||||
return {'success': False, 'error': "ID du créneau manquant"}
|
||||
|
||||
access, result = self._check_slot_access(slot_id)
|
||||
if not access:
|
||||
return {'success': False, 'error': result}
|
||||
|
||||
slot = result
|
||||
|
||||
try:
|
||||
# Confirmer le créneau
|
||||
if hasattr(slot, 'action_confirm_portal'):
|
||||
slot.action_confirm_portal()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': "Créneau confirmé avec succès",
|
||||
'portal_status': slot.portal_status if hasattr(slot, 'portal_status') else 'confirmed'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Erreur lors de la confirmation du créneau: %s", str(e))
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@http.route(['/planning/api/slot/exchange/request'], type='json', auth="user", methods=['POST'], website=True)
|
||||
def request_exchange(self, **kw):
|
||||
"""Endpoint pour demander un échange de créneau."""
|
||||
source_slot_id = kw.get('source_slot_id')
|
||||
target_slot_id = kw.get('target_slot_id')
|
||||
is_open_request = kw.get('is_open_request', False)
|
||||
preferred_start = kw.get('preferred_start')
|
||||
preferred_end = kw.get('preferred_end')
|
||||
notes = kw.get('notes')
|
||||
|
||||
if not source_slot_id:
|
||||
return {'success': False, 'error': "ID du créneau source manquant"}
|
||||
|
||||
access, result = self._check_slot_access(source_slot_id)
|
||||
if not access:
|
||||
return {'success': False, 'error': result}
|
||||
|
||||
source_slot = result
|
||||
|
||||
# Valider les données pour une demande ouverte
|
||||
if is_open_request and preferred_start and preferred_end:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(preferred_start.replace('Z', '+00:00'))
|
||||
end_dt = datetime.fromisoformat(preferred_end.replace('Z', '+00:00'))
|
||||
|
||||
if start_dt >= end_dt:
|
||||
return {'success': False, 'error': "La date de début doit être antérieure à la date de fin"}
|
||||
except ValueError:
|
||||
return {'success': False, 'error': "Format de date invalide"}
|
||||
|
||||
# Valider le créneau cible pour une demande spécifique
|
||||
if not is_open_request and target_slot_id:
|
||||
target_slot = request.env['planning.slot'].sudo().browse(target_slot_id)
|
||||
if not target_slot.exists():
|
||||
return {'success': False, 'error': "Créneau cible introuvable"}
|
||||
|
||||
# Vérifier que le créneau cible n'appartient pas à l'utilisateur
|
||||
if target_slot.employee_id.user_id.id == request.env.user.id:
|
||||
return {'success': False, 'error': "Vous ne pouvez pas échanger avec votre propre créneau"}
|
||||
|
||||
try:
|
||||
# Préparer les valeurs pour la demande d'échange
|
||||
slot_name = source_slot.name if hasattr(source_slot, 'name') and source_slot.name else 'créneau'
|
||||
slot_date = source_slot.start_datetime.strftime('%d/%m/%Y') if hasattr(source_slot, 'start_datetime') else ''
|
||||
|
||||
exchange_vals = {
|
||||
'name': f"Demande d'échange pour {slot_name} du {slot_date}",
|
||||
'source_slot_id': source_slot.id,
|
||||
'source_user_id': request.env.user.id,
|
||||
'notes': notes,
|
||||
'state': 'draft',
|
||||
'is_open_request': is_open_request
|
||||
}
|
||||
|
||||
if is_open_request:
|
||||
if preferred_start:
|
||||
exchange_vals['preferred_start'] = preferred_start
|
||||
if preferred_end:
|
||||
exchange_vals['preferred_end'] = preferred_end
|
||||
else:
|
||||
if target_slot_id:
|
||||
exchange_vals['target_slot_id'] = target_slot_id
|
||||
exchange_vals['target_user_id'] = target_slot.employee_id.user_id.id
|
||||
|
||||
# Créer la demande d'échange
|
||||
exchange = request.env['portal.planning.exchange'].sudo().create(exchange_vals)
|
||||
|
||||
# Soumettre la demande
|
||||
exchange.action_submit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': "Demande d'échange soumise avec succès",
|
||||
'exchange_id': exchange.id,
|
||||
'state': exchange.state
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Erreur lors de la création de la demande d'échange: %s", str(e))
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@http.route(['/planning/api/slots'], type='json', auth="user", methods=['POST'], website=True)
|
||||
def get_slots(self, **kw):
|
||||
"""Endpoint pour récupérer les créneaux de planning pour le calendrier."""
|
||||
start_date = kw.get('start_date')
|
||||
end_date = kw.get('end_date')
|
||||
|
||||
if not start_date or not end_date:
|
||||
return {'success': False, 'error': "Dates de début et de fin requises"}
|
||||
|
||||
try:
|
||||
# Convertir les dates en objets datetime
|
||||
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59) # Inclure toute la journée de fin
|
||||
|
||||
# Récupérer l'employé correspondant à l'utilisateur connecté
|
||||
employee = self._get_employee_from_user()
|
||||
if not employee:
|
||||
return {'success': False, 'error': "Aucun employé associé à cet utilisateur"}
|
||||
|
||||
# Récupérer les créneaux de planning pour l'employé dans la période spécifiée
|
||||
domain = [
|
||||
('employee_id', '=', employee.id),
|
||||
'|',
|
||||
'&', ('start_datetime', '>=', start_dt), ('start_datetime', '<=', end_dt),
|
||||
'&', ('end_datetime', '>=', start_dt), ('end_datetime', '<=', end_dt)
|
||||
]
|
||||
|
||||
slots = request.env['planning.slot'].sudo().search_read(domain, [
|
||||
'id', 'name', 'start_datetime', 'end_datetime', 'employee_id', 'role_id',
|
||||
'allocated_hours', 'portal_status', 'portal_confirmed', 'portal_modified',
|
||||
'portal_can_modify'
|
||||
])
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'slots': slots
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
return {'success': False, 'error': "Format de date invalide"}
|
||||
except Exception as e:
|
||||
_logger.error("Erreur lors de la récupération des créneaux: %s", str(e))
|
||||
return {'success': False, 'error': str(e)}
|
||||
147
portal_planning/controllers/planning_exchange.py
Normal file
147
portal_planning/controllers/planning_exchange.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
|
||||
from odoo.exceptions import AccessError, MissingError, UserError
|
||||
from datetime import datetime
|
||||
|
||||
class PlanningExchangePortal(CustomerPortal):
|
||||
|
||||
@http.route(['/my/planning/exchanges', '/my/planning/exchanges/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_planning_exchanges(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw):
|
||||
"""Afficher la liste des demandes d'échange de l'utilisateur."""
|
||||
values = self._prepare_portal_layout_values()
|
||||
|
||||
domain = [
|
||||
'|',
|
||||
('source_user_id', '=', request.env.user.id),
|
||||
('target_user_id', '=', request.env.user.id)
|
||||
]
|
||||
|
||||
# Filtrer par date si spécifié
|
||||
if date_begin and date_end:
|
||||
domain += [
|
||||
('date', '>=', date_begin),
|
||||
('date', '<=', date_end)
|
||||
]
|
||||
|
||||
# Filtrer par état
|
||||
searchbar_filters = {
|
||||
'all': {'label': _('Tous'), 'domain': []},
|
||||
'draft': {'label': _('Brouillons'), 'domain': [('state', '=', 'draft')]},
|
||||
'pending': {'label': _('En attente'), 'domain': [('state', '=', 'pending')]},
|
||||
'accepted': {'label': _('Acceptés'), 'domain': [('state', '=', 'accepted')]},
|
||||
'approved': {'label': _('Approuvés'), 'domain': [('state', '=', 'approved')]},
|
||||
'rejected': {'label': _('Refusés'), 'domain': [('state', '=', 'rejected')]},
|
||||
'cancelled': {'label': _('Annulés'), 'domain': [('state', '=', 'cancelled')]},
|
||||
}
|
||||
|
||||
# Filtre par défaut
|
||||
if not filterby:
|
||||
filterby = 'all'
|
||||
domain += searchbar_filters[filterby]['domain']
|
||||
|
||||
# Options de tri
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Date'), 'order': 'date desc'},
|
||||
'state': {'label': _('État'), 'order': 'state'},
|
||||
}
|
||||
|
||||
# Tri par défaut
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
order = searchbar_sortings[sortby]['order']
|
||||
|
||||
# Pagination
|
||||
exchange_count = request.env['portal.planning.exchange'].sudo().search_count(domain)
|
||||
pager = portal_pager(
|
||||
url="/my/planning/exchanges",
|
||||
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'filterby': filterby},
|
||||
total=exchange_count,
|
||||
page=page,
|
||||
step=self._items_per_page
|
||||
)
|
||||
|
||||
# Récupérer les demandes d'échange
|
||||
exchanges = request.env['portal.planning.exchange'].sudo().search(
|
||||
domain,
|
||||
order=order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset']
|
||||
)
|
||||
|
||||
# Préparer les valeurs pour le template
|
||||
values.update({
|
||||
'date': date_begin,
|
||||
'exchanges': exchanges,
|
||||
'page_name': 'planning_exchanges',
|
||||
'pager': pager,
|
||||
'default_url': '/my/planning/exchanges',
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'searchbar_filters': searchbar_filters,
|
||||
'sortby': sortby,
|
||||
'filterby': filterby,
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_my_planning_exchanges", values)
|
||||
|
||||
@http.route(['/my/planning/exchange/<int:exchange_id>'], type='http', auth="user", website=True)
|
||||
def portal_planning_exchange_detail(self, exchange_id):
|
||||
"""Afficher le détail d'une demande d'échange."""
|
||||
try:
|
||||
exchange_sudo = request.env['portal.planning.exchange'].sudo().browse(exchange_id)
|
||||
if not exchange_sudo.exists() or (exchange_sudo.source_user_id.id != request.env.user.id and exchange_sudo.target_user_id.id != request.env.user.id):
|
||||
return request.redirect('/my/planning/exchanges')
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning/exchanges')
|
||||
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'exchange': exchange_sudo,
|
||||
'page_name': 'planning_exchange_detail',
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_planning_exchange_detail", values)
|
||||
|
||||
@http.route(['/my/planning/exchange/<int:exchange_id>/accept'], type='http', auth="user", website=True)
|
||||
def portal_planning_exchange_accept(self, exchange_id):
|
||||
"""Accepter une demande d'échange."""
|
||||
try:
|
||||
exchange_sudo = request.env['portal.planning.exchange'].sudo().browse(exchange_id)
|
||||
if not exchange_sudo.exists() or exchange_sudo.target_user_id.id != request.env.user.id:
|
||||
return request.redirect('/my/planning/exchanges')
|
||||
|
||||
if exchange_sudo.can_accept:
|
||||
exchange_sudo.action_accept()
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning/exchanges')
|
||||
except UserError as e:
|
||||
return request.render('portal_planning.portal_planning_error', {
|
||||
'error': str(e),
|
||||
'back_url': '/my/planning/exchange/%s' % exchange_id,
|
||||
})
|
||||
|
||||
return request.redirect('/my/planning/exchange/%s' % exchange_id)
|
||||
|
||||
@http.route(['/my/planning/exchange/<int:exchange_id>/cancel'], type='http', auth="user", website=True)
|
||||
def portal_planning_exchange_cancel(self, exchange_id):
|
||||
"""Annuler une demande d'échange."""
|
||||
try:
|
||||
exchange_sudo = request.env['portal.planning.exchange'].sudo().browse(exchange_id)
|
||||
if not exchange_sudo.exists() or exchange_sudo.source_user_id.id != request.env.user.id:
|
||||
return request.redirect('/my/planning/exchanges')
|
||||
|
||||
if exchange_sudo.can_cancel:
|
||||
exchange_sudo.action_cancel()
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning/exchanges')
|
||||
except UserError as e:
|
||||
return request.render('portal_planning.portal_planning_error', {
|
||||
'error': str(e),
|
||||
'back_url': '/my/planning/exchange/%s' % exchange_id,
|
||||
})
|
||||
|
||||
return request.redirect('/my/planning/exchange/%s' % exchange_id)
|
||||
129
portal_planning/controllers/planning_month.py
Normal file
129
portal_planning/controllers/planning_month.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import http, fields
|
||||
from odoo.http import request
|
||||
from datetime import datetime, timedelta, date
|
||||
import calendar
|
||||
import logging
|
||||
|
||||
from .planning_slot import get_employee_from_user
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class PlanningMonthController(http.Controller):
|
||||
|
||||
@http.route(['/my/planning/month'], type='http', auth="user", website=True)
|
||||
def portal_my_planning_month(self, date=None, **kw):
|
||||
"""Afficher la vue mensuelle du planning."""
|
||||
portal = CustomerPortal()
|
||||
values = portal._prepare_portal_layout_values()
|
||||
|
||||
# Récupérer l'employé associé à l'utilisateur connecté
|
||||
employee = get_employee_from_user(request.env.user)
|
||||
|
||||
if not employee:
|
||||
return request.redirect('/my')
|
||||
|
||||
# Obtenir la date du premier jour du mois sélectionné
|
||||
today = datetime.now().date()
|
||||
if date is None:
|
||||
selected_date = today.replace(day=1)
|
||||
elif isinstance(date, str):
|
||||
try:
|
||||
year, month = map(int, date.split('-'))
|
||||
selected_date = date(year, month, 1)
|
||||
except (ValueError, TypeError):
|
||||
selected_date = today.replace(day=1)
|
||||
else:
|
||||
selected_date = today.replace(day=1)
|
||||
|
||||
# Calculer le premier et dernier jour du mois
|
||||
first_day = selected_date
|
||||
# Obtenir le dernier jour du mois
|
||||
if first_day.month == 12:
|
||||
last_day = date(first_day.year + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
last_day = date(first_day.year, first_day.month + 1, 1) - timedelta(days=1)
|
||||
|
||||
# Convertir en datetime pour la recherche
|
||||
start_datetime = datetime.combine(first_day, datetime.min.time())
|
||||
end_datetime = datetime.combine(last_day, datetime.max.time())
|
||||
|
||||
# Récupérer les créneaux de planning pour le mois
|
||||
_logger.info("Recherche de créneaux pour le mois du %s au %s", first_day, last_day)
|
||||
domain = [
|
||||
('employee_id', '=', employee.id),
|
||||
'|', '|',
|
||||
'&', ('start_datetime', '>=', start_datetime), ('start_datetime', '<=', end_datetime),
|
||||
'&', ('end_datetime', '>=', start_datetime), ('end_datetime', '<=', end_datetime),
|
||||
'&', ('start_datetime', '<=', start_datetime), ('end_datetime', '>=', end_datetime),
|
||||
]
|
||||
_logger.info("Domaine de recherche: %s", domain)
|
||||
|
||||
planning_slots = request.env['planning.slot'].sudo().search(domain)
|
||||
_logger.info("Créneaux trouvés pour le mois: %s", planning_slots.ids)
|
||||
|
||||
# Organiser les données pour l'affichage du calendrier
|
||||
cal = calendar.monthcalendar(first_day.year, first_day.month)
|
||||
weeks = []
|
||||
|
||||
# Préparer les données pour chaque semaine du mois
|
||||
for week_days in cal:
|
||||
week = []
|
||||
for day_num in week_days:
|
||||
if day_num == 0:
|
||||
# Jour hors du mois
|
||||
week.append({
|
||||
'day': 0,
|
||||
'date': None,
|
||||
'slots': [],
|
||||
'is_today': False,
|
||||
'has_slots': False,
|
||||
})
|
||||
else:
|
||||
# Créer la date pour ce jour
|
||||
day_date = date(first_day.year, first_day.month, day_num)
|
||||
|
||||
# Filtrer les créneaux pour ce jour
|
||||
day_start = datetime.combine(day_date, datetime.min.time())
|
||||
day_end = datetime.combine(day_date, datetime.max.time())
|
||||
|
||||
day_slots = planning_slots.filtered(
|
||||
lambda s: (s.start_datetime <= day_end and s.end_datetime >= day_start)
|
||||
)
|
||||
|
||||
week.append({
|
||||
'day': day_num,
|
||||
'date': day_date,
|
||||
'slots': day_slots,
|
||||
'is_today': day_date == today,
|
||||
'has_slots': bool(day_slots),
|
||||
})
|
||||
weeks.append(week)
|
||||
|
||||
# Préparer les données pour la navigation
|
||||
if first_day.month == 1:
|
||||
prev_month = date(first_day.year - 1, 12, 1)
|
||||
else:
|
||||
prev_month = date(first_day.year, first_day.month - 1, 1)
|
||||
|
||||
if first_day.month == 12:
|
||||
next_month = date(first_day.year + 1, 1, 1)
|
||||
else:
|
||||
next_month = date(first_day.year, first_day.month + 1, 1)
|
||||
|
||||
values.update({
|
||||
'employee': employee,
|
||||
'planning_slots': planning_slots,
|
||||
'weeks': weeks,
|
||||
'month_name': selected_date.strftime('%B %Y'),
|
||||
'prev_month': prev_month.strftime('%Y-%m'),
|
||||
'next_month': next_month.strftime('%Y-%m'),
|
||||
'today': today,
|
||||
'date': selected_date.strftime('%Y-%m'),
|
||||
'page_name': 'planning_month',
|
||||
'default_url': '/my/planning/month',
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_my_planning_slots", values)
|
||||
652
portal_planning/controllers/planning_slot.py
Normal file
652
portal_planning/controllers/planning_slot.py
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import http, _, fields
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
|
||||
from odoo.exceptions import AccessError, MissingError, UserError
|
||||
from odoo.http import request
|
||||
from odoo import fields
|
||||
from datetime import datetime, timedelta, time, date
|
||||
from odoo.tools import format_duration
|
||||
import logging
|
||||
import calendar
|
||||
import dateutil.utils as date_utils
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def get_employee_from_user(user):
|
||||
"""Helper method to find the employee associated with the current user.
|
||||
This handles both internal users and portal users.
|
||||
"""
|
||||
employee = user.employee_id
|
||||
_logger.info("Recherche d'employé pour l'utilisateur %s (ID: %s)", user.name, user.id)
|
||||
_logger.info("Méthode 1 (employee_id): %s", employee.id if employee else None)
|
||||
|
||||
if not employee:
|
||||
# Chercher un employé directement lié à cet utilisateur
|
||||
employee = request.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1)
|
||||
_logger.info("Méthode 2 (user_id): %s", employee.id if employee else None)
|
||||
|
||||
if not employee:
|
||||
# Essayer de trouver via la relation inverse depuis res.users
|
||||
if hasattr(user, 'employee_ids') and user.employee_ids:
|
||||
employee = user.employee_ids[0]
|
||||
_logger.info("Méthode 3 (employee_ids): %s", employee.id if employee else None)
|
||||
|
||||
# Dernière tentative: chercher dans tous les employés si l'un d'eux a le même email
|
||||
if not employee and user.email:
|
||||
employee = request.env['hr.employee'].sudo().search([('work_email', '=', user.email)], limit=1)
|
||||
_logger.info("Méthode 4 (work_email): %s", employee.id if employee else None)
|
||||
|
||||
_logger.info("Employé final trouvé: %s (ID: %s)", employee.name if employee else None, employee.id if employee else None)
|
||||
|
||||
_logger.info(f"Employee found for user {user.name} (ID: {user.id}): {employee.name if employee else 'None'}")
|
||||
return employee
|
||||
|
||||
class PlanningSlotPortal(CustomerPortal):
|
||||
|
||||
@http.route(['/my/planning/slots', '/my/planning/slots/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_planning_slots(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw):
|
||||
"""Afficher la liste des créneaux de planning de l'utilisateur."""
|
||||
values = self._prepare_portal_layout_values()
|
||||
|
||||
# Récupérer l'employé associé à l'utilisateur connecté
|
||||
employee = get_employee_from_user(request.env.user)
|
||||
|
||||
if not employee:
|
||||
values.update({
|
||||
'planning_slots': [],
|
||||
'page_name': 'planning_slots',
|
||||
'default_url': '/my/planning/slots',
|
||||
'pager': {'page_count': 1, 'page': {'url': '/my/planning/slots'}},
|
||||
'searchbar_sortings': {},
|
||||
'sortby': 'date',
|
||||
'searchbar_filters': {},
|
||||
'filterby': 'all',
|
||||
'no_employee': True
|
||||
})
|
||||
return request.render("portal_planning.portal_my_planning_slots", values)
|
||||
|
||||
domain = [
|
||||
('employee_id', '=', employee.id)
|
||||
]
|
||||
|
||||
# Filtrer par date si spécifié
|
||||
if date_begin and date_end:
|
||||
domain += [
|
||||
('start_datetime', '>=', date_begin),
|
||||
('end_datetime', '<=', date_end)
|
||||
]
|
||||
|
||||
# Filtrer par état
|
||||
searchbar_filters = {
|
||||
'all': {'label': _('Tous'), 'domain': []},
|
||||
'draft': {'label': _('À confirmer'), 'domain': [('portal_status', '=', 'draft')]},
|
||||
'confirmed': {'label': _('Confirmés'), 'domain': [('portal_status', '=', 'confirmed')]},
|
||||
'modified': {'label': _('Modifiés'), 'domain': [('portal_status', '=', 'modified')]},
|
||||
'pending': {'label': _('En attente'), 'domain': [('portal_status', '=', 'pending_approval')]},
|
||||
}
|
||||
|
||||
# Filtre par défaut
|
||||
if not filterby:
|
||||
filterby = 'all'
|
||||
domain += searchbar_filters[filterby]['domain']
|
||||
|
||||
# Options de tri
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Date'), 'order': 'start_datetime desc'},
|
||||
'role': {'label': _('Rôle'), 'order': 'role_id'},
|
||||
'status': {'label': _('Statut'), 'order': 'portal_status'},
|
||||
}
|
||||
|
||||
# Tri par défaut
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
order = searchbar_sortings[sortby]['order']
|
||||
|
||||
# Pagination
|
||||
planning_slots_count = request.env['planning.slot'].sudo().search_count(domain)
|
||||
pager = portal_pager(
|
||||
url="/my/planning/slots",
|
||||
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'filterby': filterby},
|
||||
total=planning_slots_count,
|
||||
page=page,
|
||||
step=self._items_per_page
|
||||
)
|
||||
|
||||
# Récupérer les créneaux de planning
|
||||
planning_slots = request.env['planning.slot'].sudo().search(
|
||||
domain,
|
||||
order=order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset']
|
||||
)
|
||||
|
||||
# Préparer les valeurs pour le template
|
||||
values.update({
|
||||
'date': date_begin,
|
||||
'planning_slots': planning_slots,
|
||||
'page_name': 'planning_slots',
|
||||
'pager': pager,
|
||||
'default_url': '/my/planning/slots',
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'searchbar_filters': searchbar_filters,
|
||||
'sortby': sortby,
|
||||
'filterby': filterby,
|
||||
'format_duration': format_duration,
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_my_planning_slots", values)
|
||||
|
||||
@http.route(['/my/planning/slot/create'], type='http', auth="user", website=True)
|
||||
def portal_planning_slot_create(self, date=None, **kw):
|
||||
"""Page de création d'un créneau de planning"""
|
||||
values = self._prepare_portal_layout_values()
|
||||
employee = get_employee_from_user(request.env.user)
|
||||
|
||||
if not employee:
|
||||
values.update({
|
||||
'page_name': 'planning_slot_create',
|
||||
'no_employee': True
|
||||
})
|
||||
return request.render("portal_planning.portal_my_planning_slots", values)
|
||||
|
||||
# Préparer les valeurs par défaut
|
||||
default_values = {}
|
||||
if date:
|
||||
try:
|
||||
selected_date = datetime.strptime(date, '%Y-%m-%d').date()
|
||||
# Heures par défaut (8h à 17h)
|
||||
default_start = datetime.combine(selected_date, time(8, 0))
|
||||
default_end = datetime.combine(selected_date, time(17, 0))
|
||||
default_values.update({
|
||||
'start_datetime': default_start,
|
||||
'end_datetime': default_end,
|
||||
})
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Récupérer les rôles disponibles
|
||||
roles = request.env['planning.role'].sudo().search([])
|
||||
|
||||
values.update({
|
||||
'page_name': 'planning_slot_create',
|
||||
'employee': employee,
|
||||
'roles': roles,
|
||||
'default_values': default_values,
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_planning_slot_create", values)
|
||||
|
||||
@http.route(['/my/planning/slot/submit'], type='http', auth="user", website=True, methods=['POST'], csrf=True)
|
||||
def portal_planning_slot_submit(self, **kw):
|
||||
"""Traiter la soumission du formulaire de création de créneau"""
|
||||
# Récupérer directement l'employé de l'utilisateur courant
|
||||
employee = get_employee_from_user(request.env.user)
|
||||
|
||||
# Vérifier que l'employé existe
|
||||
if not employee:
|
||||
_logger.warning("Aucun employé trouvé pour l'utilisateur %s (ID: %s)",
|
||||
request.env.user.name, request.env.user.id)
|
||||
return request.redirect('/my/planning/slots')
|
||||
|
||||
employee_id = employee.id
|
||||
_logger.info("Utilisation de l'employé %s (ID: %s) pour la création du créneau",
|
||||
employee.name, employee_id)
|
||||
|
||||
# Convertir les dates
|
||||
try:
|
||||
start_datetime = datetime.strptime(kw.get('start_datetime'), '%Y-%m-%dT%H:%M')
|
||||
end_datetime = datetime.strptime(kw.get('end_datetime'), '%Y-%m-%dT%H:%M')
|
||||
except (ValueError, TypeError):
|
||||
return request.redirect('/my/planning/slots')
|
||||
|
||||
# Récupérer la ressource associée à l'employé
|
||||
resource = request.env['resource.resource'].sudo().search([('employee_id', '=', employee_id)], limit=1)
|
||||
if not resource:
|
||||
_logger.warning("Aucune ressource trouvée pour l'employé %s (ID: %s)", employee.name, employee_id)
|
||||
return request.redirect('/my/planning/slots')
|
||||
|
||||
_logger.info("Ressource trouvée pour l'employé %s (ID: %s): %s (ID: %s)",
|
||||
employee.name, employee_id, resource.name, resource.id)
|
||||
|
||||
# Créer le créneau de planning avec resource_id au lieu de employee_id
|
||||
vals = {
|
||||
'resource_id': resource.id, # Utiliser resource_id au lieu de employee_id
|
||||
'start_datetime': start_datetime,
|
||||
'end_datetime': end_datetime,
|
||||
'allocated_hours': (end_datetime - start_datetime).total_seconds() / 3600,
|
||||
'name': kw.get('name', ''),
|
||||
# Champs spécifiques à la création via le portail
|
||||
'portal_created': True,
|
||||
'portal_creation_date': fields.Datetime.now(),
|
||||
'portal_creation_user_id': request.env.user.id,
|
||||
'portal_creation_approved': 'pending',
|
||||
}
|
||||
|
||||
# Débogage des valeurs avant création
|
||||
_logger.info("Valeurs pour la création du créneau: %s", vals)
|
||||
|
||||
# Ajouter le rôle si spécifié
|
||||
role_id = kw.get('role_id')
|
||||
if role_id and role_id.isdigit() and int(role_id) > 0:
|
||||
vals['role_id'] = int(role_id)
|
||||
|
||||
# Créer le créneau
|
||||
try:
|
||||
slot = request.env['planning.slot'].sudo().create(vals)
|
||||
_logger.info("Créneau de planning créé avec succès: ID=%s, start=%s, end=%s, employee_id=%s",
|
||||
slot.id, slot.start_datetime, slot.end_datetime, slot.employee_id.id)
|
||||
|
||||
# Vérifier si le créneau est bien en base de données
|
||||
created_slot = request.env['planning.slot'].sudo().browse(slot.id)
|
||||
if created_slot.exists():
|
||||
_logger.info("Créneau %s existe en base de données avec employee_id=%s",
|
||||
created_slot.id, created_slot.employee_id.id)
|
||||
else:
|
||||
_logger.warning("Créneau %s n'existe pas en base de données après création", slot.id)
|
||||
|
||||
# Vérifier si le créneau est visible dans une recherche directe
|
||||
domain = [
|
||||
('id', '=', slot.id)
|
||||
]
|
||||
found_slots = request.env['planning.slot'].sudo().search(domain)
|
||||
_logger.info("Recherche directe du créneau %s: trouvé %s créneaux", slot.id, len(found_slots))
|
||||
except Exception as e:
|
||||
_logger.error("Erreur lors de la création du créneau de planning: %s", str(e))
|
||||
return request.redirect('/my/planning/slots')
|
||||
|
||||
# Rediriger vers la vue hebdomadaire
|
||||
return request.redirect('/my/planning/week?date_start=%s' % start_datetime.date().strftime('%Y-%m-%d'))
|
||||
|
||||
@http.route(['/my/planning/slot/<int:slot_id>'], type='http', auth="user", website=True)
|
||||
def portal_planning_slot_detail(self, slot_id, **kw):
|
||||
"""Afficher le détail d'un créneau de planning."""
|
||||
try:
|
||||
slot_sudo = request.env['planning.slot'].sudo().browse(slot_id)
|
||||
employee = get_employee_from_user(request.env.user)
|
||||
|
||||
if not slot_sudo.exists() or not employee or slot_sudo.employee_id.id != employee.id:
|
||||
_logger.warning(f"Access denied to slot {slot_id} for user {request.env.user.id}. Employee: {employee.id if employee else 'None'}, Slot employee: {slot_sudo.employee_id.id if slot_sudo.exists() else 'None'}")
|
||||
return request.redirect('/my/planning/slots')
|
||||
except (AccessError, MissingError) as e:
|
||||
_logger.error(f"Error accessing slot {slot_id}: {str(e)}")
|
||||
return request.redirect('/my/planning/slots')
|
||||
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'slot': slot_sudo,
|
||||
'page_name': 'planning_slot_detail',
|
||||
'format_duration': format_duration,
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_planning_slot", values)
|
||||
|
||||
@http.route(['/my/planning/slot/<int:slot_id>/confirm'], type='http', auth="user", website=True)
|
||||
def portal_planning_slot_confirm(self, slot_id, **kw):
|
||||
"""Confirmer un créneau de planning."""
|
||||
try:
|
||||
slot_sudo = request.env['planning.slot'].sudo().browse(slot_id)
|
||||
employee = get_employee_from_user(request.env.user)
|
||||
|
||||
if not slot_sudo.exists() or not employee or slot_sudo.employee_id.id != employee.id:
|
||||
_logger.warning(f"Access denied to confirm slot {slot_id} for user {request.env.user.id}")
|
||||
return request.redirect('/my/planning/slots')
|
||||
|
||||
# Vérifier si le créneau peut être confirmé
|
||||
if not slot_sudo.portal_confirmed:
|
||||
slot_sudo.action_confirm_portal()
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning/slots')
|
||||
except UserError as e:
|
||||
return request.render('portal_planning.portal_planning_error', {
|
||||
'error': str(e),
|
||||
'back_url': '/my/planning/slot/%s' % slot_id,
|
||||
})
|
||||
|
||||
return request.redirect('/my/planning/slot/%s' % slot_id)
|
||||
|
||||
@http.route(['/my/planning/slot/<int:slot_id>/modify'], type='http', auth="user", website=True, methods=['GET', 'POST'])
|
||||
def portal_planning_slot_modify(self, slot_id, **kw):
|
||||
"""Modifier un créneau de planning."""
|
||||
try:
|
||||
slot_sudo = request.env['planning.slot'].sudo().browse(slot_id)
|
||||
employee = get_employee_from_user(request.env.user)
|
||||
|
||||
if not slot_sudo.exists() or not employee or slot_sudo.employee_id.id != employee.id:
|
||||
_logger.warning(f"Access denied to modify slot {slot_id} for user {request.env.user.id}")
|
||||
return request.redirect('/my/planning/slots')
|
||||
|
||||
# Vérifier si le créneau peut être modifié
|
||||
if not slot_sudo.portal_can_modify:
|
||||
return request.redirect('/my/planning/slot/%s' % slot_id)
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning/slots')
|
||||
|
||||
if request.httprequest.method == 'POST':
|
||||
# Validation des données du formulaire
|
||||
start_datetime = kw.get('start_datetime')
|
||||
end_datetime = kw.get('end_datetime')
|
||||
role_id = kw.get('role_id')
|
||||
notes = kw.get('notes')
|
||||
|
||||
if not start_datetime or not end_datetime:
|
||||
return request.render('portal_planning.portal_planning_slot_modify', {
|
||||
'error': _('Veuillez remplir tous les champs obligatoires.'),
|
||||
'slot': slot_sudo,
|
||||
'roles': request.env['planning.role'].sudo().search([]),
|
||||
})
|
||||
|
||||
# Conversion des dates
|
||||
try:
|
||||
start_dt = datetime.strptime(start_datetime, '%Y-%m-%dT%H:%M')
|
||||
end_dt = datetime.strptime(end_datetime, '%Y-%m-%dT%H:%M')
|
||||
except ValueError:
|
||||
return request.render('portal_planning.portal_planning_slot_modify', {
|
||||
'error': _('Format de date invalide.'),
|
||||
'slot': slot_sudo,
|
||||
'roles': request.env['planning.role'].sudo().search([]),
|
||||
})
|
||||
|
||||
# Vérification que la date de fin est après la date de début
|
||||
if end_dt <= start_dt:
|
||||
return request.render('portal_planning.portal_planning_slot_modify', {
|
||||
'error': _('La date de fin doit être postérieure à la date de début.'),
|
||||
'slot': slot_sudo,
|
||||
'roles': request.env['planning.role'].sudo().search([]),
|
||||
})
|
||||
|
||||
# Préparation des valeurs pour la modification
|
||||
vals = {
|
||||
'start_datetime': start_dt,
|
||||
'end_datetime': end_dt,
|
||||
'portal_modification_notes': notes,
|
||||
}
|
||||
|
||||
if role_id and role_id != '':
|
||||
vals['role_id'] = int(role_id)
|
||||
|
||||
try:
|
||||
# Appliquer la modification
|
||||
slot_sudo.action_modify_portal(vals)
|
||||
return request.redirect('/my/planning/slot/%s' % slot_id)
|
||||
except UserError as e:
|
||||
return request.render('portal_planning.portal_planning_slot_modify', {
|
||||
'error': str(e),
|
||||
'slot': slot_sudo,
|
||||
'roles': request.env['planning.role'].sudo().search([]),
|
||||
})
|
||||
|
||||
# Affichage du formulaire de modification
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'slot': slot_sudo,
|
||||
'roles': request.env['planning.role'].sudo().search([]),
|
||||
'page_name': 'planning_slot_modify',
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_planning_slot_modify", values)
|
||||
|
||||
@http.route(['/my/planning/slot/<int:slot_id>/exchange'], type='http', auth="user", website=True, methods=['GET', 'POST'])
|
||||
def portal_planning_slot_exchange(self, slot_id, **kw):
|
||||
"""Créer une demande d'échange pour un créneau de planning."""
|
||||
try:
|
||||
slot_sudo = request.env['planning.slot'].sudo().browse(slot_id)
|
||||
employee = get_employee_from_user(request.env.user)
|
||||
|
||||
if not slot_sudo.exists() or not employee or slot_sudo.employee_id.id != employee.id:
|
||||
_logger.warning(f"Access denied to exchange slot {slot_id} for user {request.env.user.id}")
|
||||
return request.redirect('/my/planning/slots')
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning/slots')
|
||||
|
||||
if request.httprequest.method == 'POST':
|
||||
# Validation des données du formulaire
|
||||
is_open_request = kw.get('is_open_request') == 'on'
|
||||
target_slot_id = kw.get('target_slot_id')
|
||||
preferred_start = kw.get('preferred_start')
|
||||
preferred_end = kw.get('preferred_end')
|
||||
notes = kw.get('notes')
|
||||
|
||||
# Vérification des champs requis selon le type de demande
|
||||
if is_open_request:
|
||||
if not preferred_start or not preferred_end:
|
||||
return request.render('portal_planning.portal_planning_slot_exchange', {
|
||||
'error': _('Veuillez spécifier les dates préférées pour votre demande ouverte.'),
|
||||
'slot': slot_sudo,
|
||||
'available_slots': request.env['planning.slot'].sudo().search([
|
||||
('employee_id.user_id', '!=', request.env.user.id),
|
||||
('start_datetime', '>=', datetime.now())
|
||||
]),
|
||||
})
|
||||
|
||||
# Conversion des dates
|
||||
try:
|
||||
start_dt = datetime.strptime(preferred_start, '%Y-%m-%dT%H:%M')
|
||||
end_dt = datetime.strptime(preferred_end, '%Y-%m-%dT%H:%M')
|
||||
except ValueError:
|
||||
return request.render('portal_planning.portal_planning_slot_exchange', {
|
||||
'error': _('Format de date invalide.'),
|
||||
'slot': slot_sudo,
|
||||
'available_slots': request.env['planning.slot'].sudo().search([
|
||||
('employee_id.user_id', '!=', request.env.user.id),
|
||||
('start_datetime', '>=', datetime.now())
|
||||
]),
|
||||
})
|
||||
|
||||
# Vérification que la date de fin est après la date de début
|
||||
if end_dt <= start_dt:
|
||||
return request.render('portal_planning.portal_planning_slot_exchange', {
|
||||
'error': _('La date de fin doit être postérieure à la date de début.'),
|
||||
'slot': slot_sudo,
|
||||
'available_slots': request.env['planning.slot'].sudo().search([
|
||||
('employee_id.user_id', '!=', request.env.user.id),
|
||||
('start_datetime', '>=', datetime.now())
|
||||
]),
|
||||
})
|
||||
else:
|
||||
if not target_slot_id:
|
||||
return request.render('portal_planning.portal_planning_slot_exchange', {
|
||||
'error': _('Veuillez sélectionner un créneau à échanger.'),
|
||||
'slot': slot_sudo,
|
||||
'available_slots': request.env['planning.slot'].sudo().search([
|
||||
('employee_id.user_id', '!=', request.env.user.id),
|
||||
('start_datetime', '>=', datetime.now())
|
||||
]),
|
||||
})
|
||||
|
||||
# Préparation des valeurs pour la demande d'échange
|
||||
vals = {
|
||||
'source_slot_id': slot_id,
|
||||
'is_open_request': is_open_request,
|
||||
'notes': notes,
|
||||
}
|
||||
|
||||
if is_open_request:
|
||||
# Récupérer les dates préférées depuis le formulaire
|
||||
preferred_start = request.httprequest.form.get('preferred_start')
|
||||
preferred_end = request.httprequest.form.get('preferred_end')
|
||||
vals.update({
|
||||
'preferred_start': preferred_start,
|
||||
'preferred_end': preferred_end,
|
||||
})
|
||||
else:
|
||||
# S'assurer que target_slot_id est un entier valide
|
||||
if target_slot_id:
|
||||
vals['target_slot_id'] = int(target_slot_id)
|
||||
|
||||
try:
|
||||
# Créer la demande d'échange
|
||||
exchange = request.env['portal.planning.exchange'].sudo().create(vals)
|
||||
# Soumettre la demande
|
||||
exchange.action_submit()
|
||||
return request.redirect('/my/planning/exchanges')
|
||||
except UserError as e:
|
||||
return request.render('portal_planning.portal_planning_slot_exchange', {
|
||||
'error': str(e),
|
||||
'slot': slot_sudo,
|
||||
'available_slots': request.env['planning.slot'].sudo().search([
|
||||
('employee_id.user_id', '!=', request.env.user.id),
|
||||
('start_datetime', '>=', datetime.now())
|
||||
]),
|
||||
})
|
||||
|
||||
# Affichage du formulaire d'échange
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'slot': slot_sudo,
|
||||
'available_slots': request.env['planning.slot'].sudo().search([
|
||||
('employee_id.user_id', '!=', request.env.user.id),
|
||||
('start_datetime', '>=', datetime.now())
|
||||
]),
|
||||
'page_name': 'planning_slot_exchange',
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_planning_slot_exchange", values)
|
||||
|
||||
@http.route(['/my/planning/week'], type='http', auth="user", website=True)
|
||||
def portal_my_planning_week(self, date_start=None, **kw):
|
||||
"""Afficher la vue hebdomadaire du planning."""
|
||||
values = self._prepare_portal_layout_values()
|
||||
|
||||
# Récupérer l'employé associé à l'utilisateur connecté
|
||||
employee = get_employee_from_user(request.env.user)
|
||||
|
||||
if not employee:
|
||||
values.update({
|
||||
'planning_slots': [],
|
||||
'no_employee': True,
|
||||
'page_name': 'planning_week',
|
||||
'default_url': '/my/planning/week',
|
||||
})
|
||||
return request.render("portal_planning.portal_my_planning_slots", values)
|
||||
|
||||
# Déterminer la date de début de semaine (lundi)
|
||||
today = fields.Date.today()
|
||||
start_date = today
|
||||
if date_start:
|
||||
try:
|
||||
start_date = fields.Date.from_string(date_start)
|
||||
except ValueError:
|
||||
start_date = today
|
||||
|
||||
# S'assurer que start_date n'est pas None
|
||||
if not start_date:
|
||||
start_date = today
|
||||
|
||||
# Si pas de date spécifiée, prendre le lundi de la semaine courante
|
||||
weekday = start_date.weekday()
|
||||
start_date = start_date - timedelta(days=weekday)
|
||||
|
||||
# Calculer la date de fin de semaine (dimanche)
|
||||
end_date = start_date + timedelta(days=6)
|
||||
|
||||
# Convertir en datetime pour la recherche
|
||||
start_datetime = datetime.combine(start_date, datetime.min.time())
|
||||
end_datetime = datetime.combine(end_date, datetime.max.time())
|
||||
|
||||
# Récupérer les créneaux de planning pour la semaine
|
||||
domain = [
|
||||
('employee_id', '=', employee.id),
|
||||
'|', '|',
|
||||
'&', ('start_datetime', '>=', start_datetime), ('start_datetime', '<=', end_datetime),
|
||||
'&', ('end_datetime', '>=', start_datetime), ('end_datetime', '<=', end_datetime),
|
||||
'&', ('start_datetime', '<=', start_datetime), ('end_datetime', '>=', end_datetime),
|
||||
]
|
||||
|
||||
# Ajouter des logs pour débogage
|
||||
_logger.info("Recherche de créneaux pour la semaine du %s au %s", start_date, end_date)
|
||||
_logger.info("Domaine de recherche: %s", domain)
|
||||
|
||||
slots = request.env['planning.slot'].sudo().search(domain, order='start_datetime')
|
||||
|
||||
# Ajouter des logs pour débogage
|
||||
_logger.info("Créneaux trouvés pour la semaine: %s", slots.ids)
|
||||
|
||||
# Organiser les créneaux par jour
|
||||
days = []
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
day_slots = slots.filtered(lambda s: s.start_datetime.date() <= current_date and s.end_datetime.date() >= current_date)
|
||||
days.append({
|
||||
'date': current_date,
|
||||
'name': current_date.strftime('%A'),
|
||||
'day_slots': day_slots,
|
||||
'is_today': current_date == today,
|
||||
})
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Préparer les valeurs pour le template
|
||||
values.update({
|
||||
'employee': employee,
|
||||
'days': days,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'prev_week': (start_date - timedelta(days=7)).strftime('%Y-%m-%d'),
|
||||
'next_week': (start_date + timedelta(days=7)).strftime('%Y-%m-%d'),
|
||||
'today': today.strftime('%Y-%m-%d'),
|
||||
'page_name': 'planning_week',
|
||||
'default_url': '/my/planning/week',
|
||||
'format_duration': format_duration,
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_my_planning_slots", values)
|
||||
|
||||
@http.route(['/my/planning/day'], type='http', auth="user", website=True)
|
||||
def portal_my_planning_day(self, date=None, **kw):
|
||||
"""Afficher la vue quotidienne du planning."""
|
||||
values = self._prepare_portal_layout_values()
|
||||
|
||||
# Récupérer l'employé associé à l'utilisateur connecté
|
||||
employee = get_employee_from_user(request.env.user)
|
||||
|
||||
if not employee:
|
||||
values.update({
|
||||
'planning_slots': [],
|
||||
'no_employee': True,
|
||||
'page_name': 'planning_day',
|
||||
'default_url': '/my/planning/day',
|
||||
})
|
||||
return request.render("portal_planning.portal_my_planning_slots", values)
|
||||
|
||||
# Déterminer la date à afficher
|
||||
today = fields.Date.today()
|
||||
selected_date = today
|
||||
if date:
|
||||
try:
|
||||
selected_date = fields.Date.from_string(date)
|
||||
except ValueError:
|
||||
selected_date = today
|
||||
|
||||
# Convertir en datetime pour la recherche
|
||||
start_datetime = datetime.combine(selected_date, datetime.min.time())
|
||||
end_datetime = datetime.combine(selected_date, datetime.max.time())
|
||||
|
||||
# Récupérer les créneaux de planning pour le jour
|
||||
domain = [
|
||||
('employee_id', '=', employee.id),
|
||||
'|',
|
||||
'&', ('start_datetime', '>=', start_datetime), ('start_datetime', '<=', end_datetime),
|
||||
'&', ('end_datetime', '>=', start_datetime), ('end_datetime', '<=', end_datetime),
|
||||
]
|
||||
|
||||
slots = request.env['planning.slot'].sudo().search(domain, order='start_datetime')
|
||||
|
||||
# Préparer les valeurs pour le template
|
||||
values.update({
|
||||
'employee': employee,
|
||||
'planning_slots': slots, # Utiliser la même clé que dans la vue standard
|
||||
'selected_date': selected_date,
|
||||
'prev_day': (selected_date - timedelta(days=1)).strftime('%Y-%m-%d'),
|
||||
'next_day': (selected_date + timedelta(days=1)).strftime('%Y-%m-%d'),
|
||||
'today': today.strftime('%Y-%m-%d'),
|
||||
'is_today': selected_date == today,
|
||||
'page_name': 'planning_day',
|
||||
'default_url': '/my/planning/day',
|
||||
'format_duration': format_duration,
|
||||
'view_mode': 'day', # Indiquer le mode d'affichage
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_my_planning_slots", values)
|
||||
341
portal_planning/controllers/portal.py
Normal file
341
portal_planning/controllers/portal.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
from odoo import http, _, fields
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal as PortalCustomerPortal, pager as portal_pager
|
||||
from odoo.exceptions import AccessError, MissingError, UserError
|
||||
from datetime import datetime, timedelta
|
||||
from odoo.tools import format_duration
|
||||
|
||||
class PlanningPortal(PortalCustomerPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
|
||||
if 'planning_count' in counters:
|
||||
# Compter les demandes de planning pour l'utilisateur connecté
|
||||
planning_count = request.env['portal.planning.request'].sudo().search_count([
|
||||
('user_id', '=', request.env.user.id)
|
||||
])
|
||||
values['planning_count'] = planning_count
|
||||
|
||||
# Compter les créneaux de planning pour l'utilisateur connecté
|
||||
employee = request.env['hr.employee'].sudo().search([('user_id', '=', request.env.user.id)], limit=1)
|
||||
if employee:
|
||||
slots_count = request.env['planning.slot'].sudo().search_count([
|
||||
('employee_id', '=', employee.id)
|
||||
])
|
||||
values['planning_slots_count'] = slots_count
|
||||
else:
|
||||
values['planning_slots_count'] = 0
|
||||
|
||||
# Compter les demandes d'échange pour l'utilisateur connecté
|
||||
exchanges_count = request.env['portal.planning.exchange'].sudo().search_count([
|
||||
('source_user_id', '=', request.env.user.id)
|
||||
])
|
||||
values['planning_exchanges_count'] = exchanges_count
|
||||
|
||||
return values
|
||||
|
||||
@http.route(['/my/planning', '/my/planning/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_planning(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw):
|
||||
values = self._prepare_portal_layout_values()
|
||||
|
||||
domain = [
|
||||
('user_id', '=', request.env.user.id)
|
||||
]
|
||||
|
||||
# Filtrer par date si spécifié
|
||||
if date_begin and date_end:
|
||||
domain += [
|
||||
('start_datetime', '>=', date_begin),
|
||||
('end_datetime', '<=', date_end)
|
||||
]
|
||||
|
||||
# Filtrer par état
|
||||
searchbar_filters = {
|
||||
'all': {'label': _('Tous'), 'domain': []},
|
||||
'draft': {'label': _('Brouillon'), 'domain': [('state', '=', 'draft')]},
|
||||
'submitted': {'label': _('Soumis'), 'domain': [('state', '=', 'submitted')]},
|
||||
'approved': {'label': _('Approuvé'), 'domain': [('state', '=', 'approved')]},
|
||||
'rejected': {'label': _('Refusé'), 'domain': [('state', '=', 'rejected')]},
|
||||
'cancelled': {'label': _('Annulé'), 'domain': [('state', '=', 'cancelled')]},
|
||||
}
|
||||
|
||||
# Filtre par défaut
|
||||
if not filterby:
|
||||
filterby = 'all'
|
||||
domain += searchbar_filters[filterby]['domain']
|
||||
|
||||
# Options de tri
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Date'), 'order': 'start_datetime desc'},
|
||||
'name': {'label': _('Titre'), 'order': 'name'},
|
||||
'state': {'label': _('État'), 'order': 'state'},
|
||||
}
|
||||
|
||||
# Tri par défaut
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
order = searchbar_sortings[sortby]['order']
|
||||
|
||||
# Pagination
|
||||
planning_count = request.env['portal.planning.request'].sudo().search_count(domain)
|
||||
pager = portal_pager(
|
||||
url="/my/planning",
|
||||
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'filterby': filterby},
|
||||
total=planning_count,
|
||||
page=page,
|
||||
step=self._items_per_page
|
||||
)
|
||||
|
||||
# Récupérer les demandes de planning
|
||||
planning_requests = request.env['portal.planning.request'].sudo().search(
|
||||
domain,
|
||||
order=order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset']
|
||||
)
|
||||
|
||||
# Préparer les valeurs pour le template
|
||||
values.update({
|
||||
'date': date_begin,
|
||||
'planning_requests': planning_requests,
|
||||
'page_name': 'planning',
|
||||
'pager': pager,
|
||||
'default_url': '/my/planning',
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'searchbar_filters': searchbar_filters,
|
||||
'sortby': sortby,
|
||||
'filterby': filterby,
|
||||
'format_duration': format_duration,
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_my_planning", values)
|
||||
|
||||
@http.route(['/my/planning/<int:planning_id>'], type='http', auth="user", website=True)
|
||||
def portal_my_planning_detail(self, planning_id, **kw):
|
||||
try:
|
||||
planning_sudo = request.env['portal.planning.request'].sudo().browse(planning_id)
|
||||
if not planning_sudo.exists() or planning_sudo.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my/planning')
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'planning': planning_sudo,
|
||||
'page_name': 'planning_detail',
|
||||
'format_duration': format_duration,
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_my_planning_detail", values)
|
||||
|
||||
@http.route(['/my/planning/create'], type='http', auth="user", website=True, methods=['GET', 'POST'])
|
||||
def portal_planning_create(self, **kw):
|
||||
"""Création d'une nouvelle demande de planning."""
|
||||
if request.httprequest.method == 'POST':
|
||||
# Validation des données du formulaire
|
||||
name = kw.get('name')
|
||||
start_datetime = kw.get('start_datetime')
|
||||
end_datetime = kw.get('end_datetime')
|
||||
role_id = kw.get('role_id')
|
||||
description = kw.get('description')
|
||||
|
||||
if not name or not start_datetime or not end_datetime:
|
||||
return request.render('portal_planning.portal_planning_create', {
|
||||
'error': _('Veuillez remplir tous les champs obligatoires.'),
|
||||
'roles': request.env['planning.role'].sudo().search([]),
|
||||
})
|
||||
|
||||
# Conversion des dates
|
||||
try:
|
||||
start_dt = datetime.strptime(start_datetime, '%Y-%m-%dT%H:%M')
|
||||
end_dt = datetime.strptime(end_datetime, '%Y-%m-%dT%H:%M')
|
||||
except ValueError:
|
||||
return request.render('portal_planning.portal_planning_create', {
|
||||
'error': _('Format de date invalide.'),
|
||||
'roles': request.env['planning.role'].sudo().search([]),
|
||||
})
|
||||
|
||||
# Vérification que la date de fin est après la date de début
|
||||
if end_dt <= start_dt:
|
||||
return request.render('portal_planning.portal_planning_create', {
|
||||
'error': _('La date de fin doit être postérieure à la date de début.'),
|
||||
'roles': request.env['planning.role'].sudo().search([]),
|
||||
})
|
||||
|
||||
# Création de la demande de planning
|
||||
vals = {
|
||||
'name': name,
|
||||
'start_datetime': start_dt,
|
||||
'end_datetime': end_dt,
|
||||
'description': description,
|
||||
'employee_id': request.env.user.employee_id.id,
|
||||
'state': 'draft',
|
||||
}
|
||||
|
||||
if role_id and role_id != '':
|
||||
vals['role_id'] = int(role_id)
|
||||
|
||||
planning_request = request.env['portal.planning.request'].sudo().create(vals)
|
||||
|
||||
return request.redirect('/my/planning/%s' % planning_request.id)
|
||||
|
||||
# Affichage du formulaire de création
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'roles': request.env['planning.role'].sudo().search([]),
|
||||
'page_name': 'planning_create',
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_planning_create", values)
|
||||
|
||||
@http.route(['/my/planning/submit/<int:planning_id>'], type='http', auth="user", website=True)
|
||||
def portal_planning_submit(self, planning_id, **kw):
|
||||
"""Soumettre une demande de planning."""
|
||||
try:
|
||||
planning_sudo = request.env['portal.planning.request'].sudo().browse(planning_id)
|
||||
if not planning_sudo.exists() or planning_sudo.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
if planning_sudo.can_submit:
|
||||
planning_sudo.action_submit()
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
return request.redirect('/my/planning/%s' % planning_id)
|
||||
|
||||
@http.route(['/my/planning/cancel/<int:planning_id>'], type='http', auth="user", website=True)
|
||||
def portal_planning_cancel(self, planning_id, **kw):
|
||||
"""Annuler une demande de planning."""
|
||||
try:
|
||||
planning_sudo = request.env['portal.planning.request'].sudo().browse(planning_id)
|
||||
if not planning_sudo.exists() or planning_sudo.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
if planning_sudo.can_cancel:
|
||||
planning_sudo.action_cancel()
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
return request.redirect('/my/planning/%s' % planning_id)
|
||||
|
||||
@http.route(['/my/planning/reset/<int:planning_id>'], type='http', auth="user", website=True)
|
||||
def portal_planning_reset(self, planning_id, **kw):
|
||||
"""Remettre une demande de planning en brouillon."""
|
||||
try:
|
||||
planning_sudo = request.env['portal.planning.request'].sudo().browse(planning_id)
|
||||
if not planning_sudo.exists() or planning_sudo.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
if planning_sudo.state in ['rejected', 'cancelled']:
|
||||
planning_sudo.action_reset_to_draft()
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
return request.redirect('/my/planning/%s' % planning_id)
|
||||
|
||||
@http.route(['/my/planning/calendar'], type='http', auth="user", website=True)
|
||||
def portal_planning_calendar(self, **kw):
|
||||
"""Affichage du calendrier des créneaux de planning."""
|
||||
values = self._prepare_portal_layout_values()
|
||||
|
||||
# Récupérer l'employé correspondant à l'utilisateur connecté
|
||||
employee = request.env['hr.employee'].sudo().search([('user_id', '=', request.env.user.id)], limit=1)
|
||||
|
||||
values.update({
|
||||
'page_name': 'planning_calendar',
|
||||
'employee': employee,
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_planning_calendar", values)
|
||||
|
||||
@http.route(['/my/planning/view_slot/<int:planning_id>'], type='http', auth="user", website=True)
|
||||
def portal_planning_view_slot(self, planning_id, **kw):
|
||||
"""Voir le créneau de planning associé à une demande."""
|
||||
try:
|
||||
planning_sudo = request.env['portal.planning.request'].sudo().browse(planning_id)
|
||||
if not planning_sudo.exists() or planning_sudo.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
if planning_sudo.planning_slot_id:
|
||||
# Préparer les valeurs pour le template
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'planning': planning_sudo,
|
||||
'slot': planning_sudo.planning_slot_id,
|
||||
'page_name': 'planning_slot',
|
||||
'format_duration': format_duration,
|
||||
})
|
||||
|
||||
return request.render("portal_planning.portal_planning_slot", values)
|
||||
else:
|
||||
return request.redirect('/my/planning/%s' % planning_id)
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
@http.route(['/my/planning/unassign/<int:planning_id>'], type='http', auth="user", website=True)
|
||||
def portal_planning_unassign(self, planning_id, **kw):
|
||||
try:
|
||||
planning_request = request.env['portal.planning.request'].sudo().browse(planning_id)
|
||||
if not planning_request.exists() or planning_request.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
# Vérifier si le planning a un créneau associé
|
||||
if not planning_request.planning_slot_id:
|
||||
return request.redirect('/my/planning?error=no_slot_to_unassign')
|
||||
|
||||
# Vérifier si le désassignement est autorisé (à implémenter selon les règles métier)
|
||||
# Par exemple, vérifier si la date de début est suffisamment loin dans le futur
|
||||
if planning_request.planning_slot_id.start_datetime < datetime.now() + timedelta(days=1):
|
||||
return request.redirect('/my/planning?error=unassign_deadline_passed')
|
||||
|
||||
# Annuler la demande de planning
|
||||
planning_request.write({
|
||||
'state': 'cancelled',
|
||||
})
|
||||
|
||||
# Libérer le créneau si nécessaire
|
||||
if planning_request.planning_slot_id:
|
||||
planning_request.planning_slot_id.write({
|
||||
'employee_id': False,
|
||||
'portal_status': 'draft',
|
||||
})
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
return request.redirect('/my/planning?message=unassign_success')
|
||||
|
||||
@http.route(['/my/planning/switch/<int:planning_id>'], type='http', auth="user", website=True)
|
||||
def portal_planning_switch(self, planning_id, **kw):
|
||||
try:
|
||||
planning_request = request.env['portal.planning.request'].sudo().browse(planning_id)
|
||||
if not planning_request.exists() or planning_request.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
# Vérifier si le planning a un créneau associé
|
||||
if not planning_request.planning_slot_id:
|
||||
return request.redirect('/my/planning?error=no_slot_to_exchange')
|
||||
|
||||
# Créer une demande d'échange
|
||||
exchange_vals = {
|
||||
'name': f"Demande d'échange pour {planning_request.name}",
|
||||
'source_slot_id': planning_request.planning_slot_id.id,
|
||||
'source_user_id': request.env.user.id,
|
||||
'notes': 'Demande d\'échange initiée depuis le portail',
|
||||
'state': 'draft',
|
||||
}
|
||||
|
||||
exchange = request.env['portal.planning.exchange'].sudo().create(exchange_vals)
|
||||
|
||||
# Rediriger vers la page de la demande d'échange
|
||||
return request.redirect(f'/my/planning/exchange/{exchange.id}')
|
||||
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/planning')
|
||||
|
||||
return request.redirect('/my/planning/exchanges?message=exchange_created')
|
||||
13
portal_planning/data/ir_sequence_data.xml
Normal file
13
portal_planning/data/ir_sequence_data.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Séquence pour les demandes d'échange de planning -->
|
||||
<record id="seq_portal_planning_exchange" model="ir.sequence">
|
||||
<field name="name">Séquence pour les demandes d'échange de planning</field>
|
||||
<field name="code">portal.planning.exchange</field>
|
||||
<field name="prefix">ECH/%(year)s/</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
30
portal_planning/data/portal_planning_data.xml
Normal file
30
portal_planning/data/portal_planning_data.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Paramètres par défaut pour le module portal_planning -->
|
||||
<record id="portal_planning_settings" model="ir.config_parameter">
|
||||
<field name="key">portal_planning.auto_approve_modification</field>
|
||||
<field name="value">False</field>
|
||||
</record>
|
||||
|
||||
<record id="portal_planning_settings_timesheet" model="ir.config_parameter">
|
||||
<field name="key">portal_planning.auto_create_timesheet</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
<record id="portal_planning_settings_notification" model="ir.config_parameter">
|
||||
<field name="key">portal_planning.send_email_notification</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
<record id="portal_planning_settings_exchange" model="ir.config_parameter">
|
||||
<field name="key">portal_planning.allow_exchange</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
<record id="portal_planning_settings_min_hours" model="ir.config_parameter">
|
||||
<field name="key">portal_planning.min_hours_before_modification</field>
|
||||
<field name="value">24</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
6
portal_planning/models/__init__.py
Normal file
6
portal_planning/models/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from . import portal_planning
|
||||
from . import planning_slot
|
||||
from . import portal_planning_modification
|
||||
from . import portal_planning_exchange
|
||||
from . import account_analytic_line
|
||||
from . import resource_resource
|
||||
55
portal_planning/models/account_analytic_line.py
Normal file
55
portal_planning/models/account_analytic_line.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class AccountAnalyticLine(models.Model):
|
||||
"""Extension du modèle account.analytic.line pour les fonctionnalités du portail planning."""
|
||||
_inherit = 'account.analytic.line'
|
||||
|
||||
planning_slot_id = fields.Many2one(
|
||||
'planning.slot',
|
||||
string='Créneau de planning',
|
||||
help="Créneau de planning associé à cette entrée timesheet",
|
||||
index=True
|
||||
)
|
||||
portal_generated = fields.Boolean(
|
||||
string='Généré via le portail',
|
||||
default=False,
|
||||
help="Indique si cette entrée a été générée via le portail planning"
|
||||
)
|
||||
portal_confirmation_date = fields.Datetime(
|
||||
string='Date de confirmation',
|
||||
help="Date de la confirmation qui a généré cette entrée"
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Surcharge de la méthode create pour gérer la création d'entrées timesheet depuis le portail."""
|
||||
for vals in vals_list:
|
||||
# Si l'entrée est liée à un créneau de planning et générée via le portail
|
||||
if vals.get('planning_slot_id') and vals.get('portal_generated'):
|
||||
# Récupérer le créneau de planning
|
||||
slot = self.env['planning.slot'].browse(vals['planning_slot_id'])
|
||||
|
||||
# Ajouter le projet et la tâche si disponibles
|
||||
if hasattr(slot, 'project_id') and slot.project_id:
|
||||
vals['project_id'] = slot.project_id.id
|
||||
if hasattr(slot, 'task_id') and slot.task_id:
|
||||
vals['task_id'] = slot.task_id.id
|
||||
|
||||
# Ajouter l'employé et l'utilisateur si non spécifiés
|
||||
if not vals.get('employee_id') and slot.employee_id:
|
||||
vals['employee_id'] = slot.employee_id.id
|
||||
if not vals.get('user_id') and slot.employee_id.user_id:
|
||||
vals['user_id'] = slot.employee_id.user_id.id
|
||||
|
||||
return super(AccountAnalyticLine, self).create(vals_list)
|
||||
|
||||
def unlink(self):
|
||||
"""Surcharge de la méthode unlink pour empêcher la suppression d'entrées générées via le portail."""
|
||||
for line in self:
|
||||
if line.portal_generated and not self.env.user.has_group('planning.group_planning_manager'):
|
||||
raise models.UserError(_("Vous ne pouvez pas supprimer une entrée timesheet générée via le portail planning."))
|
||||
|
||||
return super(AccountAnalyticLine, self).unlink()
|
||||
309
portal_planning/models/planning_slot.py
Normal file
309
portal_planning/models/planning_slot.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools import float_round
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class PlanningSlot(models.Model):
|
||||
"""Extension du modèle planning.slot pour les fonctionnalités du portail."""
|
||||
_inherit = 'planning.slot'
|
||||
|
||||
# Champs pour la modification via le portail
|
||||
portal_can_modify = fields.Boolean(
|
||||
string='Modifiable via le portail',
|
||||
default=True,
|
||||
help="Si coché, l'utilisateur du portail peut modifier ce créneau"
|
||||
)
|
||||
portal_modified = fields.Boolean(
|
||||
string='Modifié via le portail',
|
||||
default=False,
|
||||
copy=False,
|
||||
help="Indique si le créneau a été modifié via le portail"
|
||||
)
|
||||
portal_modification_date = fields.Datetime(
|
||||
string='Date de modification',
|
||||
copy=False,
|
||||
help="Date de la dernière modification via le portail"
|
||||
)
|
||||
portal_modification_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Modifié par',
|
||||
copy=False,
|
||||
help="Utilisateur ayant effectué la dernière modification"
|
||||
)
|
||||
portal_modification_approved = fields.Selection([
|
||||
('pending', 'En attente'),
|
||||
('approved', 'Approuvé'),
|
||||
('rejected', 'Refusé')
|
||||
], string='Statut de modification', default=False, copy=False)
|
||||
portal_modification_notes = fields.Text(
|
||||
string='Notes de modification',
|
||||
copy=False,
|
||||
help="Notes concernant la modification"
|
||||
)
|
||||
portal_original_start = fields.Datetime(
|
||||
string='Début original',
|
||||
copy=False,
|
||||
help="Date de début originale avant modification"
|
||||
)
|
||||
portal_original_end = fields.Datetime(
|
||||
string='Fin originale',
|
||||
copy=False,
|
||||
help="Date de fin originale avant modification"
|
||||
)
|
||||
portal_original_role_id = fields.Many2one(
|
||||
'planning.role',
|
||||
string='Rôle original',
|
||||
copy=False,
|
||||
help="Rôle original avant modification"
|
||||
)
|
||||
|
||||
# Champs pour la création via le portail
|
||||
portal_created = fields.Boolean(
|
||||
string='Créé via le portail',
|
||||
default=False,
|
||||
copy=False,
|
||||
help="Indique si le créneau a été créé via le portail"
|
||||
)
|
||||
portal_creation_date = fields.Datetime(
|
||||
string='Date de création',
|
||||
copy=False,
|
||||
help="Date de création via le portail"
|
||||
)
|
||||
portal_creation_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Créé par',
|
||||
copy=False,
|
||||
help="Utilisateur ayant créé le créneau"
|
||||
)
|
||||
portal_creation_approved = fields.Selection([
|
||||
('pending', 'En attente'),
|
||||
('approved', 'Approuvé'),
|
||||
('rejected', 'Refusé')
|
||||
], string='Statut de création', default=False, copy=False)
|
||||
portal_creation_notes = fields.Text(
|
||||
string='Notes de création',
|
||||
copy=False,
|
||||
help="Notes concernant la création"
|
||||
)
|
||||
|
||||
# Champs pour la confirmation via le portail
|
||||
portal_confirmed = fields.Boolean(
|
||||
string='Confirmé via le portail',
|
||||
default=False,
|
||||
copy=False,
|
||||
help="Indique si le créneau a été confirmé via le portail"
|
||||
)
|
||||
portal_confirmation_date = fields.Datetime(
|
||||
string='Date de confirmation',
|
||||
copy=False,
|
||||
help="Date de confirmation via le portail"
|
||||
)
|
||||
|
||||
# Champs pour les timesheets générés
|
||||
timesheet_ids = fields.One2many(
|
||||
'account.analytic.line',
|
||||
'planning_slot_id',
|
||||
string='Feuilles de temps',
|
||||
copy=False,
|
||||
help="Feuilles de temps générées pour ce créneau"
|
||||
)
|
||||
|
||||
# Champs calculés
|
||||
portal_status = fields.Selection([
|
||||
('draft', 'À confirmer'),
|
||||
('confirmed', 'Confirmé'),
|
||||
('modified', 'Modifié'),
|
||||
('pending_approval', 'En attente d\'approbation')
|
||||
], string='Statut portail', compute='_compute_portal_status', store=True)
|
||||
|
||||
@api.depends('state', 'portal_confirmed', 'portal_modified', 'portal_modification_approved', 'portal_created', 'portal_creation_approved')
|
||||
def _compute_portal_status(self):
|
||||
"""Calcule le statut du créneau pour l'affichage dans le portail."""
|
||||
for slot in self:
|
||||
if slot.portal_created and slot.portal_creation_approved == 'pending':
|
||||
slot.portal_status = 'pending_approval'
|
||||
elif slot.portal_modified and slot.portal_modification_approved == 'pending':
|
||||
slot.portal_status = 'pending_approval'
|
||||
elif slot.portal_confirmed:
|
||||
slot.portal_status = 'confirmed'
|
||||
elif slot.portal_modified:
|
||||
slot.portal_status = 'modified'
|
||||
else:
|
||||
slot.portal_status = 'draft'
|
||||
|
||||
def action_confirm_portal(self):
|
||||
"""Confirme le créneau via le portail."""
|
||||
self.ensure_one()
|
||||
if self.state != 'published':
|
||||
return False
|
||||
|
||||
self.write({
|
||||
'portal_confirmed': True,
|
||||
'portal_confirmation_date': fields.Datetime.now(),
|
||||
'state': 'published' # Assure que l'état reste publié
|
||||
})
|
||||
|
||||
# Générer les timesheets si le paramètre est activé
|
||||
auto_generate_timesheet = self.env['ir.config_parameter'].sudo().get_param('portal_planning.auto_generate_timesheet', True)
|
||||
if auto_generate_timesheet and self.employee_id:
|
||||
self._generate_timesheet()
|
||||
|
||||
# Envoyer une notification au responsable
|
||||
if hasattr(self, 'message_post'):
|
||||
self.message_post(
|
||||
body=_('Créneau confirmé via le portail par %s') % (self.employee_id.name),
|
||||
message_type='notification',
|
||||
subtype_id=self.env.ref('mail.mt_note').id
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def action_modify_portal(self, vals):
|
||||
"""Modifie le créneau via le portail."""
|
||||
self.ensure_one()
|
||||
if not self.portal_can_modify:
|
||||
return False
|
||||
|
||||
# Sauvegarder les valeurs originales avant modification
|
||||
original_vals = {
|
||||
'portal_original_start': self.start_datetime,
|
||||
'portal_original_end': self.end_datetime,
|
||||
'portal_original_role_id': self.role_id.id,
|
||||
'portal_modified': True,
|
||||
'portal_modification_date': fields.Datetime.now(),
|
||||
'portal_modification_user_id': self.env.user.id,
|
||||
}
|
||||
|
||||
# Vérifier si la modification nécessite une approbation
|
||||
auto_approve = self.env['ir.config_parameter'].sudo().get_param('portal_planning.auto_approve_modifications', False)
|
||||
has_auto_approve = False
|
||||
if self.employee_id and self.employee_id.resource_id and hasattr(self.employee_id.resource_id, 'portal_auto_approve_modifications'):
|
||||
has_auto_approve = self.employee_id.resource_id.portal_auto_approve_modifications
|
||||
if auto_approve or has_auto_approve:
|
||||
original_vals['portal_modification_approved'] = 'approved'
|
||||
# Mettre à jour les valeurs du créneau
|
||||
update_vals = {**vals, **original_vals}
|
||||
self.write(update_vals)
|
||||
else:
|
||||
original_vals['portal_modification_approved'] = 'pending'
|
||||
# Sauvegarder les valeurs originales mais ne pas mettre à jour le créneau
|
||||
self.write(original_vals)
|
||||
|
||||
# Créer une entrée dans le modèle de modification pour suivi
|
||||
self.env['portal.planning.modification'].sudo().create({
|
||||
'slot_id': self.id,
|
||||
'user_id': self.env.user.id,
|
||||
'date': fields.Datetime.now(),
|
||||
'old_start': self.start_datetime,
|
||||
'old_end': self.end_datetime,
|
||||
'old_role_id': self.role_id.id,
|
||||
'new_start': vals.get('start_datetime', self.start_datetime),
|
||||
'new_end': vals.get('end_datetime', self.end_datetime),
|
||||
'new_role_id': vals.get('role_id', self.role_id.id),
|
||||
'state': 'pending',
|
||||
'notes': vals.get('portal_modification_notes', '')
|
||||
})
|
||||
|
||||
# Envoyer un message dans le chatter si le module mail est installé
|
||||
if hasattr(self, 'message_post'):
|
||||
self.message_post(
|
||||
body=_('Créneau modifié via le portail par %s') % (self.employee_id.name),
|
||||
message_type='notification',
|
||||
subtype_id=self.env.ref('mail.mt_note').id
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def _generate_timesheet(self):
|
||||
"""Génère les entrées timesheet pour ce créneau."""
|
||||
self.ensure_one()
|
||||
|
||||
# Vérifier si le module timesheet est installé
|
||||
if 'account.analytic.line' not in self.env:
|
||||
return False
|
||||
|
||||
# Vérifier si l'employé a un utilisateur associé
|
||||
if not hasattr(self, 'employee_id') or not self.employee_id or not self.employee_id.user_id:
|
||||
return False
|
||||
|
||||
# Vérifier si des entrées timesheet existent déjà pour ce créneau
|
||||
AnalyticLine = self.env['account.analytic.line'].sudo()
|
||||
|
||||
# Vérifier les champs disponibles dans le modèle account.analytic.line
|
||||
analytic_fields = AnalyticLine._fields
|
||||
date_field = 'date' if 'date' in analytic_fields else False
|
||||
employee_field = 'employee_id' if 'employee_id' in analytic_fields else False
|
||||
project_field = 'project_id' if 'project_id' in analytic_fields else False
|
||||
|
||||
if not date_field or not employee_field:
|
||||
return False
|
||||
|
||||
# Construire le domaine de recherche en fonction des champs disponibles
|
||||
domain = []
|
||||
if date_field and hasattr(self, 'start_datetime'):
|
||||
domain.append((date_field, '>=', self.start_datetime.date()))
|
||||
if date_field and hasattr(self, 'end_datetime'):
|
||||
domain.append((date_field, '<=', self.end_datetime.date()))
|
||||
if employee_field and hasattr(self, 'employee_id'):
|
||||
domain.append((employee_field, '=', self.employee_id.id))
|
||||
if project_field:
|
||||
domain.append((project_field, '!=', False))
|
||||
|
||||
existing_timesheets = AnalyticLine.search(domain) if domain else AnalyticLine
|
||||
|
||||
# Pour simplifier, nous créons une entrée timesheet pour la durée totale du créneau
|
||||
# Une implémentation plus complexe pourrait gérer les chevauchements partiels
|
||||
if not existing_timesheets:
|
||||
# Créer une nouvelle entrée timesheet
|
||||
timesheet_vals = {
|
||||
'name': self.name or _('Créneau de planning'),
|
||||
'planning_slot_id': self.id,
|
||||
'portal_generated': True,
|
||||
'portal_confirmation_date': self.portal_confirmation_date,
|
||||
}
|
||||
|
||||
# Ajouter les champs disponibles
|
||||
if date_field and hasattr(self, 'start_datetime'):
|
||||
timesheet_vals[date_field] = self.start_datetime.date()
|
||||
if 'unit_amount' in analytic_fields and hasattr(self, 'allocated_hours'):
|
||||
timesheet_vals['unit_amount'] = self.allocated_hours
|
||||
if employee_field and hasattr(self, 'employee_id'):
|
||||
timesheet_vals[employee_field] = self.employee_id.id
|
||||
if 'user_id' in analytic_fields and hasattr(self, 'employee_id') and self.employee_id.user_id:
|
||||
timesheet_vals['user_id'] = self.employee_id.user_id.id
|
||||
|
||||
# Ajouter le projet et la tâche si disponibles
|
||||
# Vérifier si le module project est installé et si les champs sont disponibles
|
||||
if project_field and self.env.registry.get('project.project'):
|
||||
# Vérifier si le modèle planning.slot a un champ project_id
|
||||
slot_fields = self._fields
|
||||
if 'project_id' in slot_fields and hasattr(self, 'project_id'):
|
||||
project_id = getattr(self, 'project_id', False)
|
||||
if project_id:
|
||||
timesheet_vals[project_field] = project_id.id
|
||||
if 'task_id' in analytic_fields and hasattr(self, 'task_id'):
|
||||
task_id = getattr(self, 'task_id', False)
|
||||
if task_id:
|
||||
timesheet_vals['task_id'] = task_id.id
|
||||
|
||||
# Créer l'entrée timesheet
|
||||
try:
|
||||
timesheet = AnalyticLine.create(timesheet_vals)
|
||||
return timesheet
|
||||
except (ValueError, UserError) as e:
|
||||
# Log l'erreur mais ne pas interrompre le processus
|
||||
_logger.error("Erreur lors de la création de l'entrée timesheet: %s", str(e))
|
||||
except Exception as e:
|
||||
# Log l'erreur plus grave mais ne pas interrompre le processus
|
||||
_logger.error("Erreur inattendue lors de la création de l'entrée timesheet: %s", str(e))
|
||||
return False
|
||||
|
||||
return True
|
||||
175
portal_planning/models/portal_planning.py
Normal file
175
portal_planning/models/portal_planning.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class PlanningRequest(models.Model):
|
||||
"""Modèle pour les demandes de planning créées par les utilisateurs du portail."""
|
||||
_name = 'portal.planning.request'
|
||||
_description = 'Planning Request'
|
||||
_rec_name = 'name'
|
||||
_order = 'start_datetime desc'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
name = fields.Char(string='Titre', required=True, tracking=True)
|
||||
description = fields.Text(string='Description', tracking=True)
|
||||
employee_id = fields.Many2one('hr.employee', string='Employé', required=True, default=lambda self: self.env.user.employee_id.id, tracking=True)
|
||||
user_id = fields.Many2one('res.users', string='Utilisateur', related='employee_id.user_id', store=True)
|
||||
role_id = fields.Many2one('planning.role', string='Rôle', tracking=True)
|
||||
start_datetime = fields.Datetime(string='Date de début', required=True, tracking=True)
|
||||
end_datetime = fields.Datetime(string='Date de fin', required=True, tracking=True)
|
||||
allocated_hours = fields.Float(string='Durée', compute='_compute_allocated_hours', store=True, readonly=False, tracking=True)
|
||||
state = fields.Selection([
|
||||
('draft', 'Brouillon'),
|
||||
('submitted', 'Soumis'),
|
||||
('approved', 'Approuvé'),
|
||||
('rejected', 'Refusé'),
|
||||
('cancelled', 'Annulé'),
|
||||
], string='État', default='draft', tracking=True, copy=False)
|
||||
company_id = fields.Many2one('res.company', string='Société', default=lambda self: self.env.company)
|
||||
planning_slot_id = fields.Many2one('planning.slot', string='Créneau de planning créé', readonly=True, copy=False)
|
||||
can_edit = fields.Boolean(compute='_compute_can_edit')
|
||||
can_submit = fields.Boolean(compute='_compute_can_submit')
|
||||
can_approve = fields.Boolean(compute='_compute_can_approve')
|
||||
can_reject = fields.Boolean(compute='_compute_can_reject')
|
||||
can_cancel = fields.Boolean(compute='_compute_can_cancel')
|
||||
|
||||
@api.depends('start_datetime', 'end_datetime')
|
||||
def _compute_allocated_hours(self):
|
||||
for request in self:
|
||||
if request.start_datetime and request.end_datetime:
|
||||
delta = request.end_datetime - request.start_datetime
|
||||
request.allocated_hours = delta.total_seconds() / 3600
|
||||
else:
|
||||
request.allocated_hours = 0
|
||||
|
||||
@api.depends('state', 'user_id')
|
||||
def _compute_can_edit(self):
|
||||
for request in self:
|
||||
request.can_edit = request.state == 'draft' and request.user_id.id == self.env.user.id
|
||||
|
||||
@api.depends('state', 'user_id')
|
||||
def _compute_can_submit(self):
|
||||
for request in self:
|
||||
request.can_submit = request.state == 'draft' and request.user_id.id == self.env.user.id
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_can_approve(self):
|
||||
is_manager = self.env.user.has_group('planning.group_planning_manager')
|
||||
for request in self:
|
||||
request.can_approve = request.state == 'submitted' and is_manager
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_can_reject(self):
|
||||
is_manager = self.env.user.has_group('planning.group_planning_manager')
|
||||
for request in self:
|
||||
request.can_reject = request.state == 'submitted' and is_manager
|
||||
|
||||
@api.depends('state', 'user_id')
|
||||
def _compute_can_cancel(self):
|
||||
is_manager = self.env.user.has_group('planning.group_planning_manager')
|
||||
for request in self:
|
||||
request.can_cancel = (request.state in ['draft', 'submitted'] and request.user_id.id == self.env.user.id) or \
|
||||
(request.state in ['submitted', 'approved'] and is_manager)
|
||||
|
||||
def action_submit(self):
|
||||
self.ensure_one()
|
||||
if self.can_submit:
|
||||
self.write({'state': 'submitted'})
|
||||
# Envoyer une notification au responsable
|
||||
self.message_post(
|
||||
body=_('Demande de planning soumise par %s') % self.employee_id.name,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
return True
|
||||
|
||||
def action_approve(self):
|
||||
self.ensure_one()
|
||||
if self.can_approve:
|
||||
# Créer un créneau de planning réel
|
||||
slot_vals = {
|
||||
'employee_id': self.employee_id.id,
|
||||
'role_id': self.role_id.id,
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'allocated_hours': self.allocated_hours,
|
||||
'name': self.description,
|
||||
'state': 'published',
|
||||
}
|
||||
slot = self.env['planning.slot'].sudo().create(slot_vals)
|
||||
|
||||
# Mettre à jour la demande
|
||||
self.write({
|
||||
'state': 'approved',
|
||||
'planning_slot_id': slot.id
|
||||
})
|
||||
|
||||
# Envoyer une notification à l'employé
|
||||
self.message_post(
|
||||
body=_('Demande de planning approuvée. Un créneau de planning a été créé.'),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
return True
|
||||
|
||||
def action_reject(self):
|
||||
self.ensure_one()
|
||||
if self.can_reject:
|
||||
self.write({'state': 'rejected'})
|
||||
# Envoyer une notification à l'employé
|
||||
self.message_post(
|
||||
body=_('Demande de planning refusée.'),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
return True
|
||||
|
||||
def action_cancel(self):
|
||||
self.ensure_one()
|
||||
if self.can_cancel:
|
||||
self.write({'state': 'cancelled'})
|
||||
# Envoyer une notification
|
||||
self.message_post(
|
||||
body=_('Demande de planning annulée.'),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
return True
|
||||
|
||||
def action_reset_to_draft(self):
|
||||
self.ensure_one()
|
||||
if self.state in ['rejected', 'cancelled'] and self.user_id.id == self.env.user.id:
|
||||
self.write({'state': 'draft'})
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _sync_planning_slots(self):
|
||||
"""Synchronise les créneaux de planning avec les demandes approuvées."""
|
||||
# Cette méthode peut être utilisée pour synchroniser les demandes approuvées
|
||||
# avec les créneaux de planning existants si nécessaire
|
||||
return True
|
||||
|
||||
def action_view_planning_slot(self):
|
||||
"""Afficher le créneau de planning associé à cette demande."""
|
||||
self.ensure_one()
|
||||
if not self.planning_slot_id:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Information'),
|
||||
'message': _('Aucun créneau de planning n\'est associé à cette demande.'),
|
||||
'type': 'info',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'name': _('Créneau de planning'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'planning.slot',
|
||||
'res_id': self.planning_slot_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
417
portal_planning/models/portal_planning_exchange.py
Normal file
417
portal_planning/models/portal_planning_exchange.py
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
class PlanningExchange(models.Model):
|
||||
"""Modèle pour les demandes d'échange de créneaux de planning via le portail."""
|
||||
_name = 'portal.planning.exchange'
|
||||
_description = 'Échange de Planning via Portail'
|
||||
_rec_name = 'name'
|
||||
_order = 'date desc'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
name = fields.Char(
|
||||
string='Référence',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: _('Nouvel échange')
|
||||
)
|
||||
date = fields.Datetime(
|
||||
string='Date de demande',
|
||||
required=True,
|
||||
default=fields.Datetime.now,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
# Créneau source (celui que l'utilisateur souhaite échanger)
|
||||
source_slot_id = fields.Many2one(
|
||||
'planning.slot',
|
||||
string='Créneau à échanger',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
tracking=True
|
||||
)
|
||||
source_employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employé source',
|
||||
related='source_slot_id.employee_id',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
source_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Demandeur',
|
||||
required=True,
|
||||
default=lambda self: self.env.user.id,
|
||||
tracking=True
|
||||
)
|
||||
source_start = fields.Datetime(
|
||||
string='Début source',
|
||||
related='source_slot_id.start_datetime',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
source_end = fields.Datetime(
|
||||
string='Fin source',
|
||||
related='source_slot_id.end_datetime',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
source_role_id = fields.Many2one(
|
||||
'planning.role',
|
||||
string='Rôle source',
|
||||
related='source_slot_id.role_id',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# Créneau cible (celui avec lequel l'utilisateur souhaite échanger)
|
||||
target_slot_id = fields.Many2one(
|
||||
'planning.slot',
|
||||
string='Créneau souhaité',
|
||||
ondelete='cascade',
|
||||
tracking=True
|
||||
)
|
||||
target_employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employé cible',
|
||||
related='target_slot_id.employee_id',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
target_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Destinataire',
|
||||
related='target_employee_id.user_id',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
target_start = fields.Datetime(
|
||||
string='Début cible',
|
||||
related='target_slot_id.start_datetime',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
target_end = fields.Datetime(
|
||||
string='Fin cible',
|
||||
related='target_slot_id.end_datetime',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
target_role_id = fields.Many2one(
|
||||
'planning.role',
|
||||
string='Rôle cible',
|
||||
related='target_slot_id.role_id',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# Dates libres (si l'utilisateur souhaite échanger avec n'importe qui)
|
||||
is_open_request = fields.Boolean(
|
||||
string='Demande ouverte',
|
||||
default=False,
|
||||
help="Si activé, la demande est ouverte à tous les employés disponibles"
|
||||
)
|
||||
preferred_start = fields.Datetime(
|
||||
string='Début préféré',
|
||||
help="Date de début préférée pour l'échange (si demande ouverte)"
|
||||
)
|
||||
preferred_end = fields.Datetime(
|
||||
string='Fin préférée',
|
||||
help="Date de fin préférée pour l'échange (si demande ouverte)"
|
||||
)
|
||||
|
||||
# Suivi de la demande
|
||||
state = fields.Selection([
|
||||
('draft', 'Brouillon'),
|
||||
('pending', 'En attente'),
|
||||
('accepted', 'Accepté par l\'employé'),
|
||||
('approved', 'Approuvé'),
|
||||
('rejected', 'Refusé'),
|
||||
('cancelled', 'Annulé')
|
||||
], string='État', default='draft', tracking=True, copy=False)
|
||||
|
||||
approver_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Approuvé/Refusé par',
|
||||
copy=False,
|
||||
tracking=True
|
||||
)
|
||||
approval_date = fields.Datetime(
|
||||
string='Date d\'approbation/refus',
|
||||
copy=False,
|
||||
tracking=True
|
||||
)
|
||||
notes = fields.Text(
|
||||
string='Notes',
|
||||
tracking=True
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Société',
|
||||
default=lambda self: self.env.company
|
||||
)
|
||||
|
||||
# Champs calculés
|
||||
can_submit = fields.Boolean(
|
||||
compute='_compute_can_submit',
|
||||
string='Peut soumettre',
|
||||
help="Indique si l'utilisateur actuel peut soumettre cette demande"
|
||||
)
|
||||
can_accept = fields.Boolean(
|
||||
compute='_compute_can_accept',
|
||||
string='Peut accepter',
|
||||
help="Indique si l'utilisateur actuel peut accepter cette demande"
|
||||
)
|
||||
can_approve = fields.Boolean(
|
||||
compute='_compute_can_approve',
|
||||
string='Peut approuver',
|
||||
help="Indique si l'utilisateur actuel peut approuver cette demande"
|
||||
)
|
||||
can_reject = fields.Boolean(
|
||||
compute='_compute_can_reject',
|
||||
string='Peut refuser',
|
||||
help="Indique si l'utilisateur actuel peut refuser cette demande"
|
||||
)
|
||||
can_cancel = fields.Boolean(
|
||||
compute='_compute_can_cancel',
|
||||
string='Peut annuler',
|
||||
help="Indique si l'utilisateur actuel peut annuler cette demande"
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Génère une séquence unique pour chaque nouvelle demande d'échange."""
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('Nouvel échange')) == _('Nouvel échange'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('portal.planning.exchange') or _('Nouvel échange')
|
||||
return super(PlanningExchange, self).create(vals_list)
|
||||
|
||||
@api.depends('state', 'source_user_id')
|
||||
def _compute_can_submit(self):
|
||||
"""Détermine si l'utilisateur actuel peut soumettre cette demande."""
|
||||
for exchange in self:
|
||||
exchange.can_submit = (
|
||||
exchange.state == 'draft' and
|
||||
exchange.source_user_id.id == self.env.user.id
|
||||
)
|
||||
|
||||
@api.depends('state', 'target_user_id')
|
||||
def _compute_can_accept(self):
|
||||
"""Détermine si l'utilisateur actuel peut accepter cette demande."""
|
||||
for exchange in self:
|
||||
exchange.can_accept = (
|
||||
exchange.state == 'pending' and
|
||||
exchange.target_user_id.id == self.env.user.id
|
||||
)
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_can_approve(self):
|
||||
"""Détermine si l'utilisateur actuel peut approuver cette demande."""
|
||||
is_manager = self.env.user.has_group('planning.group_planning_manager')
|
||||
for exchange in self:
|
||||
exchange.can_approve = exchange.state == 'accepted' and is_manager
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_can_reject(self):
|
||||
"""Détermine si l'utilisateur actuel peut refuser cette demande."""
|
||||
is_manager = self.env.user.has_group('planning.group_planning_manager')
|
||||
for exchange in self:
|
||||
exchange.can_reject = exchange.state in ['pending', 'accepted'] and (
|
||||
is_manager or
|
||||
exchange.target_user_id.id == self.env.user.id
|
||||
)
|
||||
|
||||
@api.depends('state', 'source_user_id')
|
||||
def _compute_can_cancel(self):
|
||||
"""Détermine si l'utilisateur actuel peut annuler cette demande."""
|
||||
is_manager = self.env.user.has_group('planning.group_planning_manager')
|
||||
for exchange in self:
|
||||
exchange.can_cancel = (
|
||||
exchange.state in ['draft', 'pending', 'accepted'] and
|
||||
(exchange.source_user_id.id == self.env.user.id or is_manager)
|
||||
)
|
||||
|
||||
def action_submit(self):
|
||||
"""Soumet la demande d'échange."""
|
||||
self.ensure_one()
|
||||
if not self.can_submit:
|
||||
return False
|
||||
|
||||
# Vérifier que le créneau source appartient bien à l'utilisateur
|
||||
if self.source_slot_id.employee_id.user_id.id != self.env.user.id:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Erreur'),
|
||||
'message': _('Vous ne pouvez échanger que vos propres créneaux de planning.'),
|
||||
'type': 'danger',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
# Vérifier que le créneau cible existe si ce n'est pas une demande ouverte
|
||||
if not self.is_open_request and not self.target_slot_id:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Erreur'),
|
||||
'message': _('Veuillez sélectionner un créneau cible ou activer la demande ouverte.'),
|
||||
'type': 'danger',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
# Vérifier que les dates préférées sont renseignées si c'est une demande ouverte
|
||||
if self.is_open_request and (not self.preferred_start or not self.preferred_end):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Erreur'),
|
||||
'message': _('Veuillez renseigner les dates préférées pour une demande ouverte.'),
|
||||
'type': 'danger',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
# Mettre à jour l'état de la demande
|
||||
self.write({
|
||||
'state': 'pending',
|
||||
'date': fields.Datetime.now()
|
||||
})
|
||||
|
||||
# Envoyer une notification au destinataire si ce n'est pas une demande ouverte
|
||||
if not self.is_open_request and self.target_user_id:
|
||||
self.message_post(
|
||||
body=_('%s souhaite échanger son créneau du %s avec votre créneau du %s') % (
|
||||
self.source_employee_id.name,
|
||||
self.source_start.strftime('%d/%m/%Y %H:%M'),
|
||||
self.target_start.strftime('%d/%m/%Y %H:%M')
|
||||
),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
partner_ids=[self.target_user_id.partner_id.id]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def action_accept(self):
|
||||
"""Accepte la demande d'échange (par l'employé cible)."""
|
||||
self.ensure_one()
|
||||
if not self.can_accept:
|
||||
return False
|
||||
|
||||
# Mettre à jour l'état de la demande
|
||||
self.write({
|
||||
'state': 'accepted',
|
||||
})
|
||||
|
||||
# Envoyer une notification au demandeur
|
||||
self.message_post(
|
||||
body=_('%s a accepté votre demande d\'échange. En attente d\'approbation par un responsable.') % self.target_employee_id.name,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
partner_ids=[self.source_user_id.partner_id.id]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def action_approve(self):
|
||||
"""Approuve la demande d'échange (par un responsable)."""
|
||||
self.ensure_one()
|
||||
if not self.can_approve:
|
||||
return False
|
||||
|
||||
# Échanger les employés des créneaux
|
||||
source_employee = self.source_slot_id.employee_id
|
||||
target_employee = self.target_slot_id.employee_id
|
||||
|
||||
self.source_slot_id.write({
|
||||
'employee_id': target_employee.id,
|
||||
'portal_confirmed': False, # Nécessite une nouvelle confirmation
|
||||
})
|
||||
|
||||
self.target_slot_id.write({
|
||||
'employee_id': source_employee.id,
|
||||
'portal_confirmed': False, # Nécessite une nouvelle confirmation
|
||||
})
|
||||
|
||||
# Mettre à jour l'état de la demande
|
||||
self.write({
|
||||
'state': 'approved',
|
||||
'approver_id': self.env.user.id,
|
||||
'approval_date': fields.Datetime.now()
|
||||
})
|
||||
|
||||
# Envoyer des notifications aux employés
|
||||
self.message_post(
|
||||
body=_('Votre demande d\'échange a été approuvée par %s. Les créneaux ont été échangés.') % self.env.user.name,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
partner_ids=[self.source_user_id.partner_id.id, self.target_user_id.partner_id.id]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def action_reject(self):
|
||||
"""Refuse la demande d'échange."""
|
||||
self.ensure_one()
|
||||
if not self.can_reject:
|
||||
return False
|
||||
|
||||
# Mettre à jour l'état de la demande
|
||||
self.write({
|
||||
'state': 'rejected',
|
||||
'approver_id': self.env.user.id if self.env.user.has_group('planning.group_planning_manager') else False,
|
||||
'approval_date': fields.Datetime.now() if self.env.user.has_group('planning.group_planning_manager') else False
|
||||
})
|
||||
|
||||
# Envoyer une notification au demandeur
|
||||
if self.env.user.id == self.target_user_id.id:
|
||||
message = _('%s a refusé votre demande d\'échange.') % self.target_employee_id.name
|
||||
else:
|
||||
message = _('Votre demande d\'échange a été refusée par %s.') % self.env.user.name
|
||||
|
||||
self.message_post(
|
||||
body=message,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
partner_ids=[self.source_user_id.partner_id.id]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def action_cancel(self):
|
||||
"""Annule la demande d'échange."""
|
||||
self.ensure_one()
|
||||
if not self.can_cancel:
|
||||
return False
|
||||
|
||||
# Mettre à jour l'état de la demande
|
||||
self.write({
|
||||
'state': 'cancelled',
|
||||
})
|
||||
|
||||
# Envoyer une notification
|
||||
if self.env.user.id == self.source_user_id.id:
|
||||
message = _('Vous avez annulé votre demande d\'échange.')
|
||||
partners = []
|
||||
if self.target_user_id:
|
||||
partners.append(self.target_user_id.partner_id.id)
|
||||
else:
|
||||
message = _('Votre demande d\'échange a été annulée par %s.') % self.env.user.name
|
||||
partners = [self.source_user_id.partner_id.id]
|
||||
|
||||
if partners:
|
||||
self.message_post(
|
||||
body=message,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
partner_ids=partners
|
||||
)
|
||||
|
||||
return True
|
||||
249
portal_planning/models/portal_planning_modification.py
Normal file
249
portal_planning/models/portal_planning_modification.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class PlanningModification(models.Model):
|
||||
"""Modèle pour suivre les modifications de créneaux de planning via le portail."""
|
||||
_name = 'portal.planning.modification'
|
||||
_description = 'Modification de Planning via Portail'
|
||||
_rec_name = 'slot_id'
|
||||
_order = 'date desc'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
slot_id = fields.Many2one(
|
||||
'planning.slot',
|
||||
string='Créneau de planning',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
tracking=True
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Demandé par',
|
||||
required=True,
|
||||
default=lambda self: self.env.user.id,
|
||||
tracking=True
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employé',
|
||||
related='slot_id.employee_id',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
date = fields.Datetime(
|
||||
string='Date de demande',
|
||||
required=True,
|
||||
default=fields.Datetime.now,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
# Valeurs originales
|
||||
old_start = fields.Datetime(
|
||||
string='Début original',
|
||||
required=True,
|
||||
tracking=True
|
||||
)
|
||||
old_end = fields.Datetime(
|
||||
string='Fin originale',
|
||||
required=True,
|
||||
tracking=True
|
||||
)
|
||||
old_role_id = fields.Many2one(
|
||||
'planning.role',
|
||||
string='Rôle original',
|
||||
tracking=True
|
||||
)
|
||||
|
||||
# Nouvelles valeurs
|
||||
new_start = fields.Datetime(
|
||||
string='Nouveau début',
|
||||
required=True,
|
||||
tracking=True
|
||||
)
|
||||
new_end = fields.Datetime(
|
||||
string='Nouvelle fin',
|
||||
required=True,
|
||||
tracking=True
|
||||
)
|
||||
new_role_id = fields.Many2one(
|
||||
'planning.role',
|
||||
string='Nouveau rôle',
|
||||
tracking=True
|
||||
)
|
||||
|
||||
# Suivi de la demande
|
||||
state = fields.Selection([
|
||||
('pending', 'En attente'),
|
||||
('approved', 'Approuvé'),
|
||||
('rejected', 'Refusé'),
|
||||
('cancelled', 'Annulé')
|
||||
], string='État', default='pending', tracking=True, copy=False)
|
||||
approver_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Approuvé/Refusé par',
|
||||
copy=False,
|
||||
tracking=True
|
||||
)
|
||||
approval_date = fields.Datetime(
|
||||
string='Date d\'approbation/refus',
|
||||
copy=False,
|
||||
tracking=True
|
||||
)
|
||||
notes = fields.Text(
|
||||
string='Notes',
|
||||
tracking=True
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Société',
|
||||
default=lambda self: self.env.company
|
||||
)
|
||||
|
||||
# Champs calculés
|
||||
allocated_hours = fields.Float(
|
||||
string='Heures allouées',
|
||||
compute='_compute_allocated_hours',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
can_approve = fields.Boolean(
|
||||
compute='_compute_can_approve',
|
||||
string='Peut approuver',
|
||||
help="Indique si l'utilisateur actuel peut approuver cette demande"
|
||||
)
|
||||
can_reject = fields.Boolean(
|
||||
compute='_compute_can_reject',
|
||||
string='Peut refuser',
|
||||
help="Indique si l'utilisateur actuel peut refuser cette demande"
|
||||
)
|
||||
can_cancel = fields.Boolean(
|
||||
compute='_compute_can_cancel',
|
||||
string='Peut annuler',
|
||||
help="Indique si l'utilisateur actuel peut annuler cette demande"
|
||||
)
|
||||
|
||||
@api.depends('new_start', 'new_end')
|
||||
def _compute_allocated_hours(self):
|
||||
"""Calcule le nombre d'heures allouées pour la modification."""
|
||||
for modification in self:
|
||||
if modification.new_start and modification.new_end:
|
||||
delta = modification.new_end - modification.new_start
|
||||
modification.allocated_hours = delta.total_seconds() / 3600
|
||||
else:
|
||||
modification.allocated_hours = 0
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_can_approve(self):
|
||||
"""Détermine si l'utilisateur actuel peut approuver cette demande."""
|
||||
is_manager = self.env.user.has_group('planning.group_planning_manager')
|
||||
for modification in self:
|
||||
modification.can_approve = modification.state == 'pending' and is_manager
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_can_reject(self):
|
||||
"""Détermine si l'utilisateur actuel peut refuser cette demande."""
|
||||
is_manager = self.env.user.has_group('planning.group_planning_manager')
|
||||
for modification in self:
|
||||
modification.can_reject = modification.state == 'pending' and is_manager
|
||||
|
||||
@api.depends('state', 'user_id')
|
||||
def _compute_can_cancel(self):
|
||||
"""Détermine si l'utilisateur actuel peut annuler cette demande."""
|
||||
is_manager = self.env.user.has_group('planning.group_planning_manager')
|
||||
for modification in self:
|
||||
modification.can_cancel = (
|
||||
modification.state == 'pending' and
|
||||
(modification.user_id.id == self.env.user.id or is_manager)
|
||||
)
|
||||
|
||||
def action_approve(self):
|
||||
"""Approuve la demande de modification."""
|
||||
self.ensure_one()
|
||||
if not self.can_approve:
|
||||
return False
|
||||
|
||||
# Mettre à jour le créneau de planning
|
||||
self.slot_id.write({
|
||||
'start_datetime': self.new_start,
|
||||
'end_datetime': self.new_end,
|
||||
'allocated_hours': self.allocated_hours,
|
||||
'role_id': self.new_role_id.id if self.new_role_id else self.slot_id.role_id.id,
|
||||
'portal_modification_approved': 'approved',
|
||||
'portal_confirmed': False, # Nécessite une nouvelle confirmation
|
||||
})
|
||||
|
||||
# Mettre à jour la demande
|
||||
self.write({
|
||||
'state': 'approved',
|
||||
'approver_id': self.env.user.id,
|
||||
'approval_date': fields.Datetime.now()
|
||||
})
|
||||
|
||||
# Envoyer une notification à l'employé
|
||||
self.message_post(
|
||||
body=_('Demande de modification approuvée par %s') % self.env.user.name,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
partner_ids=[self.user_id.partner_id.id]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def action_reject(self):
|
||||
"""Refuse la demande de modification."""
|
||||
self.ensure_one()
|
||||
if not self.can_reject:
|
||||
return False
|
||||
|
||||
# Mettre à jour le créneau de planning
|
||||
self.slot_id.write({
|
||||
'portal_modification_approved': 'rejected'
|
||||
})
|
||||
|
||||
# Mettre à jour la demande
|
||||
self.write({
|
||||
'state': 'rejected',
|
||||
'approver_id': self.env.user.id,
|
||||
'approval_date': fields.Datetime.now()
|
||||
})
|
||||
|
||||
# Envoyer une notification à l'employé
|
||||
self.message_post(
|
||||
body=_('Demande de modification refusée par %s') % self.env.user.name,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
partner_ids=[self.user_id.partner_id.id]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def action_cancel(self):
|
||||
"""Annule la demande de modification."""
|
||||
self.ensure_one()
|
||||
if not self.can_cancel:
|
||||
return False
|
||||
|
||||
# Mettre à jour le créneau de planning si c'est l'employé qui annule
|
||||
if self.user_id.id == self.env.user.id:
|
||||
self.slot_id.write({
|
||||
'portal_modification_approved': False,
|
||||
'portal_modified': False
|
||||
})
|
||||
|
||||
# Mettre à jour la demande
|
||||
self.write({
|
||||
'state': 'cancelled',
|
||||
'approver_id': self.env.user.id,
|
||||
'approval_date': fields.Datetime.now()
|
||||
})
|
||||
|
||||
# Envoyer une notification
|
||||
self.message_post(
|
||||
body=_('Demande de modification annulée par %s') % self.env.user.name,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
|
||||
return True
|
||||
28
portal_planning/models/resource_resource.py
Normal file
28
portal_planning/models/resource_resource.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
class ResourceResource(models.Model):
|
||||
"""Extension du modèle resource.resource pour les fonctionnalités du portail planning."""
|
||||
_inherit = 'resource.resource'
|
||||
|
||||
portal_modification_auto_approve = fields.Boolean(
|
||||
string='Approbation auto des modifications',
|
||||
default=False,
|
||||
help="Si coché, les modifications de planning via le portail seront automatiquement approuvées pour cette ressource"
|
||||
)
|
||||
portal_min_hours_before_modification = fields.Integer(
|
||||
string='Heures min. avant modification',
|
||||
default=24,
|
||||
help="Nombre minimum d'heures avant le début du créneau pour permettre une modification via le portail"
|
||||
)
|
||||
portal_allow_creation = fields.Boolean(
|
||||
string='Autoriser création de créneaux',
|
||||
default=True,
|
||||
help="Si coché, cette ressource peut créer des créneaux de planning via le portail"
|
||||
)
|
||||
portal_allow_exchange = fields.Boolean(
|
||||
string='Autoriser échange de créneaux',
|
||||
default=True,
|
||||
help="Si coché, cette ressource peut demander des échanges de créneaux via le portail"
|
||||
)
|
||||
11
portal_planning/security/ir.model.access.csv
Normal file
11
portal_planning/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_planning_slot_portal,planning.slot.portal,planning.model_planning_slot,base.group_portal,1,1,0,0
|
||||
access_portal_planning_request_portal,portal.planning.request.portal,model_portal_planning_request,base.group_portal,1,1,1,0
|
||||
access_portal_planning_request_user,portal.planning.request.user,model_portal_planning_request,planning.group_planning_user,1,1,1,0
|
||||
access_portal_planning_request_manager,portal.planning.request.manager,model_portal_planning_request,planning.group_planning_manager,1,1,1,1
|
||||
access_portal_planning_modification_portal,portal.planning.modification.portal,model_portal_planning_modification,base.group_portal,1,1,1,0
|
||||
access_portal_planning_modification_user,portal.planning.modification.user,model_portal_planning_modification,planning.group_planning_user,1,1,1,1
|
||||
access_portal_planning_modification_manager,portal.planning.modification.manager,model_portal_planning_modification,planning.group_planning_manager,1,1,1,1
|
||||
access_portal_planning_exchange_portal,portal.planning.exchange.portal,model_portal_planning_exchange,base.group_portal,1,1,1,0
|
||||
access_portal_planning_exchange_user,portal.planning.exchange.user,model_portal_planning_exchange,planning.group_planning_user,1,1,1,1
|
||||
access_portal_planning_exchange_manager,portal.planning.exchange.manager,model_portal_planning_exchange,planning.group_planning_manager,1,1,1,1
|
||||
|
40
portal_planning/security/portal_planning_security.xml
Normal file
40
portal_planning/security/portal_planning_security.xml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Règle de sécurité pour les utilisateurs du portail -->
|
||||
<record id="planning_slot_portal_rule" model="ir.rule">
|
||||
<field name="name">Planning Slot: portal users can only see their own planning</field>
|
||||
<field name="model_id" ref="planning.model_planning_slot"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Règle de sécurité pour les demandes de planning des utilisateurs du portail -->
|
||||
<record id="portal_planning_request_rule" model="ir.rule">
|
||||
<field name="name">Planning Request: portal users can only see their own requests</field>
|
||||
<field name="model_id" ref="portal_planning.model_portal_planning_request"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Règle de sécurité pour les demandes de planning des utilisateurs internes -->
|
||||
<record id="portal_planning_request_user_rule" model="ir.rule">
|
||||
<field name="name">Planning Request: internal users can see all requests</field>
|
||||
<field name="model_id" ref="portal_planning.model_portal_planning_request"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('planning.group_planning_manager'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
242
portal_planning/specs.md
Normal file
242
portal_planning/specs.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# Spécifications du module Portal Planning
|
||||
|
||||
## 1. Objectifs
|
||||
- Permettre aux utilisateurs du portail d'accéder à leur planning personnel
|
||||
- Offrir la possibilité de confirmer leur présence sur les créneaux affectés
|
||||
- Permettre aux employés de modifier leurs créneaux (avec processus de reconfirmation)
|
||||
- Permettre aux employés d'ajouter de nouveaux créneaux (avec processus de validation)
|
||||
- Permettre les demandes d'échange de créneaux
|
||||
- Notifier les employés des modifications de leur planning
|
||||
- Fournir des statistiques personnelles sur les heures travaillées
|
||||
|
||||
## 2. Architecture technique
|
||||
- Extension du contrôleur portal.CustomerPortal pour l'interface utilisateur
|
||||
- Extension des modèles existants (planning.slot, resource.resource) pour les fonctionnalités de base
|
||||
- Création de modèles dédiés pour les fonctionnalités spécifiques au portail (échanges, modifications, préférences)
|
||||
|
||||
## 3. Fonctionnalités détaillées
|
||||
### 3.1 Visualisation du planning personnel
|
||||
- Page d'accueil du portail avec compteur et aperçu des créneaux
|
||||
- Page de planning détaillée avec vue calendrier
|
||||
- Page de détail pour chaque créneau
|
||||
- Filtres pour afficher les créneaux par statut (confirmé, à confirmer, modifié, en attente de validation)
|
||||
|
||||
### 3.2 Confirmation des créneaux
|
||||
- Processus de confirmation via différentes interfaces (portail, email)
|
||||
- Notifications de confirmation
|
||||
- Historique des confirmations
|
||||
- Création automatique des entrées timesheet lors de la confirmation
|
||||
- Création d'une nouvelle entrée timesheet si aucune n'existe pour cette période
|
||||
- Ajout uniquement des heures qui ne sont pas déjà couvertes par des entrées timesheet existantes
|
||||
- Conservation des entrées timesheet existantes qui chevauchent le créneau
|
||||
|
||||
### 3.3 Modification des créneaux par l'employé
|
||||
- Interface de modification des détails du créneau (horaires, rôle, notes)
|
||||
- Processus de validation des modifications
|
||||
- Modifications automatiquement approuvées selon les règles configurées
|
||||
- Modifications nécessitant une approbation par un responsable
|
||||
- Statut "modifié" pour les créneaux en attente de reconfirmation
|
||||
- Notifications aux responsables pour les modifications nécessitant une approbation
|
||||
- Historique des modifications pour chaque créneau
|
||||
|
||||
### 3.4 Ajout de créneaux par l'employé
|
||||
- Interface d'ajout de nouveaux créneaux (horaires, rôle, notes)
|
||||
- Processus de validation des nouveaux créneaux
|
||||
- Tous les nouveaux créneaux nécessitent une validation par un responsable
|
||||
- Statut "en attente de validation" pour les nouveaux créneaux
|
||||
- Notifications aux responsables pour les nouveaux créneaux à valider
|
||||
- Une fois validé, le créneau passe au statut "à confirmer"
|
||||
- L'employé peut alors confirmer sa présence sur le créneau
|
||||
|
||||
### 3.5 Échange de créneaux
|
||||
- Processus de demande d'échange
|
||||
- Système d'approbation
|
||||
- Finalisation des échanges
|
||||
- Historique des demandes d'échange
|
||||
|
||||
### 3.6 Notifications
|
||||
- Types de notifications (affectation, modification, échange, approbation/refus de modification)
|
||||
- Canaux de notification (email, portail)
|
||||
- Préférences de notification personnalisables
|
||||
|
||||
### 3.7 Statistiques personnelles
|
||||
- Heures travaillées par période
|
||||
- Taux de confirmation
|
||||
- Nombre de modifications effectuées
|
||||
- Comparaison avec les heures contractuelles
|
||||
|
||||
## 4. Interface utilisateur
|
||||
- Intégration au portail existant
|
||||
- Design responsive pour tous les appareils
|
||||
- Accessibilité
|
||||
- Interfaces spécifiques pour la modification des créneaux
|
||||
|
||||
## 5. Sécurité
|
||||
- Règles de sécurité pour limiter l'accès aux données personnelles
|
||||
- Droits d'accès spécifiques pour les utilisateurs du portail
|
||||
- Protection des données sensibles
|
||||
- Règles pour déterminer quels champs peuvent être modifiés par l'employé
|
||||
|
||||
## 6. Modèles de données
|
||||
|
||||
### 6.1 Extensions de modèles existants
|
||||
|
||||
#### account.analytic.line (timesheet)
|
||||
- `planning_slot_id`: Lien vers le créneau de planning associé
|
||||
- `portal_generated`: Booléen indiquant si l'entrée a été générée via le portail
|
||||
- `portal_confirmation_date`: Date de la confirmation qui a généré cette entrée
|
||||
|
||||
|
||||
|
||||
#### planning.slot (extension)
|
||||
- `portal_can_modify`: Booléen indiquant si l'utilisateur du portail peut modifier ce créneau
|
||||
- `portal_modified`: Booléen indiquant si le créneau a été modifié via le portail
|
||||
- `portal_modification_date`: Date de la dernière modification via le portail
|
||||
- `portal_modification_user_id`: Utilisateur ayant effectué la dernière modification
|
||||
- `portal_modification_approved`: Statut d'approbation de la modification (en attente, approuvé, refusé)
|
||||
- `portal_modification_notes`: Notes concernant la modification
|
||||
- `portal_original_start`: Date de début originale avant modification
|
||||
- `portal_original_end`: Date de fin originale avant modification
|
||||
- `portal_original_role_id`: Rôle original avant modification
|
||||
- `portal_created`: Booléen indiquant si le créneau a été créé via le portail
|
||||
- `portal_creation_date`: Date de création via le portail
|
||||
- `portal_creation_user_id`: Utilisateur ayant créé le créneau
|
||||
- `portal_creation_approved`: Statut d'approbation de la création (en attente, approuvé, refusé)
|
||||
- `portal_creation_notes`: Notes concernant la création
|
||||
|
||||
#### resource.resource (extension)
|
||||
- `portal_modification_auto_approve`: Règles pour l'approbation automatique des modifications
|
||||
|
||||
### 6.2 Nouveaux modèles
|
||||
|
||||
#### portal.planning.modification
|
||||
- `slot_id`: Lien vers le créneau modifié
|
||||
- `user_id`: Utilisateur ayant effectué la modification
|
||||
- `date`: Date de la modification
|
||||
- `field_name`: Champ modifié
|
||||
- `old_value`: Ancienne valeur
|
||||
- `new_value`: Nouvelle valeur
|
||||
- `state`: État de la modification (en attente, approuvé, refusé)
|
||||
- `approver_id`: Utilisateur ayant approuvé/refusé la modification
|
||||
- `approval_date`: Date d'approbation/refus
|
||||
- `notes`: Notes concernant la modification
|
||||
|
||||
#### portal.planning.creation
|
||||
- `slot_id`: Lien vers le créneau créé
|
||||
- `user_id`: Utilisateur ayant créé le créneau
|
||||
- `date`: Date de la création
|
||||
- `state`: État de la création (en attente, approuvé, refusé)
|
||||
- `approver_id`: Utilisateur ayant approuvé/refusé la création
|
||||
- `approval_date`: Date d'approbation/refus
|
||||
- `notes`: Notes concernant la création
|
||||
|
||||
## 7. Contrôleurs et routes
|
||||
|
||||
### 7.1 Extension de portal.CustomerPortal
|
||||
- `/my/planning`: Vue d'ensemble du planning
|
||||
- `/my/planning/slot/<int:slot_id>`: Détail d'un créneau
|
||||
- `/my/planning/slot/<int:slot_id>/confirm`: Confirmation d'un créneau
|
||||
- `/my/planning/slot/<int:slot_id>/modify`: Modification d'un créneau
|
||||
- `/my/planning/slot/create`: Création d'un nouveau créneau
|
||||
- `/my/planning/slot/<int:slot_id>/exchange`: Demande d'échange d'un créneau
|
||||
|
||||
### 7.2 API JSON pour les interactions dynamiques
|
||||
- `/planning/slot/update`: Mise à jour d'un créneau (AJAX)
|
||||
- `/planning/slot/create`: Création d'un créneau (AJAX)
|
||||
- `/planning/slot/confirm`: Confirmation d'un créneau (AJAX)
|
||||
- `/planning/slot/exchange/request`: Demande d'échange (AJAX)
|
||||
|
||||
## 8. Vues et templates
|
||||
|
||||
### 8.1 Templates portail
|
||||
- `portal_planning_layout.xml`: Layout général pour les pages de planning
|
||||
- `portal_planning_my_planning.xml`: Vue d'ensemble du planning
|
||||
- `portal_planning_slot_detail.xml`: Détail d'un créneau
|
||||
- `portal_planning_slot_modify.xml`: Formulaire de modification d'un créneau
|
||||
- `portal_planning_slot_create.xml`: Formulaire de création d'un créneau
|
||||
- `portal_planning_slot_exchange.xml`: Formulaire de demande d'échange
|
||||
|
||||
### 8.2 Vues backend
|
||||
- `planning_slot_views.xml`: Extension des vues de planning.slot
|
||||
- `portal_planning_modification_views.xml`: Vues pour le modèle portal.planning.modification
|
||||
- `portal_planning_creation_views.xml`: Vues pour le modèle portal.planning.creation
|
||||
|
||||
## 9. Workflows
|
||||
|
||||
### 9.1 Workflow de création d'un créneau
|
||||
1. L'employé accède à la page de création de créneau via le portail
|
||||
2. Il remplit le formulaire avec les détails du nouveau créneau (dates, horaires, rôle, notes)
|
||||
3. Le système enregistre le créneau avec le statut "en attente de validation"
|
||||
4. Le responsable est notifié de la demande de création de créneau
|
||||
5. Le responsable examine la demande et l'approuve ou la refuse
|
||||
6. L'employé est notifié de la décision
|
||||
7. Si approuvé, le créneau est créé et marqué comme "à confirmer"
|
||||
8. L'employé peut alors confirmer sa présence sur le créneau
|
||||
|
||||
### 9.2 Workflow de modification d'un créneau
|
||||
1. L'employé accède à son créneau via le portail
|
||||
2. Il clique sur "Modifier" et effectue les changements souhaités
|
||||
3. Le système vérifie si les modifications nécessitent une approbation
|
||||
- Si non, les modifications sont appliquées immédiatement
|
||||
- Si oui, les modifications sont enregistrées avec le statut "en attente"
|
||||
4. Le responsable est notifié des modifications en attente
|
||||
5. Le responsable approuve ou refuse les modifications
|
||||
6. L'employé est notifié de la décision
|
||||
7. Si approuvé, le créneau est mis à jour et marqué comme "à confirmer"
|
||||
8. L'employé doit confirmer à nouveau sa présence sur le créneau modifié
|
||||
|
||||
### 9.3 Workflow de confirmation et génération de timesheet
|
||||
1. L'employé accède à son créneau via le portail
|
||||
2. Il confirme sa présence/exécution pour ce créneau
|
||||
3. Le système vérifie si des entrées timesheet existent pour cette période
|
||||
- Si aucune entrée n'existe, une nouvelle entrée est créée avec les détails du créneau
|
||||
- Si des entrées existent et chevauchent partiellement le créneau:
|
||||
- Les entrées existantes sont conservées telles quelles
|
||||
- De nouvelles entrées sont créées uniquement pour les périodes du créneau qui ne sont pas déjà couvertes
|
||||
- Si une entrée existe et couvre exactement le créneau, aucune nouvelle entrée n'est créée
|
||||
4. Les entrées timesheet générées sont liées au créneau de planning
|
||||
5. Le créneau est marqué comme confirmé
|
||||
6. L'employé et le responsable sont notifiés de la confirmation
|
||||
|
||||
## 10. Configuration
|
||||
|
||||
### 10.1 Paramètres du module
|
||||
- `portal_planning.allow_modification`: Activer/désactiver la possibilité de modifier les créneaux
|
||||
- `portal_planning.modification_approval_required`: Définir si l'approbation est requise pour les modifications
|
||||
- `portal_planning.allow_creation`: Activer/désactiver la possibilité de créer des créneaux
|
||||
- `portal_planning.fields_modifiable`: Liste des champs modifiables par l'employé
|
||||
- `portal_planning.auto_create_timesheet`: Activer/désactiver la création automatique des entrées timesheet lors de la confirmation
|
||||
- `portal_planning.timesheet_project_id`: Projet par défaut pour les entrées timesheet générées
|
||||
- `portal_planning.timesheet_task_mapping`: Mappage entre les rôles de planning et les tâches de projet
|
||||
|
||||
### 10.2 Groupes de sécurité
|
||||
- `group_portal_planning_user`: Utilisateurs du portail ayant accès au planning
|
||||
- `group_portal_planning_manager`: Gestionnaires pouvant approuver les modifications
|
||||
|
||||
## 11. Intégrations
|
||||
- Intégration avec les autres modules de planning (contract, holidays, skills)
|
||||
- Intégration avec les modules hr et mail
|
||||
- Intégration avec le module timesheet pour la génération automatique des entrées
|
||||
- Intégration avec le module project pour l'association des entrées timesheet aux projets et tâches
|
||||
- API pour intégrations externes
|
||||
|
||||
## 12. Déploiement et maintenance
|
||||
- Prérequis (Odoo Enterprise 16.0+, modules planning et portal)
|
||||
- Processus d'installation et configuration
|
||||
- Stratégie de mise à jour et maintenance
|
||||
|
||||
## 13. Tests et validation
|
||||
- Tests unitaires pour les modèles et méthodes
|
||||
- Tests d'intégration pour les flux complets
|
||||
- Tests utilisateurs pour valider l'expérience
|
||||
|
||||
## 14. Documentation
|
||||
- Documentation technique des modèles et API
|
||||
- Guide utilisateur pour les fonctionnalités du portail
|
||||
- Guide administrateur pour la configuration
|
||||
|
||||
## 15. Roadmap et évolutions futures
|
||||
- Application mobile dédiée
|
||||
- Intégration avec des calendriers externes
|
||||
- Système de rappels personnalisables
|
||||
- Statistiques avancées sur les modifications et confirmations
|
||||
210
portal_planning/static/src/css/portal_planning.css
Normal file
210
portal_planning/static/src/css/portal_planning.css
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/* Styles pour le module portal_planning */
|
||||
|
||||
/* Styles généraux */
|
||||
.o_portal_planning {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* Styles pour la vue mensuelle */
|
||||
.planning-month .calendar-month {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.planning-month .calendar-day {
|
||||
height: 120px;
|
||||
padding: 5px;
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.planning-month .calendar-day-header {
|
||||
margin-bottom: 5px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.planning-month .calendar-day-number {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.planning-month .calendar-day-today {
|
||||
background-color: rgba(0, 123, 255, 0.05);
|
||||
border: 1px solid rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.planning-month .calendar-day-other-month {
|
||||
background-color: #f8f9fa;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.planning-month .calendar-day-has-slots {
|
||||
background-color: rgba(40, 167, 69, 0.05);
|
||||
}
|
||||
|
||||
.planning-month .calendar-slot {
|
||||
font-size: 0.8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.planning-month .calendar-slot:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Styles pour la vue hebdomadaire */
|
||||
.planning-week .table th {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.planning-week .day-header {
|
||||
background-color: #f8f9fa;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.planning-week .day-today {
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
border: 1px solid rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Styles pour la vue quotidienne */
|
||||
.planning-day .timeline {
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.planning-day .timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.planning-day .timeline-badge {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
color: white;
|
||||
position: absolute;
|
||||
left: -30px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.planning-day .timeline-content {
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ddd;
|
||||
background-color: #fff;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* Styles pour les statistiques */
|
||||
.planning-stats .stat-card {
|
||||
background-color: #fff;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
padding: 15px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.planning-stats .stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.planning-stats .stat-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Styles pour les boutons de sélection de vue */
|
||||
.view-selector .btn {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Styles pour les formulaires */
|
||||
.o_portal_planning_form .form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.o_portal_planning_form .form-control {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Styles pour les alertes */
|
||||
.o_portal_planning .alert {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Styles pour les cartes */
|
||||
.o_portal_planning .card {
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.o_portal_planning .card-header {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.125);
|
||||
}
|
||||
|
||||
/* Styles pour les boutons */
|
||||
.o_portal_planning .btn-primary {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.o_portal_planning .btn-success {
|
||||
background-color: #28a745;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.o_portal_planning .btn-danger {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.o_portal_planning .btn-info {
|
||||
background-color: #17a2b8;
|
||||
border-color: #17a2b8;
|
||||
}
|
||||
|
||||
/* Styles pour les badges */
|
||||
.o_portal_planning .badge-primary {
|
||||
background-color: #007bff;
|
||||
}
|
||||
|
||||
.o_portal_planning .badge-success {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.o_portal_planning .badge-warning {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
|
||||
.o_portal_planning .badge-danger {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
/* Styles responsifs */
|
||||
@media (max-width: 767.98px) {
|
||||
.planning-month .calendar-day {
|
||||
height: 80px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.planning-month .calendar-slot {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.planning-stats .stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
16
portal_planning/static/src/img/portal-planning.svg
Normal file
16
portal_planning/static/src/img/portal-planning.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="40px" height="40px" viewBox="0 0 40 40" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Portal Planning Icon</title>
|
||||
<g id="planning-icon" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<rect id="Rectangle" stroke="#875A7B" stroke-width="2" x="5" y="5" width="30" height="30" rx="3"></rect>
|
||||
<line x1="5" y1="12" x2="35" y2="12" id="Line" stroke="#875A7B" stroke-width="2" stroke-linecap="round"></line>
|
||||
<line x1="10" y1="2" x2="10" y2="8" id="Line-2" stroke="#875A7B" stroke-width="2" stroke-linecap="round"></line>
|
||||
<line x1="30" y1="2" x2="30" y2="8" id="Line-3" stroke="#875A7B" stroke-width="2" stroke-linecap="round"></line>
|
||||
<rect id="Rectangle" fill="#875A7B" x="10" y="16" width="4" height="4" rx="1"></rect>
|
||||
<rect id="Rectangle-Copy" fill="#875A7B" x="18" y="16" width="4" height="4" rx="1"></rect>
|
||||
<rect id="Rectangle-Copy-2" fill="#875A7B" x="26" y="16" width="4" height="4" rx="1"></rect>
|
||||
<rect id="Rectangle-Copy-3" fill="#875A7B" x="10" y="24" width="4" height="4" rx="1"></rect>
|
||||
<rect id="Rectangle-Copy-4" fill="#875A7B" x="18" y="24" width="4" height="4" rx="1"></rect>
|
||||
<rect id="Rectangle-Copy-5" fill="#875A7B" x="26" y="24" width="4" height="4" rx="1"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
284
portal_planning/static/src/js/portal_planning.js
Normal file
284
portal_planning/static/src/js/portal_planning.js
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
odoo.define('portal_planning.portal', function (require) {
|
||||
'use strict';
|
||||
|
||||
const publicWidget = require('web.public.widget');
|
||||
const core = require('web.core');
|
||||
const time = require('web.time');
|
||||
const ajax = require('web.ajax');
|
||||
const _t = core._t;
|
||||
|
||||
publicWidget.registry.PortalPlanning = publicWidget.Widget.extend({
|
||||
selector: '.o_portal_planning',
|
||||
events: {
|
||||
'click .o_planning_confirm': '_onConfirmPlanning',
|
||||
'click .o_planning_modify': '_onModifyPlanning',
|
||||
'click .o_planning_exchange': '_onExchangePlanning',
|
||||
'click .o_planning_cancel_modification': '_onCancelModification',
|
||||
'click .o_planning_cancel_exchange': '_onCancelExchange',
|
||||
'submit .o_planning_modification_form': '_onSubmitModification',
|
||||
'submit .o_planning_exchange_form': '_onSubmitExchange',
|
||||
'change .o_planning_filter': '_onFilterChange',
|
||||
'change #planning_date_range': '_onDateRangeChange',
|
||||
'click .o_planning_show_details': '_onToggleDetails',
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this._initDatePickers();
|
||||
this._initTooltips();
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize date pickers for modification forms
|
||||
* @private
|
||||
*/
|
||||
_initDatePickers: function () {
|
||||
const self = this;
|
||||
const datepickerOptions = {
|
||||
minDate: moment().startOf('day'),
|
||||
useCurrent: false,
|
||||
locale: moment.locale(),
|
||||
format: time.getLangDatetimeFormat(),
|
||||
icons: {
|
||||
time: 'fa fa-clock-o',
|
||||
date: 'fa fa-calendar',
|
||||
up: 'fa fa-chevron-up',
|
||||
down: 'fa fa-chevron-down',
|
||||
previous: 'fa fa-chevron-left',
|
||||
next: 'fa fa-chevron-right',
|
||||
today: 'fa fa-calendar-check-o',
|
||||
clear: 'fa fa-delete',
|
||||
close: 'fa fa-times'
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize start date picker
|
||||
this.$el.find('.o_planning_datetime_start').each(function () {
|
||||
const $input = $(this);
|
||||
$input.datetimepicker(datepickerOptions);
|
||||
|
||||
// When start date changes, update end date min value
|
||||
$input.on('change.datetimepicker', function (e) {
|
||||
const $endDate = $input.closest('form').find('.o_planning_datetime_end');
|
||||
if ($endDate.length) {
|
||||
$endDate.datetimepicker('minDate', e.date);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize end date picker
|
||||
this.$el.find('.o_planning_datetime_end').each(function () {
|
||||
const $input = $(this);
|
||||
const startDate = $input.closest('form').find('.o_planning_datetime_start').val();
|
||||
|
||||
const endOptions = Object.assign({}, datepickerOptions);
|
||||
if (startDate) {
|
||||
endOptions.minDate = moment(startDate, time.getLangDatetimeFormat());
|
||||
}
|
||||
|
||||
$input.datetimepicker(endOptions);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize tooltips
|
||||
* @private
|
||||
*/
|
||||
_initTooltips: function () {
|
||||
this.$('[data-toggle="tooltip"]').tooltip();
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle planning confirmation
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onConfirmPlanning: function (ev) {
|
||||
ev.preventDefault();
|
||||
const $button = $(ev.currentTarget);
|
||||
const slotId = $button.data('slot-id');
|
||||
|
||||
this._rpc({
|
||||
route: '/my/planning/confirm',
|
||||
params: {
|
||||
slot_id: slotId,
|
||||
},
|
||||
}).then(function (result) {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
self._showNotification('error', result.error || _t('An error occurred during confirmation.'));
|
||||
}
|
||||
}).guardedCatch(function (error) {
|
||||
self._showNotification('error', _t('An error occurred during confirmation.'));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle planning modification button click
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onModifyPlanning: function (ev) {
|
||||
ev.preventDefault();
|
||||
const $button = $(ev.currentTarget);
|
||||
const slotId = $button.data('slot-id');
|
||||
const $slot = $button.closest('.planning-slot');
|
||||
|
||||
// Toggle modification form
|
||||
$slot.find('.o_planning_modification_form_container').toggleClass('d-none');
|
||||
|
||||
// Initialize date pickers if form is shown
|
||||
if (!$slot.find('.o_planning_modification_form_container').hasClass('d-none')) {
|
||||
this._initDatePickers();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle planning exchange button click
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onExchangePlanning: function (ev) {
|
||||
ev.preventDefault();
|
||||
const $button = $(ev.currentTarget);
|
||||
const slotId = $button.data('slot-id');
|
||||
|
||||
// Redirect to exchange form
|
||||
window.location.href = '/my/planning/exchange/new?slot_id=' + slotId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle modification form submission
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onSubmitModification: function (ev) {
|
||||
ev.preventDefault();
|
||||
const self = this;
|
||||
const $form = $(ev.currentTarget);
|
||||
const formData = $form.serializeArray();
|
||||
|
||||
ajax.post($form.attr('action'), formData).then(function (result) {
|
||||
result = JSON.parse(result);
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
self._showNotification('error', result.error || _t('An error occurred during modification.'));
|
||||
}
|
||||
}).guardedCatch(function (error) {
|
||||
self._showNotification('error', _t('An error occurred during modification.'));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle exchange form submission
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onSubmitExchange: function (ev) {
|
||||
ev.preventDefault();
|
||||
const self = this;
|
||||
const $form = $(ev.currentTarget);
|
||||
const formData = $form.serializeArray();
|
||||
|
||||
ajax.post($form.attr('action'), formData).then(function (result) {
|
||||
result = JSON.parse(result);
|
||||
if (result.success) {
|
||||
window.location.href = '/my/planning/exchanges';
|
||||
} else {
|
||||
self._showNotification('error', result.error || _t('An error occurred during exchange request.'));
|
||||
}
|
||||
}).guardedCatch(function (error) {
|
||||
self._showNotification('error', _t('An error occurred during exchange request.'));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle cancellation of modification
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onCancelModification: function (ev) {
|
||||
ev.preventDefault();
|
||||
const $button = $(ev.currentTarget);
|
||||
const $form = $button.closest('.o_planning_modification_form_container');
|
||||
|
||||
// Hide form
|
||||
$form.addClass('d-none');
|
||||
|
||||
// Reset form
|
||||
$form.find('form')[0].reset();
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle cancellation of exchange
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onCancelExchange: function (ev) {
|
||||
ev.preventDefault();
|
||||
window.history.back();
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle filter change
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFilterChange: function (ev) {
|
||||
const $form = $(ev.currentTarget).closest('form');
|
||||
$form.submit();
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle date range change
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onDateRangeChange: function (ev) {
|
||||
const $form = $(ev.currentTarget).closest('form');
|
||||
$form.submit();
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle planning slot details
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onToggleDetails: function (ev) {
|
||||
ev.preventDefault();
|
||||
const $button = $(ev.currentTarget);
|
||||
const $details = $button.closest('.planning-slot').find('.slot-details');
|
||||
|
||||
$details.toggleClass('d-none');
|
||||
$button.find('i').toggleClass('fa-chevron-down fa-chevron-up');
|
||||
|
||||
const isVisible = !$details.hasClass('d-none');
|
||||
$button.find('span').text(isVisible ? _t('Hide Details') : _t('Show Details'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Show notification
|
||||
* @private
|
||||
* @param {String} type - Type of notification (success, error, warning, info)
|
||||
* @param {String} message - Message to display
|
||||
*/
|
||||
_showNotification: function (type, message) {
|
||||
const $notification = $('<div>').addClass('o_portal_planning_notification ' + type).text(message);
|
||||
$('body').append($notification);
|
||||
|
||||
// Auto remove after 5 seconds
|
||||
setTimeout(function () {
|
||||
$notification.fadeOut(300, function () {
|
||||
$(this).remove();
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
return publicWidget.registry.PortalPlanning;
|
||||
});
|
||||
280
portal_planning/static/src/js/portal_planning_calendar.js
Normal file
280
portal_planning/static/src/js/portal_planning_calendar.js
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
odoo.define('portal_planning.calendar', (require) => {
|
||||
|
||||
const publicWidget = require('web.public.widget');
|
||||
const core = require('web.core');
|
||||
const time = require('web.time');
|
||||
const ajax = require('web.ajax');
|
||||
const _t = core._t;
|
||||
|
||||
publicWidget.registry.PlanningCalendar = publicWidget.Widget.extend({
|
||||
selector: '.o_portal_planning_calendar',
|
||||
events: {
|
||||
'click .o_planning_slot_confirm': '_onConfirmSlot',
|
||||
'click .o_planning_slot_modify': '_onModifySlot',
|
||||
'click .o_planning_slot_exchange': '_onExchangeSlot'
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start() {
|
||||
return this._super.apply(this, arguments).then(() => {
|
||||
this._initCalendar();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialise le calendrier FullCalendar
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_initCalendar() {
|
||||
const calendarEl = this.$el.find('.o_calendar_container')[0];
|
||||
|
||||
if (!calendarEl) {
|
||||
console.error('Élément calendrier non trouvé');
|
||||
return;
|
||||
}
|
||||
|
||||
const calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: 'timeGridWeek',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
||||
},
|
||||
locale: 'fr',
|
||||
firstDay: 1, // Lundi
|
||||
allDaySlot: false,
|
||||
slotMinTime: '06:00:00',
|
||||
slotMaxTime: '22:00:00',
|
||||
slotDuration: '00:30:00',
|
||||
eventTimeFormat: {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
nowIndicator: true,
|
||||
navLinks: true,
|
||||
selectable: false,
|
||||
selectMirror: true,
|
||||
eventClick: (info) => {
|
||||
this._onEventClick(info);
|
||||
},
|
||||
events: (info, successCallback, failureCallback) => {
|
||||
this._loadEvents(info.start, info.end, successCallback, failureCallback);
|
||||
},
|
||||
eventDidMount: (info) => {
|
||||
$(info.el).popover({
|
||||
title: info.event.title,
|
||||
content: self._formatEventPopover(info.event),
|
||||
trigger: 'hover',
|
||||
placement: 'top',
|
||||
container: 'body',
|
||||
html: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
calendar.render();
|
||||
this.calendar = calendar;
|
||||
},
|
||||
|
||||
/**
|
||||
* Charge les événements du calendrier
|
||||
*
|
||||
* @private
|
||||
* @param {Date} start - Date de début
|
||||
* @param {Date} end - Date de fin
|
||||
* @param {Function} successCallback - Fonction de rappel en cas de succès
|
||||
* @param {Function} failureCallback - Fonction de rappel en cas d'échec
|
||||
*/
|
||||
_loadEvents(start, end, successCallback, failureCallback) {
|
||||
ajax.jsonRpc('/planning/api/slots', 'call', {
|
||||
start_date: moment(start).format('YYYY-MM-DD'),
|
||||
end_date: moment(end).format('YYYY-MM-DD')
|
||||
}).then((data) => {
|
||||
const events = [];
|
||||
if (data.success) {
|
||||
_.each(data.slots, (slot) => {
|
||||
let color;
|
||||
switch (slot.portal_status) {
|
||||
case 'draft':
|
||||
color = '#17a2b8'; // info
|
||||
break;
|
||||
case 'confirmed':
|
||||
color = '#28a745'; // success
|
||||
break;
|
||||
case 'modified':
|
||||
color = '#ffc107'; // warning
|
||||
break;
|
||||
case 'pending_approval':
|
||||
color = '#dc3545'; // danger
|
||||
break;
|
||||
default:
|
||||
color = '#6c757d'; // secondary
|
||||
}
|
||||
|
||||
events.push({
|
||||
id: slot.id,
|
||||
title: slot.name || (slot.role_id ? slot.role_id[1] : _t('Créneau de planning')),
|
||||
start: slot.start_datetime,
|
||||
end: slot.end_datetime,
|
||||
allDay: false,
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
extendedProps: {
|
||||
slot: slot
|
||||
}
|
||||
});
|
||||
});
|
||||
successCallback(events);
|
||||
} else {
|
||||
failureCallback(data.error || _t("Erreur lors du chargement des créneaux"));
|
||||
}
|
||||
}).guardedCatch(function (error) {
|
||||
failureCallback(error);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Formate le contenu de la popover pour un événement
|
||||
*
|
||||
* @private
|
||||
* @param {Object} event - Événement FullCalendar
|
||||
* @returns {String} - Contenu HTML de la popover
|
||||
*/
|
||||
_formatEventPopover(event) {
|
||||
const slot = event.extendedProps.slot;
|
||||
const start = moment(slot.start_datetime);
|
||||
const end = moment(slot.end_datetime);
|
||||
const duration = moment.duration(end.diff(start));
|
||||
const hours = Math.floor(duration.asHours());
|
||||
const minutes = Math.floor(duration.asMinutes()) % 60;
|
||||
|
||||
let html = '<div class="o_planning_popover">';
|
||||
|
||||
if (slot.role_id) {
|
||||
html += '<p><strong>' + _t('Rôle') + ':</strong> ' + slot.role_id[1] + '</p>';
|
||||
}
|
||||
|
||||
html += '<p><strong>' + _t('Date') + ':</strong> ' + start.format('DD/MM/YYYY') + '</p>';
|
||||
html += '<p><strong>' + _t('Horaire') + ':</strong> ' + start.format('HH:mm') + ' - ' + end.format('HH:mm') + '</p>';
|
||||
html += '<p><strong>' + _t('Durée') + ':</strong> ' + hours + 'h' + (minutes ? ' ' + minutes + 'min' : '') + '</p>';
|
||||
|
||||
if (slot.portal_status) {
|
||||
var status;
|
||||
switch (slot.portal_status) {
|
||||
case 'draft':
|
||||
status = '<span class="badge badge-info">' + _t('À confirmer') + '</span>';
|
||||
break;
|
||||
case 'confirmed':
|
||||
status = '<span class="badge badge-success">' + _t('Confirmé') + '</span>';
|
||||
break;
|
||||
case 'modified':
|
||||
status = '<span class="badge badge-warning">' + _t('Modifié') + '</span>';
|
||||
break;
|
||||
case 'pending_approval':
|
||||
status = '<span class="badge badge-danger">' + _t('En attente') + '</span>';
|
||||
break;
|
||||
default:
|
||||
status = '<span class="badge badge-secondary">' + slot.portal_status + '</span>';
|
||||
}
|
||||
html += '<p><strong>' + _t('Statut') + ':</strong> ' + status + '</p>';
|
||||
}
|
||||
|
||||
html += '<div class="mt-2">';
|
||||
html += '<a href="/my/planning/slot/' + slot.id + '" class="btn btn-sm btn-primary"><i class="fa fa-eye"></i> ' + _t('Voir') + '</a> ';
|
||||
|
||||
if (slot.portal_status === 'draft') {
|
||||
html += '<a href="/my/planning/slot/' + slot.id + '/confirm" class="btn btn-sm btn-success o_planning_slot_confirm" data-slot-id="' + slot.id + '"><i class="fa fa-check"></i> ' + _t('Confirmer') + '</a> ';
|
||||
}
|
||||
|
||||
if (slot.portal_can_modify) {
|
||||
html += '<a href="/my/planning/slot/' + slot.id + '/modify" class="btn btn-sm btn-warning o_planning_slot_modify" data-slot-id="' + slot.id + '"><i class="fa fa-pencil"></i> ' + _t('Modifier') + '</a> ';
|
||||
}
|
||||
|
||||
html += '<a href="/my/planning/slot/' + slot.id + '/exchange" class="btn btn-sm btn-info o_planning_slot_exchange" data-slot-id="' + slot.id + '"><i class="fa fa-exchange"></i> ' + _t('Échanger') + '</a>';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gère le clic sur un événement du calendrier
|
||||
*
|
||||
* @private
|
||||
* @param {Object} info - Informations sur l'événement cliqué
|
||||
*/
|
||||
_onEventClick(info) {
|
||||
const slotId = info.event.id;
|
||||
window.location.href = '/my/planning/slot/' + slotId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gère le clic sur le bouton de confirmation d'un créneau
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev - Événement du navigateur
|
||||
*/
|
||||
_onConfirmSlot(ev) {
|
||||
ev.preventDefault();
|
||||
const slotId = $(ev.currentTarget).data('slot-id');
|
||||
|
||||
ajax.jsonRpc('/planning/api/slot/confirm', 'call', {
|
||||
slot_id: slotId
|
||||
}).then((data) => {
|
||||
if (data.success) {
|
||||
self.calendar.refetchEvents();
|
||||
self.displayNotification({
|
||||
type: 'success',
|
||||
title: _t('Succès'),
|
||||
message: data.message || _t('Créneau confirmé avec succès'),
|
||||
sticky: false
|
||||
});
|
||||
} else {
|
||||
self.displayNotification({
|
||||
type: 'danger',
|
||||
title: _t('Erreur'),
|
||||
message: data.error || _t('Erreur lors de la confirmation du créneau'),
|
||||
sticky: false
|
||||
});
|
||||
}
|
||||
}).guardedCatch(function (error) {
|
||||
self.displayNotification({
|
||||
type: 'danger',
|
||||
title: _t('Erreur'),
|
||||
message: _t('Erreur lors de la communication avec le serveur'),
|
||||
sticky: false
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Gère le clic sur le bouton de modification d'un créneau
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev - Événement du navigateur
|
||||
*/
|
||||
_onModifySlot(ev) {
|
||||
ev.preventDefault();
|
||||
const slotId = $(ev.currentTarget).data('slot-id');
|
||||
window.location.href = '/my/planning/slot/' + slotId + '/modify';
|
||||
},
|
||||
|
||||
/**
|
||||
* Gère le clic sur le bouton d'échange d'un créneau
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev - Événement du navigateur
|
||||
*/
|
||||
_onExchangeSlot(ev) {
|
||||
ev.preventDefault();
|
||||
const slotId = $(ev.currentTarget).data('slot-id');
|
||||
window.location.href = '/my/planning/slot/' + slotId + '/exchange';
|
||||
}
|
||||
});
|
||||
|
||||
return publicWidget.registry.PlanningCalendar;
|
||||
});
|
||||
184
portal_planning/static/src/scss/portal_planning.scss
Normal file
184
portal_planning/static/src/scss/portal_planning.scss
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// Styles pour le portail planning
|
||||
|
||||
.o_portal_planning {
|
||||
.planning-slot {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.slot-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.slot-title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.slot-date {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.slot-time {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.slot-actions {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.planning-confirmed {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.planning-draft {
|
||||
border-left: 4px solid #17a2b8;
|
||||
}
|
||||
|
||||
.planning-modified {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.planning-pending {
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.planning-exchange {
|
||||
background-color: #f8f9fa;
|
||||
|
||||
.exchange-details {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
}
|
||||
|
||||
.planning-modification {
|
||||
.modification-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.original-values, .new-values {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.original-values {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.new-values {
|
||||
background-color: #e9f7ef;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.planning-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
background-color: #f8f9fa;
|
||||
text-align: center;
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.planning-filters {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.planning-pagination {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Styles pour les formulaires de modification et d'échange
|
||||
.o_portal_planning_form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
// Styles pour les notifications
|
||||
.o_portal_planning_notification {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1050;
|
||||
max-width: 350px;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16);
|
||||
animation: fadeIn 0.3s ease;
|
||||
|
||||
&.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeeba;
|
||||
}
|
||||
|
||||
&.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
5
portal_planning/tests/__init__.py
Normal file
5
portal_planning/tests/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from . import test_portal_planning_request
|
||||
from . import test_portal_planning_exchange
|
||||
from . import test_planning_slot
|
||||
from . import test_portal_planning_integration
|
||||
from . import test_portal_planning_module_integration
|
||||
203
portal_planning/tests/test_planning_slot.py
Normal file
203
portal_planning/tests/test_planning_slot.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPlanningSlot(TransactionCase):
|
||||
"""Test cases for planning.slot model extended for portal access."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# Créer un utilisateur portail
|
||||
cls.portal_user = cls.env['res.users'].create({
|
||||
'name': 'Portal User',
|
||||
'login': 'portal_test_user',
|
||||
'email': 'portal_test@example.com',
|
||||
'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])],
|
||||
})
|
||||
|
||||
# Créer un employé lié à l'utilisateur portail
|
||||
cls.employee = cls.env['hr.employee'].create({
|
||||
'name': 'Test Employee',
|
||||
'user_id': cls.portal_user.id,
|
||||
})
|
||||
|
||||
# Créer un rôle de planning
|
||||
cls.role = cls.env['planning.role'].create({
|
||||
'name': 'Test Role',
|
||||
})
|
||||
|
||||
# Dates pour les tests
|
||||
cls.start_datetime = datetime.now().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
cls.end_datetime = cls.start_datetime + timedelta(hours=4)
|
||||
|
||||
# Créer un créneau de planning
|
||||
cls.slot = cls.env['planning.slot'].sudo().create({
|
||||
'name': 'Test Slot',
|
||||
'start_datetime': cls.start_datetime,
|
||||
'end_datetime': cls.end_datetime,
|
||||
'role_id': cls.role.id,
|
||||
'employee_id': cls.employee.id,
|
||||
'allocated_hours': 4.0,
|
||||
'portal_status': 'draft',
|
||||
'portal_confirmed': False,
|
||||
'portal_modified': False,
|
||||
})
|
||||
|
||||
def test_01_confirm_slot_portal(self):
|
||||
"""Test la confirmation d'un créneau via le portail."""
|
||||
# Confirmer le créneau
|
||||
self.slot.action_confirm_portal()
|
||||
|
||||
# Vérifier que le statut a changé
|
||||
self.assertEqual(self.slot.portal_status, 'confirmed')
|
||||
self.assertTrue(self.slot.portal_confirmed)
|
||||
self.assertFalse(self.slot.portal_modified)
|
||||
|
||||
def test_02_modify_slot_portal(self):
|
||||
"""Test la modification d'un créneau via le portail."""
|
||||
# Nouvelles valeurs pour la modification
|
||||
new_start = self.start_datetime + timedelta(hours=1)
|
||||
new_end = self.end_datetime + timedelta(hours=1)
|
||||
|
||||
# Modifier le créneau
|
||||
self.slot.write({
|
||||
'portal_start_datetime': new_start,
|
||||
'portal_end_datetime': new_end,
|
||||
'portal_modified': True,
|
||||
'portal_status': 'modified',
|
||||
})
|
||||
|
||||
# Vérifier que les valeurs de modification ont été enregistrées
|
||||
self.assertEqual(self.slot.portal_start_datetime, new_start)
|
||||
self.assertEqual(self.slot.portal_end_datetime, new_end)
|
||||
self.assertTrue(self.slot.portal_modified)
|
||||
self.assertEqual(self.slot.portal_status, 'modified')
|
||||
|
||||
# Approuver la modification
|
||||
self.slot.action_approve_modification()
|
||||
|
||||
# Vérifier que les valeurs ont été appliquées au créneau
|
||||
self.assertEqual(self.slot.start_datetime, new_start)
|
||||
self.assertEqual(self.slot.end_datetime, new_end)
|
||||
self.assertEqual(self.slot.portal_status, 'confirmed')
|
||||
self.assertTrue(self.slot.portal_confirmed)
|
||||
self.assertFalse(self.slot.portal_modified)
|
||||
|
||||
def test_03_reject_modification(self):
|
||||
"""Test le rejet d'une modification de créneau."""
|
||||
# Nouvelles valeurs pour la modification
|
||||
new_start = self.start_datetime + timedelta(hours=1)
|
||||
new_end = self.end_datetime + timedelta(hours=1)
|
||||
|
||||
# Modifier le créneau
|
||||
self.slot.write({
|
||||
'portal_start_datetime': new_start,
|
||||
'portal_end_datetime': new_end,
|
||||
'portal_modified': True,
|
||||
'portal_status': 'modified',
|
||||
})
|
||||
|
||||
# Rejeter la modification
|
||||
self.slot.action_reject_modification()
|
||||
|
||||
# Vérifier que les valeurs originales sont conservées
|
||||
self.assertEqual(self.slot.start_datetime, self.start_datetime)
|
||||
self.assertEqual(self.slot.end_datetime, self.end_datetime)
|
||||
self.assertEqual(self.slot.portal_status, 'draft')
|
||||
self.assertFalse(self.slot.portal_confirmed)
|
||||
self.assertFalse(self.slot.portal_modified)
|
||||
|
||||
def test_04_generate_timesheet(self):
|
||||
"""Test la génération de feuille de temps à partir d'un créneau."""
|
||||
# Activer la génération de feuilles de temps
|
||||
self.slot.write({
|
||||
'generate_timesheet': True,
|
||||
'portal_status': 'confirmed',
|
||||
'portal_confirmed': True,
|
||||
})
|
||||
|
||||
# Générer la feuille de temps
|
||||
self.slot.action_generate_timesheet()
|
||||
|
||||
# Vérifier qu'une feuille de temps a été créée
|
||||
timesheet = self.env['account.analytic.line'].sudo().search([
|
||||
('planning_slot_id', '=', self.slot.id),
|
||||
('employee_id', '=', self.employee.id),
|
||||
], limit=1)
|
||||
|
||||
self.assertTrue(timesheet, "Une feuille de temps aurait dû être créée")
|
||||
self.assertEqual(timesheet.unit_amount, self.slot.allocated_hours)
|
||||
|
||||
def test_05_compute_can_modify(self):
|
||||
"""Test le calcul du champ portal_can_modify."""
|
||||
# Par défaut, un créneau en brouillon peut être modifié
|
||||
self.assertTrue(self.slot.portal_can_modify)
|
||||
|
||||
# Un créneau confirmé ne peut pas être modifié
|
||||
self.slot.write({
|
||||
'portal_status': 'confirmed',
|
||||
'portal_confirmed': True,
|
||||
})
|
||||
self.assertFalse(self.slot.portal_can_modify)
|
||||
|
||||
# Un créneau déjà modifié ne peut pas être modifié à nouveau
|
||||
self.slot.write({
|
||||
'portal_status': 'modified',
|
||||
'portal_modified': True,
|
||||
'portal_confirmed': False,
|
||||
})
|
||||
self.assertFalse(self.slot.portal_can_modify)
|
||||
|
||||
def test_06_compute_can_confirm(self):
|
||||
"""Test le calcul du champ portal_can_confirm."""
|
||||
# Par défaut, un créneau en brouillon peut être confirmé
|
||||
self.assertTrue(self.slot.portal_can_confirm)
|
||||
|
||||
# Un créneau déjà confirmé ne peut pas être confirmé à nouveau
|
||||
self.slot.write({
|
||||
'portal_status': 'confirmed',
|
||||
'portal_confirmed': True,
|
||||
})
|
||||
self.assertFalse(self.slot.portal_can_confirm)
|
||||
|
||||
# Un créneau modifié ne peut pas être confirmé
|
||||
self.slot.write({
|
||||
'portal_status': 'modified',
|
||||
'portal_modified': True,
|
||||
'portal_confirmed': False,
|
||||
})
|
||||
self.assertFalse(self.slot.portal_can_confirm)
|
||||
|
||||
def test_07_compute_can_exchange(self):
|
||||
"""Test le calcul du champ portal_can_exchange."""
|
||||
# Par défaut, un créneau peut être échangé
|
||||
self.assertTrue(self.slot.portal_can_exchange)
|
||||
|
||||
# Un créneau avec une demande d'échange en cours ne peut pas être échangé à nouveau
|
||||
exchange = self.env['portal.planning.exchange'].sudo().create({
|
||||
'name': 'Test Exchange',
|
||||
'source_slot_id': self.slot.id,
|
||||
'source_user_id': self.portal_user.id,
|
||||
'state': 'pending',
|
||||
})
|
||||
|
||||
# Rafraîchir le créneau pour recalculer les champs calculés
|
||||
self.slot.refresh()
|
||||
|
||||
# Vérifier que le créneau ne peut plus être échangé
|
||||
self.assertFalse(self.slot.portal_can_exchange)
|
||||
|
||||
# Annuler la demande d'échange
|
||||
exchange.action_cancel()
|
||||
|
||||
# Rafraîchir le créneau pour recalculer les champs calculés
|
||||
self.slot.refresh()
|
||||
|
||||
# Vérifier que le créneau peut à nouveau être échangé
|
||||
self.assertTrue(self.slot.portal_can_exchange)
|
||||
223
portal_planning/tests/test_portal_planning_exchange.py
Normal file
223
portal_planning/tests/test_portal_planning_exchange.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPortalPlanningExchange(TransactionCase):
|
||||
"""Test cases for portal.planning.exchange model."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# Créer un utilisateur portail source
|
||||
cls.portal_user_source = cls.env['res.users'].create({
|
||||
'name': 'Portal User Source',
|
||||
'login': 'portal_source_user',
|
||||
'email': 'portal_source@example.com',
|
||||
'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])],
|
||||
})
|
||||
|
||||
# Créer un utilisateur portail cible
|
||||
cls.portal_user_target = cls.env['res.users'].create({
|
||||
'name': 'Portal User Target',
|
||||
'login': 'portal_target_user',
|
||||
'email': 'portal_target@example.com',
|
||||
'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])],
|
||||
})
|
||||
|
||||
# Créer des employés liés aux utilisateurs portail
|
||||
cls.employee_source = cls.env['hr.employee'].create({
|
||||
'name': 'Source Employee',
|
||||
'user_id': cls.portal_user_source.id,
|
||||
})
|
||||
|
||||
cls.employee_target = cls.env['hr.employee'].create({
|
||||
'name': 'Target Employee',
|
||||
'user_id': cls.portal_user_target.id,
|
||||
})
|
||||
|
||||
# Créer un rôle de planning
|
||||
cls.role = cls.env['planning.role'].create({
|
||||
'name': 'Test Role',
|
||||
})
|
||||
|
||||
# Dates pour les tests
|
||||
cls.start_datetime_source = datetime.now().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
cls.end_datetime_source = cls.start_datetime_source + timedelta(hours=4)
|
||||
|
||||
cls.start_datetime_target = datetime.now().replace(hour=14, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
cls.end_datetime_target = cls.start_datetime_target + timedelta(hours=4)
|
||||
|
||||
# Créer des créneaux de planning
|
||||
cls.slot_source = cls.env['planning.slot'].sudo().create({
|
||||
'name': 'Source Slot',
|
||||
'start_datetime': cls.start_datetime_source,
|
||||
'end_datetime': cls.end_datetime_source,
|
||||
'role_id': cls.role.id,
|
||||
'employee_id': cls.employee_source.id,
|
||||
'allocated_hours': 4.0,
|
||||
})
|
||||
|
||||
cls.slot_target = cls.env['planning.slot'].sudo().create({
|
||||
'name': 'Target Slot',
|
||||
'start_datetime': cls.start_datetime_target,
|
||||
'end_datetime': cls.end_datetime_target,
|
||||
'role_id': cls.role.id,
|
||||
'employee_id': cls.employee_target.id,
|
||||
'allocated_hours': 4.0,
|
||||
})
|
||||
|
||||
def test_01_create_exchange_request(self):
|
||||
"""Test la création d'une demande d'échange."""
|
||||
# Créer une demande d'échange
|
||||
exchange = self.env['portal.planning.exchange'].sudo().create({
|
||||
'name': 'Test Exchange Request',
|
||||
'source_slot_id': self.slot_source.id,
|
||||
'target_slot_id': self.slot_target.id,
|
||||
'source_user_id': self.portal_user_source.id,
|
||||
'target_user_id': self.portal_user_target.id,
|
||||
'state': 'draft',
|
||||
'reason': 'Test reason',
|
||||
})
|
||||
|
||||
# Vérifier que la demande a été créée correctement
|
||||
self.assertEqual(exchange.name, 'Test Exchange Request')
|
||||
self.assertEqual(exchange.state, 'draft')
|
||||
self.assertEqual(exchange.source_slot_id, self.slot_source)
|
||||
self.assertEqual(exchange.target_slot_id, self.slot_target)
|
||||
self.assertEqual(exchange.source_user_id, self.portal_user_source)
|
||||
self.assertEqual(exchange.target_user_id, self.portal_user_target)
|
||||
|
||||
def test_02_submit_exchange_request(self):
|
||||
"""Test la soumission d'une demande d'échange."""
|
||||
# Créer une demande d'échange
|
||||
exchange = self.env['portal.planning.exchange'].sudo().create({
|
||||
'name': 'Test Exchange Request',
|
||||
'source_slot_id': self.slot_source.id,
|
||||
'target_slot_id': self.slot_target.id,
|
||||
'source_user_id': self.portal_user_source.id,
|
||||
'target_user_id': self.portal_user_target.id,
|
||||
'state': 'draft',
|
||||
'reason': 'Test reason',
|
||||
})
|
||||
|
||||
# Soumettre la demande
|
||||
exchange.action_submit()
|
||||
|
||||
# Vérifier que l'état a changé
|
||||
self.assertEqual(exchange.state, 'pending')
|
||||
|
||||
def test_03_approve_exchange_request(self):
|
||||
"""Test l'approbation d'une demande d'échange."""
|
||||
# Créer une demande d'échange
|
||||
exchange = self.env['portal.planning.exchange'].sudo().create({
|
||||
'name': 'Test Exchange Request',
|
||||
'source_slot_id': self.slot_source.id,
|
||||
'target_slot_id': self.slot_target.id,
|
||||
'source_user_id': self.portal_user_source.id,
|
||||
'target_user_id': self.portal_user_target.id,
|
||||
'state': 'pending', # Déjà en attente
|
||||
'reason': 'Test reason',
|
||||
})
|
||||
|
||||
# Approuver la demande
|
||||
exchange.action_approve()
|
||||
|
||||
# Vérifier que l'état a changé
|
||||
self.assertEqual(exchange.state, 'approved')
|
||||
|
||||
# Vérifier que les employés des créneaux ont été échangés
|
||||
self.slot_source.refresh()
|
||||
self.slot_target.refresh()
|
||||
|
||||
self.assertEqual(self.slot_source.employee_id, self.employee_target)
|
||||
self.assertEqual(self.slot_target.employee_id, self.employee_source)
|
||||
|
||||
def test_04_reject_exchange_request(self):
|
||||
"""Test le refus d'une demande d'échange."""
|
||||
# Créer une demande d'échange
|
||||
exchange = self.env['portal.planning.exchange'].sudo().create({
|
||||
'name': 'Test Exchange Request',
|
||||
'source_slot_id': self.slot_source.id,
|
||||
'target_slot_id': self.slot_target.id,
|
||||
'source_user_id': self.portal_user_source.id,
|
||||
'target_user_id': self.portal_user_target.id,
|
||||
'state': 'pending', # Déjà en attente
|
||||
'reason': 'Test reason',
|
||||
})
|
||||
|
||||
# Refuser la demande
|
||||
exchange.action_reject()
|
||||
|
||||
# Vérifier que l'état a changé
|
||||
self.assertEqual(exchange.state, 'rejected')
|
||||
|
||||
# Vérifier que les employés des créneaux n'ont pas été échangés
|
||||
self.slot_source.refresh()
|
||||
self.slot_target.refresh()
|
||||
|
||||
self.assertEqual(self.slot_source.employee_id, self.employee_source)
|
||||
self.assertEqual(self.slot_target.employee_id, self.employee_target)
|
||||
|
||||
def test_05_cancel_exchange_request(self):
|
||||
"""Test l'annulation d'une demande d'échange."""
|
||||
# Créer une demande d'échange
|
||||
exchange = self.env['portal.planning.exchange'].sudo().create({
|
||||
'name': 'Test Exchange Request',
|
||||
'source_slot_id': self.slot_source.id,
|
||||
'target_slot_id': self.slot_target.id,
|
||||
'source_user_id': self.portal_user_source.id,
|
||||
'target_user_id': self.portal_user_target.id,
|
||||
'state': 'draft',
|
||||
'reason': 'Test reason',
|
||||
})
|
||||
|
||||
# Annuler la demande
|
||||
exchange.action_cancel()
|
||||
|
||||
# Vérifier que l'état a changé
|
||||
self.assertEqual(exchange.state, 'cancelled')
|
||||
|
||||
def test_06_validation_different_employees(self):
|
||||
"""Test la validation que les employés source et cible sont différents."""
|
||||
# Créer un créneau avec le même employé que le créneau source
|
||||
slot_same_employee = self.env['planning.slot'].sudo().create({
|
||||
'name': 'Same Employee Slot',
|
||||
'start_datetime': self.start_datetime_target,
|
||||
'end_datetime': self.end_datetime_target,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee_source.id, # Même employé que le créneau source
|
||||
'allocated_hours': 4.0,
|
||||
})
|
||||
|
||||
# Essayer de créer une demande d'échange avec le même employé
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['portal.planning.exchange'].sudo().create({
|
||||
'name': 'Test Exchange Request',
|
||||
'source_slot_id': self.slot_source.id,
|
||||
'target_slot_id': slot_same_employee.id, # Créneau avec le même employé
|
||||
'source_user_id': self.portal_user_source.id,
|
||||
'target_user_id': self.portal_user_source.id, # Même utilisateur
|
||||
'state': 'draft',
|
||||
'reason': 'Test reason',
|
||||
})
|
||||
|
||||
def test_07_compute_name(self):
|
||||
"""Test le calcul automatique du nom de la demande d'échange."""
|
||||
# Créer une demande d'échange sans nom
|
||||
exchange = self.env['portal.planning.exchange'].sudo().create({
|
||||
'source_slot_id': self.slot_source.id,
|
||||
'target_slot_id': self.slot_target.id,
|
||||
'source_user_id': self.portal_user_source.id,
|
||||
'target_user_id': self.portal_user_target.id,
|
||||
'state': 'draft',
|
||||
'reason': 'Test reason',
|
||||
})
|
||||
|
||||
# Vérifier que le nom a été généré automatiquement
|
||||
expected_name = f"Échange: {self.slot_source.name} <-> {self.slot_target.name}"
|
||||
self.assertEqual(exchange.name, expected_name)
|
||||
312
portal_planning/tests/test_portal_planning_integration.py
Normal file
312
portal_planning/tests/test_portal_planning_integration.py
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
from odoo.tests import Form
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPortalPlanningIntegration(TransactionCase):
|
||||
"""Test d'intégration pour les workflows complets du module portal_planning."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# Créer un utilisateur portail
|
||||
cls.portal_user = cls.env['res.users'].create({
|
||||
'name': 'Portal User',
|
||||
'login': 'portal_test_user',
|
||||
'email': 'portal_test@example.com',
|
||||
'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])],
|
||||
})
|
||||
|
||||
# Créer un utilisateur manager
|
||||
cls.manager_user = cls.env['res.users'].create({
|
||||
'name': 'Manager User',
|
||||
'login': 'manager_test_user',
|
||||
'email': 'manager_test@example.com',
|
||||
'groups_id': [(6, 0, [cls.env.ref('planning.group_planning_manager').id])],
|
||||
})
|
||||
|
||||
# Créer un employé lié à l'utilisateur portail
|
||||
cls.employee = cls.env['hr.employee'].create({
|
||||
'name': 'Test Employee',
|
||||
'user_id': cls.portal_user.id,
|
||||
})
|
||||
|
||||
# Créer un rôle de planning
|
||||
cls.role = cls.env['planning.role'].create({
|
||||
'name': 'Test Role',
|
||||
})
|
||||
|
||||
# Dates pour les tests
|
||||
cls.start_datetime = datetime.now().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
cls.end_datetime = cls.start_datetime + timedelta(hours=4)
|
||||
|
||||
def test_01_complete_planning_request_workflow(self):
|
||||
"""Test du workflow complet de demande de planning: création, soumission, approbation, confirmation."""
|
||||
# 1. Créer une demande de planning en tant qu'utilisateur portail
|
||||
request_vals = {
|
||||
'name': 'Test Planning Request',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'user_id': self.portal_user.id,
|
||||
'state': 'draft',
|
||||
}
|
||||
|
||||
request = self.env['portal.planning.request'].sudo(self.portal_user).create(request_vals)
|
||||
|
||||
# Vérifier que la demande est bien créée
|
||||
self.assertEqual(request.state, 'draft')
|
||||
|
||||
# 2. Soumettre la demande
|
||||
request.sudo(self.portal_user).action_submit()
|
||||
self.assertEqual(request.state, 'submitted')
|
||||
|
||||
# 3. Approuver la demande en tant que manager
|
||||
request.sudo(self.manager_user).action_approve()
|
||||
self.assertEqual(request.state, 'approved')
|
||||
|
||||
# 4. Vérifier qu'un créneau de planning a été créé
|
||||
slot = self.env['planning.slot'].sudo().search([
|
||||
('employee_id', '=', self.employee.id),
|
||||
('start_datetime', '=', self.start_datetime),
|
||||
('end_datetime', '=', self.end_datetime),
|
||||
], limit=1)
|
||||
|
||||
self.assertTrue(slot, "Un créneau de planning aurait dû être créé")
|
||||
self.assertEqual(slot.role_id, self.role)
|
||||
|
||||
# 5. Confirmer le créneau en tant qu'utilisateur portail
|
||||
slot.sudo(self.portal_user).action_confirm_portal()
|
||||
|
||||
# Vérifier que le créneau est confirmé
|
||||
self.assertEqual(slot.portal_status, 'confirmed')
|
||||
self.assertTrue(slot.portal_confirmed)
|
||||
|
||||
def test_02_complete_planning_modification_workflow(self):
|
||||
"""Test du workflow complet de modification de planning: création, modification, approbation."""
|
||||
# 1. Créer un créneau de planning
|
||||
slot = self.env['planning.slot'].sudo().create({
|
||||
'name': 'Test Slot',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'allocated_hours': 4.0,
|
||||
'portal_status': 'draft',
|
||||
})
|
||||
|
||||
# 2. Modifier le créneau en tant qu'utilisateur portail
|
||||
new_start = self.start_datetime + timedelta(hours=1)
|
||||
new_end = self.end_datetime + timedelta(hours=1)
|
||||
|
||||
# Simuler une modification via le portail
|
||||
slot.sudo(self.portal_user).write({
|
||||
'portal_start_datetime': new_start,
|
||||
'portal_end_datetime': new_end,
|
||||
'portal_modified': True,
|
||||
'portal_status': 'modified',
|
||||
})
|
||||
|
||||
# Vérifier que le statut a changé
|
||||
self.assertEqual(slot.portal_status, 'modified')
|
||||
self.assertTrue(slot.portal_modified)
|
||||
|
||||
# 3. Approuver la modification en tant que manager
|
||||
slot.sudo(self.manager_user).action_approve_modification()
|
||||
|
||||
# Vérifier que les modifications ont été appliquées
|
||||
self.assertEqual(slot.start_datetime, new_start)
|
||||
self.assertEqual(slot.end_datetime, new_end)
|
||||
self.assertEqual(slot.portal_status, 'confirmed')
|
||||
self.assertTrue(slot.portal_confirmed)
|
||||
self.assertFalse(slot.portal_modified)
|
||||
|
||||
def test_03_complete_planning_exchange_workflow(self):
|
||||
"""Test du workflow complet d'échange de planning: création, soumission, approbation."""
|
||||
# 1. Créer un deuxième utilisateur portail et employé
|
||||
portal_user2 = self.env['res.users'].create({
|
||||
'name': 'Portal User 2',
|
||||
'login': 'portal_test_user2',
|
||||
'email': 'portal_test2@example.com',
|
||||
'groups_id': [(6, 0, [self.env.ref('base.group_portal').id])],
|
||||
})
|
||||
|
||||
employee2 = self.env['hr.employee'].create({
|
||||
'name': 'Test Employee 2',
|
||||
'user_id': portal_user2.id,
|
||||
})
|
||||
|
||||
# 2. Créer deux créneaux de planning
|
||||
slot1 = self.env['planning.slot'].sudo().create({
|
||||
'name': 'Slot 1',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'allocated_hours': 4.0,
|
||||
'portal_status': 'confirmed',
|
||||
'portal_confirmed': True,
|
||||
})
|
||||
|
||||
slot2 = self.env['planning.slot'].sudo().create({
|
||||
'name': 'Slot 2',
|
||||
'start_datetime': self.start_datetime + timedelta(days=1),
|
||||
'end_datetime': self.end_datetime + timedelta(days=1),
|
||||
'role_id': self.role.id,
|
||||
'employee_id': employee2.id,
|
||||
'allocated_hours': 4.0,
|
||||
'portal_status': 'confirmed',
|
||||
'portal_confirmed': True,
|
||||
})
|
||||
|
||||
# 3. Créer une demande d'échange en tant qu'utilisateur portail
|
||||
exchange = self.env['portal.planning.exchange'].sudo(self.portal_user).create({
|
||||
'name': 'Test Exchange',
|
||||
'source_slot_id': slot1.id,
|
||||
'target_slot_id': slot2.id,
|
||||
'source_user_id': self.portal_user.id,
|
||||
'target_user_id': portal_user2.id,
|
||||
'state': 'draft',
|
||||
'description': 'Test exchange reason',
|
||||
})
|
||||
|
||||
# Vérifier que la demande est bien créée
|
||||
self.assertEqual(exchange.state, 'draft')
|
||||
|
||||
# 4. Soumettre la demande
|
||||
exchange.sudo(self.portal_user).action_submit()
|
||||
self.assertEqual(exchange.state, 'pending')
|
||||
|
||||
# 5. Approuver la demande en tant que manager
|
||||
exchange.sudo(self.manager_user).action_approve()
|
||||
self.assertEqual(exchange.state, 'approved')
|
||||
|
||||
# 6. Vérifier que les employés ont été échangés
|
||||
slot1.refresh()
|
||||
slot2.refresh()
|
||||
|
||||
self.assertEqual(slot1.employee_id, employee2)
|
||||
self.assertEqual(slot2.employee_id, self.employee)
|
||||
|
||||
def test_04_integration_with_timesheet(self):
|
||||
"""Test de l'intégration avec les feuilles de temps."""
|
||||
# Vérifier si les modules nécessaires sont installés
|
||||
module_obj = self.env['ir.module.module']
|
||||
hr_timesheet_module = module_obj.search([('name', '=', 'hr_timesheet'), ('state', '=', 'installed')])
|
||||
project_module = module_obj.search([('name', '=', 'project'), ('state', '=', 'installed')])
|
||||
|
||||
if not (hr_timesheet_module and project_module):
|
||||
self.skipTest("Ce test nécessite les modules 'hr_timesheet' et 'project' installés")
|
||||
|
||||
try:
|
||||
# 1. Créer un projet et une tâche
|
||||
project = self.env['project.project'].create({
|
||||
'name': 'Test Project',
|
||||
})
|
||||
|
||||
task = self.env['project.task'].create({
|
||||
'name': 'Test Task',
|
||||
'project_id': project.id,
|
||||
})
|
||||
|
||||
# 2. Créer un créneau de planning avec génération de feuille de temps activée
|
||||
slot_vals = {
|
||||
'name': 'Test Slot',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'allocated_hours': 4.0,
|
||||
'portal_status': 'confirmed',
|
||||
'portal_confirmed': True,
|
||||
}
|
||||
|
||||
# Vérifier si les champs liés au timesheet existent
|
||||
slot_fields = self.env['planning.slot']._fields
|
||||
if 'generate_timesheet' in slot_fields:
|
||||
slot_vals['generate_timesheet'] = True
|
||||
if 'project_id' in slot_fields:
|
||||
slot_vals['project_id'] = project.id
|
||||
if 'task_id' in slot_fields:
|
||||
slot_vals['task_id'] = task.id
|
||||
|
||||
slot = self.env['planning.slot'].sudo().create(slot_vals)
|
||||
|
||||
# 3. Générer la feuille de temps si la méthode existe
|
||||
if hasattr(slot, 'action_generate_timesheet'):
|
||||
slot.sudo().action_generate_timesheet()
|
||||
|
||||
# 4. Vérifier qu'une feuille de temps a été créée
|
||||
timesheet = self.env['account.analytic.line'].sudo().search([
|
||||
('planning_slot_id', '=', slot.id),
|
||||
('employee_id', '=', self.employee.id),
|
||||
('project_id', '=', project.id),
|
||||
('task_id', '=', task.id),
|
||||
], limit=1)
|
||||
|
||||
self.assertTrue(timesheet, "Une feuille de temps aurait dû être créée")
|
||||
if hasattr(timesheet, 'unit_amount'):
|
||||
self.assertEqual(timesheet.unit_amount, slot.allocated_hours)
|
||||
except Exception as e:
|
||||
self.skipTest(f"Test ignoré en raison d'une erreur: {e}")
|
||||
|
||||
|
||||
def test_05_portal_access_rights(self):
|
||||
"""Test des droits d'accès des utilisateurs du portail."""
|
||||
# 1. Créer un créneau de planning pour l'employé
|
||||
slot = self.env['planning.slot'].sudo().create({
|
||||
'name': 'Test Slot',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'allocated_hours': 4.0,
|
||||
'portal_status': 'draft',
|
||||
})
|
||||
|
||||
# 2. Créer un autre employé et un créneau associé
|
||||
other_employee = self.env['hr.employee'].create({
|
||||
'name': 'Other Employee',
|
||||
})
|
||||
|
||||
other_slot = self.env['planning.slot'].sudo().create({
|
||||
'name': 'Other Slot',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': other_employee.id,
|
||||
'allocated_hours': 4.0,
|
||||
'portal_status': 'draft',
|
||||
})
|
||||
|
||||
# 3. Vérifier que l'utilisateur portail peut accéder à son propre créneau
|
||||
# mais pas à celui d'un autre employé
|
||||
# Note: Nous utilisons search_count car read pourrait lever une exception
|
||||
own_slots_count = self.env['planning.slot'].sudo(self.portal_user).search_count([
|
||||
('id', '=', slot.id)
|
||||
])
|
||||
|
||||
other_slots_count = self.env['planning.slot'].sudo(self.portal_user).search_count([
|
||||
('id', '=', other_slot.id)
|
||||
])
|
||||
|
||||
self.assertEqual(own_slots_count, 1, "L'utilisateur portail devrait pouvoir voir son propre créneau")
|
||||
self.assertEqual(other_slots_count, 0, "L'utilisateur portail ne devrait pas pouvoir voir le créneau d'un autre employé")
|
||||
|
||||
# 4. Vérifier que le manager peut accéder à tous les créneaux
|
||||
manager_own_slots_count = self.env['planning.slot'].sudo(self.manager_user).search_count([
|
||||
('id', '=', slot.id)
|
||||
])
|
||||
|
||||
manager_other_slots_count = self.env['planning.slot'].sudo(self.manager_user).search_count([
|
||||
('id', '=', other_slot.id)
|
||||
])
|
||||
|
||||
self.assertEqual(manager_own_slots_count, 1, "Le manager devrait pouvoir voir tous les créneaux")
|
||||
self.assertEqual(manager_other_slots_count, 1, "Le manager devrait pouvoir voir tous les créneaux")
|
||||
209
portal_planning/tests/test_portal_planning_module_integration.py
Normal file
209
portal_planning/tests/test_portal_planning_module_integration.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
from odoo.tests import Form
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPortalPlanningModuleIntegration(TransactionCase):
|
||||
"""Test d'intégration pour les interactions entre le module portal_planning et les autres modules."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# Créer un utilisateur portail
|
||||
cls.portal_user = cls.env['res.users'].create({
|
||||
'name': 'Portal User',
|
||||
'login': 'portal_test_user',
|
||||
'email': 'portal_test@example.com',
|
||||
'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])],
|
||||
})
|
||||
|
||||
# Créer un utilisateur manager
|
||||
cls.manager_user = cls.env['res.users'].create({
|
||||
'name': 'Manager User',
|
||||
'login': 'manager_test_user',
|
||||
'email': 'manager_test@example.com',
|
||||
'groups_id': [(6, 0, [cls.env.ref('planning.group_planning_manager').id])],
|
||||
})
|
||||
|
||||
# Créer un employé lié à l'utilisateur portail
|
||||
cls.employee = cls.env['hr.employee'].create({
|
||||
'name': 'Test Employee',
|
||||
'user_id': cls.portal_user.id,
|
||||
})
|
||||
|
||||
# Créer un rôle de planning
|
||||
cls.role = cls.env['planning.role'].create({
|
||||
'name': 'Test Role',
|
||||
})
|
||||
|
||||
# Créer un projet et une tâche pour les feuilles de temps
|
||||
cls.project = cls.env['project.project'].create({
|
||||
'name': 'Test Project',
|
||||
})
|
||||
|
||||
cls.task = cls.env['project.task'].create({
|
||||
'name': 'Test Task',
|
||||
'project_id': cls.project.id,
|
||||
})
|
||||
|
||||
# Dates pour les tests
|
||||
cls.start_datetime = datetime.now().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
cls.end_datetime = cls.start_datetime + timedelta(hours=4)
|
||||
|
||||
def test_01_planning_module_integration(self):
|
||||
"""Test de l'intégration avec le module planning."""
|
||||
# 1. Créer un créneau de planning via le module planning standard
|
||||
slot = self.env['planning.slot'].sudo(self.manager_user).create({
|
||||
'name': 'Test Slot',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'allocated_hours': 4.0,
|
||||
})
|
||||
|
||||
# 2. Vérifier que les champs spécifiques au portail ont été ajoutés
|
||||
self.assertIn('portal_status', slot._fields)
|
||||
self.assertIn('portal_confirmed', slot._fields)
|
||||
self.assertIn('portal_modified', slot._fields)
|
||||
self.assertIn('portal_can_confirm', slot._fields)
|
||||
self.assertIn('portal_can_modify', slot._fields)
|
||||
self.assertIn('portal_can_exchange', slot._fields)
|
||||
|
||||
# 3. Vérifier que le statut par défaut est 'draft'
|
||||
self.assertEqual(slot.portal_status, 'draft')
|
||||
self.assertFalse(slot.portal_confirmed)
|
||||
self.assertFalse(slot.portal_modified)
|
||||
|
||||
# 4. Vérifier que l'utilisateur portail peut accéder à son créneau
|
||||
portal_slot = self.env['planning.slot'].sudo(self.portal_user).search([
|
||||
('id', '=', slot.id)
|
||||
], limit=1)
|
||||
|
||||
self.assertTrue(portal_slot, "L'utilisateur portail devrait pouvoir accéder à son créneau")
|
||||
|
||||
def test_02_timesheet_module_integration(self):
|
||||
"""Test de l'intégration avec le module timesheet."""
|
||||
# 1. Créer un créneau de planning avec génération de feuille de temps
|
||||
slot = self.env['planning.slot'].sudo().create({
|
||||
'name': 'Test Slot',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'allocated_hours': 4.0,
|
||||
'portal_status': 'confirmed',
|
||||
'portal_confirmed': True,
|
||||
'generate_timesheet': True,
|
||||
'project_id': self.project.id,
|
||||
'task_id': self.task.id,
|
||||
})
|
||||
|
||||
# 2. Générer la feuille de temps
|
||||
slot.sudo().action_generate_timesheet()
|
||||
|
||||
# 3. Vérifier qu'une feuille de temps a été créée
|
||||
timesheet = self.env['account.analytic.line'].sudo().search([
|
||||
('planning_slot_id', '=', slot.id),
|
||||
('employee_id', '=', self.employee.id),
|
||||
('project_id', '=', self.project.id),
|
||||
('task_id', '=', self.task.id),
|
||||
], limit=1)
|
||||
|
||||
self.assertTrue(timesheet, "Une feuille de temps aurait dû être créée")
|
||||
self.assertEqual(timesheet.unit_amount, slot.allocated_hours)
|
||||
|
||||
# 4. Vérifier que la feuille de temps est liée au créneau de planning
|
||||
self.assertEqual(timesheet.planning_slot_id, slot)
|
||||
|
||||
# 5. Vérifier que l'utilisateur portail peut voir sa feuille de temps
|
||||
portal_timesheet = self.env['account.analytic.line'].sudo(self.portal_user).search([
|
||||
('id', '=', timesheet.id)
|
||||
], limit=1)
|
||||
|
||||
self.assertTrue(portal_timesheet, "L'utilisateur portail devrait pouvoir voir sa feuille de temps")
|
||||
|
||||
def test_03_mail_module_integration(self):
|
||||
"""Test de l'intégration avec le module mail pour les notifications."""
|
||||
# 1. Créer une demande de planning
|
||||
request = self.env['portal.planning.request'].sudo().create({
|
||||
'name': 'Test Planning Request',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'user_id': self.portal_user.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# 2. Vérifier que le modèle hérite de mail.thread
|
||||
self.assertTrue(hasattr(request, 'message_post'), "Le modèle devrait hériter de mail.thread")
|
||||
|
||||
# 3. Soumettre la demande et vérifier qu'un message est posté
|
||||
messages_before = len(request.message_ids)
|
||||
request.sudo(self.portal_user).action_submit()
|
||||
messages_after = len(request.message_ids)
|
||||
|
||||
self.assertTrue(messages_after > messages_before, "Un message devrait être posté lors de la soumission")
|
||||
|
||||
# 4. Approuver la demande et vérifier qu'un message est posté
|
||||
messages_before = len(request.message_ids)
|
||||
request.sudo(self.manager_user).action_approve()
|
||||
messages_after = len(request.message_ids)
|
||||
|
||||
self.assertTrue(messages_after > messages_before, "Un message devrait être posté lors de l'approbation")
|
||||
|
||||
def test_04_portal_module_integration(self):
|
||||
"""Test de l'intégration avec le module portal."""
|
||||
# 1. Créer un créneau de planning
|
||||
slot = self.env['planning.slot'].sudo().create({
|
||||
'name': 'Test Slot',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'allocated_hours': 4.0,
|
||||
'portal_status': 'draft',
|
||||
})
|
||||
|
||||
# 2. Vérifier que le contrôleur du portail est correctement configuré
|
||||
# Note: Nous ne pouvons pas tester directement les routes HTTP dans un test unitaire,
|
||||
# mais nous pouvons vérifier que les méthodes existent dans le contrôleur
|
||||
|
||||
portal_controller = self.env['ir.http']._get_default_lang().sudo()._get_http_routing_module_status()
|
||||
self.assertTrue(portal_controller, "Le module portal devrait être installé")
|
||||
|
||||
# 3. Vérifier que les règles de sécurité permettent à l'utilisateur portail
|
||||
# d'accéder à ses propres créneaux mais pas à ceux des autres
|
||||
|
||||
# Créer un autre employé et un créneau associé
|
||||
other_employee = self.env['hr.employee'].create({
|
||||
'name': 'Other Employee',
|
||||
})
|
||||
|
||||
other_slot = self.env['planning.slot'].sudo().create({
|
||||
'name': 'Other Slot',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': other_employee.id,
|
||||
'allocated_hours': 4.0,
|
||||
'portal_status': 'draft',
|
||||
})
|
||||
|
||||
# Vérifier l'accès
|
||||
own_slots_count = self.env['planning.slot'].sudo(self.portal_user).search_count([
|
||||
('id', '=', slot.id)
|
||||
])
|
||||
|
||||
other_slots_count = self.env['planning.slot'].sudo(self.portal_user).search_count([
|
||||
('id', '=', other_slot.id)
|
||||
])
|
||||
|
||||
self.assertEqual(own_slots_count, 1, "L'utilisateur portail devrait pouvoir voir son propre créneau")
|
||||
self.assertEqual(other_slots_count, 0, "L'utilisateur portail ne devrait pas pouvoir voir le créneau d'un autre employé")
|
||||
196
portal_planning/tests/test_portal_planning_request.py
Normal file
196
portal_planning/tests/test_portal_planning_request.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPortalPlanningRequest(TransactionCase):
|
||||
"""Test cases for portal.planning.request model."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# Créer un utilisateur portail
|
||||
cls.portal_user = cls.env['res.users'].create({
|
||||
'name': 'Portal User',
|
||||
'login': 'portal_test_user',
|
||||
'email': 'portal_test@example.com',
|
||||
'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])],
|
||||
})
|
||||
|
||||
# Créer un employé lié à l'utilisateur portail
|
||||
cls.employee = cls.env['hr.employee'].create({
|
||||
'name': 'Test Employee',
|
||||
'user_id': cls.portal_user.id,
|
||||
})
|
||||
|
||||
# Créer un rôle de planning
|
||||
cls.role = cls.env['planning.role'].create({
|
||||
'name': 'Test Role',
|
||||
})
|
||||
|
||||
# Dates pour les tests
|
||||
cls.start_datetime = datetime.now().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
cls.end_datetime = cls.start_datetime + timedelta(hours=4)
|
||||
|
||||
def test_01_create_planning_request(self):
|
||||
"""Test la création d'une demande de planning."""
|
||||
# Créer une demande de planning
|
||||
request = self.env['portal.planning.request'].sudo().create({
|
||||
'name': 'Test Planning Request',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'user_id': self.portal_user.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Vérifier que la demande a été créée correctement
|
||||
self.assertEqual(request.name, 'Test Planning Request')
|
||||
self.assertEqual(request.state, 'draft')
|
||||
self.assertEqual(request.employee_id, self.employee)
|
||||
self.assertEqual(request.role_id, self.role)
|
||||
|
||||
# Vérifier les heures allouées
|
||||
expected_hours = 4.0 # 4 heures entre start_datetime et end_datetime
|
||||
self.assertEqual(request.allocated_hours, expected_hours)
|
||||
|
||||
def test_02_submit_planning_request(self):
|
||||
"""Test la soumission d'une demande de planning."""
|
||||
# Créer une demande de planning
|
||||
request = self.env['portal.planning.request'].sudo().create({
|
||||
'name': 'Test Planning Request',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'user_id': self.portal_user.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Soumettre la demande
|
||||
request.action_submit()
|
||||
|
||||
# Vérifier que l'état a changé
|
||||
self.assertEqual(request.state, 'submitted')
|
||||
|
||||
def test_03_approve_planning_request(self):
|
||||
"""Test l'approbation d'une demande de planning."""
|
||||
# Créer une demande de planning
|
||||
request = self.env['portal.planning.request'].sudo().create({
|
||||
'name': 'Test Planning Request',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'user_id': self.portal_user.id,
|
||||
'state': 'submitted', # Déjà soumise
|
||||
})
|
||||
|
||||
# Approuver la demande
|
||||
request.action_approve()
|
||||
|
||||
# Vérifier que l'état a changé
|
||||
self.assertEqual(request.state, 'approved')
|
||||
|
||||
# Vérifier qu'un créneau de planning a été créé
|
||||
slot = self.env['planning.slot'].sudo().search([
|
||||
('employee_id', '=', self.employee.id),
|
||||
('start_datetime', '=', self.start_datetime),
|
||||
('end_datetime', '=', self.end_datetime),
|
||||
], limit=1)
|
||||
|
||||
self.assertTrue(slot, "Un créneau de planning aurait dû être créé")
|
||||
self.assertEqual(slot.role_id, self.role)
|
||||
|
||||
def test_04_reject_planning_request(self):
|
||||
"""Test le refus d'une demande de planning."""
|
||||
# Créer une demande de planning
|
||||
request = self.env['portal.planning.request'].sudo().create({
|
||||
'name': 'Test Planning Request',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'user_id': self.portal_user.id,
|
||||
'state': 'submitted', # Déjà soumise
|
||||
})
|
||||
|
||||
# Refuser la demande
|
||||
request.action_reject()
|
||||
|
||||
# Vérifier que l'état a changé
|
||||
self.assertEqual(request.state, 'rejected')
|
||||
|
||||
def test_05_cancel_planning_request(self):
|
||||
"""Test l'annulation d'une demande de planning."""
|
||||
# Créer une demande de planning
|
||||
request = self.env['portal.planning.request'].sudo().create({
|
||||
'name': 'Test Planning Request',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'user_id': self.portal_user.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Annuler la demande
|
||||
request.action_cancel()
|
||||
|
||||
# Vérifier que l'état a changé
|
||||
self.assertEqual(request.state, 'cancelled')
|
||||
|
||||
def test_06_reset_to_draft_planning_request(self):
|
||||
"""Test la remise en brouillon d'une demande de planning."""
|
||||
# Créer une demande de planning
|
||||
request = self.env['portal.planning.request'].sudo().create({
|
||||
'name': 'Test Planning Request',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.end_datetime,
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'user_id': self.portal_user.id,
|
||||
'state': 'rejected', # Déjà refusée
|
||||
})
|
||||
|
||||
# Remettre en brouillon
|
||||
request.action_reset_to_draft()
|
||||
|
||||
# Vérifier que l'état a changé
|
||||
self.assertEqual(request.state, 'draft')
|
||||
|
||||
def test_07_validation_end_datetime_after_start_datetime(self):
|
||||
"""Test la validation que la date de fin est après la date de début."""
|
||||
# Essayer de créer une demande avec une date de fin avant la date de début
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['portal.planning.request'].sudo().create({
|
||||
'name': 'Test Planning Request',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.start_datetime - timedelta(hours=1), # Date de fin avant la date de début
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'user_id': self.portal_user.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
def test_08_compute_allocated_hours(self):
|
||||
"""Test le calcul des heures allouées."""
|
||||
# Créer une demande de planning avec une durée spécifique
|
||||
duration_hours = 6
|
||||
request = self.env['portal.planning.request'].sudo().create({
|
||||
'name': 'Test Planning Request',
|
||||
'start_datetime': self.start_datetime,
|
||||
'end_datetime': self.start_datetime + timedelta(hours=duration_hours),
|
||||
'role_id': self.role.id,
|
||||
'employee_id': self.employee.id,
|
||||
'user_id': self.portal_user.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Vérifier que les heures allouées sont correctement calculées
|
||||
self.assertEqual(request.allocated_hours, float(duration_hours))
|
||||
168
portal_planning/todo.md
Normal file
168
portal_planning/todo.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# Todo List - Module Portal Planning
|
||||
|
||||
## 1. Configuration initiale
|
||||
|
||||
- [x] 1.1 Créer la structure de base du module
|
||||
- [x] Créer le fichier `__manifest__.py` avec les dépendances (planning, portal, timesheet)
|
||||
- [x] Créer les dossiers de base (models, controllers, views, security, static)
|
||||
- [x] Créer le fichier `__init__.py` à la racine
|
||||
- **Observation**: La structure de base est déjà en place avec les dépendances nécessaires.
|
||||
|
||||
- [x] 1.2 Configurer les fichiers de sécurité
|
||||
- [x] Créer le fichier `security/portal_planning_security.xml` pour les groupes de sécurité
|
||||
- [x] Créer le fichier `security/ir.model.access.csv` pour les droits d'accès
|
||||
- [x] Définir les règles de sécurité pour les utilisateurs du portail
|
||||
- **Observation**: Les règles de sécurité de base sont en place, permettant aux utilisateurs du portail d'accéder à leurs propres créneaux de planning.
|
||||
|
||||
- [x] 1.3 Configurer les paramètres du module
|
||||
- [x] Créer le fichier `data/portal_planning_data.xml` pour les paramètres par défaut
|
||||
- **Observation**: Les paramètres par défaut ont été configurés, incluant les options d'approbation automatique et de génération de feuilles de temps.
|
||||
|
||||
## 2. Extensions de modèles
|
||||
|
||||
- [x] 2.1 Étendre le modèle `planning.slot`
|
||||
- [x] Ajouter les champs pour la modification via le portail
|
||||
- [x] Ajouter les champs pour la création via le portail
|
||||
- [x] Implémenter les méthodes de validation des modifications/créations
|
||||
- **Observation**: Le modèle a été étendu avec tous les champs nécessaires et les méthodes pour gérer les modifications et confirmations via le portail.
|
||||
|
||||
- [x] 2.2 Étendre le modèle `resource.resource`
|
||||
- [x] Ajouter les champs pour l'approbation automatique
|
||||
- **Observation**: Le modèle a été étendu pour permettre la configuration de l'approbation automatique au niveau de la ressource.
|
||||
|
||||
- [x] 2.3 Étendre le modèle `account.analytic.line` (timesheet)
|
||||
- [x] Ajouter les champs pour lier les entrées timesheet aux créneaux de planning
|
||||
- **Observation**: Le modèle a été étendu pour lier les feuilles de temps aux créneaux de planning et gérer leur création automatique.
|
||||
|
||||
## 3. Nouveaux modèles
|
||||
|
||||
- [x] 3.1 Créer le modèle `portal.planning.request`
|
||||
- [x] Définir les champs pour les demandes de planning
|
||||
- [x] Implémenter les méthodes de validation/refus
|
||||
- **Observation**: Un modèle `portal.planning.request` a été créé à la place des modèles `portal.planning.modification` et `portal.planning.creation` initialement prévus. Ce modèle gère les demandes de création de créneaux de planning par les utilisateurs du portail.
|
||||
|
||||
- [x] 3.2 Créer le modèle `portal.planning.exchange`
|
||||
- [x] Définir les champs pour les demandes d'échange
|
||||
- [x] Implémenter les méthodes de validation/refus
|
||||
- **Observation**: Le modèle a été créé avec tous les champs nécessaires et les méthodes pour gérer les demandes d'échange entre créneaux.
|
||||
|
||||
## 4. Contrôleurs
|
||||
|
||||
- [x] 4.1 Étendre le contrôleur `portal.CustomerPortal`
|
||||
- [x] Implémenter la route `/my/planning` pour afficher la liste des demandes de planning
|
||||
- [x] Implémenter la route `/my/planning/<int:planning_id>` pour afficher le détail d'une demande
|
||||
- [x] Implémenter la route `/my/planning/create` pour créer une nouvelle demande
|
||||
- [x] Implémenter la route `/my/planning/submit/<int:planning_id>` pour soumettre une demande
|
||||
- [x] Implémenter la route `/my/planning/cancel/<int:planning_id>` pour annuler une demande
|
||||
- **Observation**: Le contrôleur de base est implémenté pour gérer les demandes de planning, mais il manque les routes pour la gestion directe des créneaux de planning.
|
||||
|
||||
- [x] 4.2 Ajouter les routes pour la gestion des créneaux de planning
|
||||
- [x] Implémenter la route `/my/planning/slot/<int:slot_id>` pour afficher un créneau
|
||||
- [x] Implémenter la route `/my/planning/slot/<int:slot_id>/confirm` pour confirmer un créneau
|
||||
- [x] Implémenter la route `/my/planning/slot/<int:slot_id>/modify` pour modifier un créneau
|
||||
- [x] Implémenter la route `/my/planning/slot/<int:slot_id>/exchange` pour échanger un créneau
|
||||
- **Observation**: Les routes pour la gestion des créneaux ont été implémentées dans le fichier `controllers/planning_slot.py`.
|
||||
|
||||
- [x] 4.3 Créer le contrôleur pour l'API JSON
|
||||
- [x] Implémenter l'endpoint `/planning/slot/update` pour mettre à jour un créneau
|
||||
- [x] Implémenter l'endpoint `/planning/slot/confirm` pour confirmer un créneau
|
||||
- [x] Implémenter l'endpoint `/planning/slot/exchange/request` pour demander un échange
|
||||
- **Observation**: Les endpoints JSON ont été implémentés dans le fichier `controllers/api.py` pour permettre les interactions AJAX avec les créneaux de planning.
|
||||
|
||||
## 5. Vues et templates
|
||||
|
||||
- [x] 5.1 Créer les templates du portail pour les demandes de planning
|
||||
- [x] Intégrer le planning dans le portail (menu et breadcrumbs)
|
||||
- [x] Créer la vue d'ensemble des demandes de planning
|
||||
- [x] Créer la vue détaillée d'une demande de planning
|
||||
- [x] Créer le formulaire de création d'une demande
|
||||
- **Observation**: Les templates de base pour la gestion des demandes de planning sont en place.
|
||||
|
||||
- [x] 5.2 Créer les templates pour la gestion des créneaux de planning
|
||||
- [x] Créer la vue d'ensemble des créneaux de planning
|
||||
- [x] Créer la vue détaillée d'un créneau
|
||||
- [x] Créer le formulaire de modification d'un créneau
|
||||
- [x] Créer l'interface pour les échanges de créneaux
|
||||
- **Observation**: Les templates pour la gestion des créneaux ont été créés dans les fichiers `views/portal_planning_slot_templates.xml` et `views/portal_planning_exchange_templates.xml`.
|
||||
|
||||
- [x] 5.3 Créer les vues backend
|
||||
- [x] Créer les vues pour le modèle `portal.planning.request`
|
||||
- **Observation**: Les vues backend pour le modèle `portal.planning.request` sont en place.
|
||||
|
||||
- [x] 5.4 Compléter les vues backend
|
||||
- [x] Étendre les vues du modèle `planning.slot` pour intégrer les fonctionnalités du portail
|
||||
- [x] Créer les vues pour le modèle `portal.planning.exchange`
|
||||
- [x] Créer les vues pour le modèle `portal.planning.modification`
|
||||
- **Observation**: Toutes les vues backend ont été créées pour les modèles du module.
|
||||
|
||||
## 6. Fonctionnalités de confirmation et timesheet
|
||||
|
||||
- [x] 6.1 Implémenter la confirmation des créneaux
|
||||
- [x] Développer la logique de confirmation
|
||||
- [x] Ajouter les notifications de confirmation
|
||||
- **Observation**: La logique de confirmation des créneaux a été implémentée dans le modèle `planning.slot`.
|
||||
|
||||
- [x] 6.2 Implémenter la génération automatique des timesheets
|
||||
- [x] Développer la logique de vérification des entrées existantes
|
||||
- [x] Implémenter la création des entrées pour les périodes non couvertes
|
||||
- [x] Lier les entrées timesheet aux créneaux de planning
|
||||
- **Observation**: La génération automatique des feuilles de temps a été implémentée dans le modèle `planning.slot`.
|
||||
|
||||
## 7. Fonctionnalités de modification
|
||||
|
||||
- [x] 7.1 Implémenter la modification des créneaux
|
||||
- [x] Développer la logique de validation des modifications
|
||||
- [x] Implémenter le processus d'approbation
|
||||
- [x] Ajouter les notifications de modification
|
||||
- **Observation**: La logique de modification des créneaux a été implémentée dans le modèle `planning.slot` et `portal.planning.modification`.
|
||||
|
||||
## 8. Fonctionnalités d'échange
|
||||
|
||||
- [x] 8.1 Implémenter l'échange de créneaux
|
||||
- [x] Développer la logique de demande d'échange
|
||||
- [x] Implémenter le processus d'approbation
|
||||
- [x] Ajouter les notifications d'échange
|
||||
- **Observation**: La logique d'échange des créneaux a été implémentée dans le modèle `portal.planning.exchange`.
|
||||
|
||||
## 9. Interface utilisateur et expérience
|
||||
|
||||
- [x] 9.1 Développer les assets frontend
|
||||
- [x] Créer les fichiers CSS pour le style du planning dans le portail
|
||||
- [x] Créer les fichiers JavaScript pour les interactions dynamiques
|
||||
- **Observation**: Les assets frontend ont été développés pour améliorer l'expérience utilisateur dans le portail.
|
||||
|
||||
- [x] 9.2 Implémenter la vue calendrier
|
||||
- [x] Intégrer une bibliothèque de calendrier (FullCalendar.js)
|
||||
- [x] Développer les interactions avec le calendrier
|
||||
- **Observation**: La vue calendrier a été implémentée avec FullCalendar.js, permettant aux utilisateurs du portail de visualiser leur planning sous forme de calendrier interactif. Un endpoint API `/planning/api/slots` a été créé pour alimenter le calendrier avec les données des créneaux de planning.
|
||||
|
||||
## 10. Tests
|
||||
|
||||
- [x] 10.1 Écrire les tests unitaires
|
||||
- [x] Tests pour les modèles
|
||||
- [x] Tests pour les contrôleurs
|
||||
- **Observation**: Les tests unitaires ont été implémentés pour les principaux modèles du module (`portal.planning.request`, `portal.planning.exchange`, `planning.slot`). Ces tests vérifient le bon fonctionnement des méthodes et des contraintes de validation.
|
||||
|
||||
- [x] 10.2 Écrire les tests d'intégration
|
||||
- [x] Tests pour les workflows complets
|
||||
- [x] Tests pour les interactions entre modules
|
||||
- **Observation**: Les tests d'intégration ont été implémentés pour vérifier les workflows complets (création, soumission, approbation) et les interactions entre les différents modules (planning, timesheet, mail, portal).
|
||||
|
||||
## 11. Documentation
|
||||
|
||||
- [ ] 11.1 Écrire la documentation technique
|
||||
- [ ] Documentation des modèles et API
|
||||
- [ ] Documentation des workflows
|
||||
|
||||
- [ ] 11.2 Écrire la documentation utilisateur
|
||||
- [ ] Guide d'utilisation du portail planning
|
||||
- [ ] Guide d'administration et configuration
|
||||
|
||||
## 12. Déploiement
|
||||
|
||||
- [ ] 12.1 Préparer le module pour le déploiement
|
||||
- [ ] Vérifier les dépendances
|
||||
- [ ] Tester l'installation sur un environnement propre
|
||||
|
||||
- [ ] 12.2 Créer les scripts de migration
|
||||
- [ ] Script de migration pour les données existantes
|
||||
175
portal_planning/views/planning_slot_views.xml
Normal file
175
portal_planning/views/planning_slot_views.xml
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Extension de la vue form de planning.slot -->
|
||||
<record id="planning_slot_view_form_inherit_portal" model="ir.ui.view">
|
||||
<field name="name">planning.slot.form.inherit.portal</field>
|
||||
<field name="model">planning.slot</field>
|
||||
<field name="inherit_id" ref="planning.planning_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Ajout d'un groupe pour les champs du portail à la fin du formulaire -->
|
||||
<xpath expr="//sheet//group[last()]" position="after">
|
||||
<group string="Informations portail">
|
||||
<group string="Options du portail">
|
||||
<field name="portal_can_modify"/>
|
||||
<field name="portal_confirmed"/>
|
||||
<field name="portal_confirmation_date" readonly="1" invisible="portal_confirmed == False"/>
|
||||
</group>
|
||||
<group string="Modification via portail" invisible="portal_modified == False">
|
||||
<field name="portal_modified" invisible="1"/>
|
||||
<field name="portal_modification_date" readonly="1"/>
|
||||
<field name="portal_modification_user_id" readonly="1"/>
|
||||
<field name="portal_modification_approved"/>
|
||||
<field name="portal_original_start" readonly="1"/>
|
||||
<field name="portal_original_end" readonly="1"/>
|
||||
<field name="portal_original_role_id" readonly="1"/>
|
||||
</group>
|
||||
<group string="Notes de modification" invisible="portal_modification_notes == False">
|
||||
<field name="portal_modification_notes" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<group string="Notes de création" invisible="portal_creation_notes == False">
|
||||
<field name="portal_creation_notes" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='state']" position="after">
|
||||
<field name="portal_status" widget="badge"
|
||||
invisible="portal_status == False"
|
||||
decoration-info="portal_status == 'draft'"
|
||||
decoration-success="portal_status == 'confirmed'"
|
||||
decoration-warning="portal_status == 'modified'"
|
||||
decoration-danger="portal_status == 'pending_approval'"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Extension de la vue tree de planning.slot -->
|
||||
<record id="planning_slot_view_tree_inherit_portal" model="ir.ui.view">
|
||||
<field name="name">planning.slot.tree.inherit.portal</field>
|
||||
<field name="model">planning.slot</field>
|
||||
<field name="inherit_id" ref="planning.planning_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='state']" position="after">
|
||||
<field name="portal_status" optional="show" widget="badge"
|
||||
decoration-info="portal_status == 'draft'"
|
||||
decoration-success="portal_status == 'confirmed'"
|
||||
decoration-warning="portal_status == 'modified'"
|
||||
decoration-danger="portal_status == 'pending_approval'"/>
|
||||
<field name="portal_confirmed" optional="hide"/>
|
||||
<field name="portal_modified" optional="hide"/>
|
||||
<field name="portal_created" optional="hide"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue de recherche personnalisée pour planning.slot -->
|
||||
<record id="planning_slot_view_search_portal" model="ir.ui.view">
|
||||
<field name="name">planning.slot.search.portal</field>
|
||||
<field name="model">planning.slot</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="priority">100</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="resource_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="role_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
|
||||
<filter string="Mes créneaux" name="my_slots" domain="[('user_id', '=', uid)]"/>
|
||||
<separator/>
|
||||
<filter string="Créneaux futurs" name="future" domain="[('start_datetime', '>=', context_today().strftime('%Y-%m-%d 00:00:00'))]"/>
|
||||
<filter string="Créneaux passés" name="past" domain="[('end_datetime', '<', context_today().strftime('%Y-%m-%d 00:00:00'))]"/>
|
||||
<separator/>
|
||||
<filter string="Publiés" name="published" domain="[('state', '=', 'published')]"/>
|
||||
<filter string="Non publiés" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
|
||||
<separator/>
|
||||
<filter string="À confirmer (portail)" name="to_confirm" domain="[('portal_status', '=', 'draft')]"/>
|
||||
<filter string="Confirmés (portail)" name="confirmed" domain="[('portal_status', '=', 'confirmed')]"/>
|
||||
<filter string="Modifiés (portail)" name="modified" domain="[('portal_status', '=', 'modified')]"/>
|
||||
<filter string="En attente d'approbation" name="pending_approval" domain="[('portal_status', '=', 'pending_approval')]"/>
|
||||
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Employé" name="group_by_employee" context="{'group_by': 'employee_id'}"/>
|
||||
<filter string="Rôle" name="group_by_role" context="{'group_by': 'role_id'}"/>
|
||||
<filter string="Statut" name="group_by_state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Statut portail" name="group_by_portal_status" context="{'group_by': 'portal_status'}"/>
|
||||
<filter string="Entreprise" name="group_by_company" context="{'group_by': 'company_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour les créneaux à confirmer via le portail -->
|
||||
<record id="action_planning_slot_to_confirm_portal" model="ir.actions.act_window">
|
||||
<field name="name">Créneaux à confirmer</field>
|
||||
<field name="res_model">planning.slot</field>
|
||||
<field name="view_mode">gantt,calendar,list,form,kanban</field>
|
||||
<field name="domain">[('portal_status', '=', 'draft')]</field>
|
||||
<field name="context">{'search_default_future': True}</field>
|
||||
<field name="search_view_id" ref="planning_slot_view_search_portal"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucun créneau à confirmer
|
||||
</p>
|
||||
<p>
|
||||
Tous les créneaux ont été confirmés par les utilisateurs du portail.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour les créneaux modifiés via le portail -->
|
||||
<record id="action_planning_slot_modified_portal" model="ir.actions.act_window">
|
||||
<field name="name">Créneaux modifiés</field>
|
||||
<field name="res_model">planning.slot</field>
|
||||
<field name="view_mode">gantt,calendar,list,form,kanban</field>
|
||||
<field name="domain">[('portal_status', '=', 'modified')]</field>
|
||||
<field name="context">{'search_default_future': True}</field>
|
||||
<field name="search_view_id" ref="planning_slot_view_search_portal"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucun créneau modifié via le portail
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour les créneaux en attente d'approbation -->
|
||||
<record id="action_planning_slot_pending_approval" model="ir.actions.act_window">
|
||||
<field name="name">Créneaux en attente d'approbation</field>
|
||||
<field name="res_model">planning.slot</field>
|
||||
<field name="view_mode">gantt,calendar,list,form,kanban</field>
|
||||
<field name="domain">[('portal_status', '=', 'pending_approval')]</field>
|
||||
<field name="context">{'search_default_future': True}</field>
|
||||
<field name="search_view_id" ref="planning_slot_view_search_portal"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucun créneau en attente d'approbation
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu pour les actions liées au portail -->
|
||||
<menuitem id="menu_planning_portal"
|
||||
name="Portail Planning"
|
||||
parent="planning.planning_menu_root"
|
||||
sequence="15"/>
|
||||
|
||||
<menuitem id="menu_planning_slot_to_confirm_portal"
|
||||
name="Créneaux à confirmer"
|
||||
parent="menu_planning_portal"
|
||||
action="action_planning_slot_to_confirm_portal"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_planning_slot_modified_portal"
|
||||
name="Créneaux modifiés"
|
||||
parent="menu_planning_portal"
|
||||
action="action_planning_slot_modified_portal"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_planning_slot_pending_approval"
|
||||
name="En attente d'approbation"
|
||||
parent="menu_planning_portal"
|
||||
action="action_planning_slot_pending_approval"
|
||||
sequence="30"/>
|
||||
|
||||
</odoo>
|
||||
256
portal_planning/views/portal_planning_exchange_templates.xml
Normal file
256
portal_planning/views/portal_planning_exchange_templates.xml
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Liste des demandes d'échange -->
|
||||
<template id="portal_my_planning_exchanges" name="My Planning Exchanges">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Mes demandes d'échange</t>
|
||||
</t>
|
||||
|
||||
<div class="o_portal_planning">
|
||||
<!-- Statistiques -->
|
||||
<div class="planning-stats mb-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-info">
|
||||
<t t-esc="len([e for e in exchanges if e.state == 'draft'])"/>
|
||||
</div>
|
||||
<div class="stat-label">Brouillons</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-warning">
|
||||
<t t-esc="len([e for e in exchanges if e.state == 'pending'])"/>
|
||||
</div>
|
||||
<div class="stat-label">En attente</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-success">
|
||||
<t t-esc="len([e for e in exchanges if e.state == 'approved'])"/>
|
||||
</div>
|
||||
<div class="stat-label">Approuvés</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-danger">
|
||||
<t t-esc="len([e for e in exchanges if e.state == 'rejected'])"/>
|
||||
</div>
|
||||
<div class="stat-label">Refusés</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres par état -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<ul class="nav nav-tabs">
|
||||
<t t-foreach="searchbar_filters" t-as="filter_key">
|
||||
<li class="nav-item">
|
||||
<a t-att-href="'/my/planning/exchanges?filterby=%s' % filter_key" t-attf-class="nav-link #{'active' if filterby == filter_key else ''}">
|
||||
<t t-esc="searchbar_filters[filter_key]['label']"/>
|
||||
</a>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="not exchanges">
|
||||
<div class="alert alert-info">
|
||||
<p>Aucune demande d'échange n'est disponible pour le moment.</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="exchanges">
|
||||
<div class="planning-exchanges">
|
||||
<t t-foreach="exchanges" t-as="exchange">
|
||||
<div t-attf-class="planning-exchange mb-3 #{exchange.state == 'draft' and 'exchange-draft' or exchange.state == 'pending' and 'exchange-pending' or exchange.state == 'approved' and 'exchange-approved' or 'exchange-rejected'}">
|
||||
<div class="exchange-header">
|
||||
<div class="exchange-title">
|
||||
<span t-field="exchange.name"/>
|
||||
</div>
|
||||
<div class="exchange-status">
|
||||
<span t-if="exchange.state == 'draft'" class="badge badge-info">Brouillon</span>
|
||||
<span t-if="exchange.state == 'pending'" class="badge badge-warning">En attente</span>
|
||||
<span t-if="exchange.state == 'approved'" class="badge badge-success">Approuvé</span>
|
||||
<span t-if="exchange.state == 'rejected'" class="badge badge-danger">Refusé</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exchange-date">
|
||||
<i class="fa fa-calendar"></i> Créé le <span t-field="exchange.create_date" t-options="{'widget': 'date'}"/>
|
||||
</div>
|
||||
|
||||
<!-- Bouton pour afficher/masquer les détails -->
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-link o_exchange_show_details">
|
||||
<i class="fa fa-chevron-down"></i> <span>Afficher les détails</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Détails de l'échange (masqués par défaut) -->
|
||||
<div class="exchange-details d-none mt-3">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Créneau à échanger:</h6>
|
||||
<p><strong>Date:</strong> <span t-field="exchange.source_slot_id.start_datetime" t-options="{'widget': 'date'}"/></p>
|
||||
<p><strong>Horaire:</strong> <span t-field="exchange.source_slot_id.start_datetime" t-options="{'widget': 'time', 'time_only': 'true'}"/> - <span t-field="exchange.source_slot_id.end_datetime" t-options="{'widget': 'time', 'time_only': 'true'}"/></p>
|
||||
<p t-if="exchange.source_slot_id.role_id"><strong>Rôle:</strong> <span t-field="exchange.source_slot_id.role_id.name"/></p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<t t-if="exchange.target_slot_id">
|
||||
<h6>Créneau souhaité:</h6>
|
||||
<p><strong>Date:</strong> <span t-field="exchange.target_slot_id.start_datetime" t-options="{'widget': 'date'}"/></p>
|
||||
<p><strong>Horaire:</strong> <span t-field="exchange.target_slot_id.start_datetime" t-options="{'widget': 'time', 'time_only': 'true'}"/> - <span t-field="exchange.target_slot_id.end_datetime" t-options="{'widget': 'time', 'time_only': 'true'}"/></p>
|
||||
<p t-if="exchange.target_slot_id.role_id"><strong>Rôle:</strong> <span t-field="exchange.target_slot_id.role_id.name"/></p>
|
||||
<p><strong>Employé:</strong> <span t-field="exchange.target_slot_id.employee_id.name"/></p>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<h6>Dates préférées:</h6>
|
||||
<t t-if="exchange.preferred_start">
|
||||
<p><strong>Date de début:</strong> <span t-field="exchange.preferred_start" t-options="{'widget': 'datetime'}"/></p>
|
||||
<p><strong>Date de fin:</strong> <span t-field="exchange.preferred_end" t-options="{'widget': 'datetime'}"/></p>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p>Aucune date préférée spécifiée (demande ouverte)</p>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div t-if="exchange.notes" class="mt-3">
|
||||
<h6>Notes:</h6>
|
||||
<p t-field="exchange.notes"/>
|
||||
</div>
|
||||
|
||||
<!-- Réponse -->
|
||||
<div t-if="exchange.response" class="mt-3">
|
||||
<h6>Réponse:</h6>
|
||||
<p t-field="exchange.response"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exchange-actions mt-3">
|
||||
<a t-att-href="'/my/planning/exchange/%s' % exchange.id" class="btn btn-sm btn-primary">
|
||||
<i class="fa fa-eye"></i> Voir
|
||||
</a>
|
||||
<t t-if="exchange.state == 'draft'">
|
||||
<a t-att-href="'/my/planning/exchange/%s/submit' % exchange.id" class="btn btn-sm btn-success o_exchange_submit" t-att-data-exchange-id="exchange.id">
|
||||
<i class="fa fa-paper-plane"></i> Soumettre
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="exchange.state in ['draft', 'pending']">
|
||||
<a t-att-href="'/my/planning/exchange/%s/cancel' % exchange.id" class="btn btn-sm btn-danger o_exchange_cancel" t-att-data-exchange-id="exchange.id">
|
||||
<i class="fa fa-times"></i> Annuler
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div t-if="pager" class="o_portal_pager text-center">
|
||||
<t t-call="portal.pager"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Détail d'une demande d'échange -->
|
||||
<template id="portal_planning_exchange_detail" name="Planning Exchange Detail">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container o_portal_planning">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<h4 t-field="exchange.name"/>
|
||||
<span t-if="exchange.state == 'draft'" class="badge badge-info">Brouillon</span>
|
||||
<span t-if="exchange.state == 'pending'" class="badge badge-warning">En attente</span>
|
||||
<span t-if="exchange.state == 'approved'" class="badge badge-success">Approuvé</span>
|
||||
<span t-if="exchange.state == 'rejected'" class="badge badge-danger">Refusé</span>
|
||||
</div>
|
||||
<div class="col-lg-6 text-right">
|
||||
<t t-if="exchange.state == 'draft'">
|
||||
<a t-att-href="'/my/planning/exchange/%s/submit' % exchange.id" class="btn btn-success o_exchange_submit" t-att-data-exchange-id="exchange.id">
|
||||
<i class="fa fa-paper-plane"></i> Soumettre
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="exchange.state in ['draft', 'pending']">
|
||||
<a t-att-href="'/my/planning/exchange/%s/cancel' % exchange.id" class="btn btn-danger o_exchange_cancel" t-att-data-exchange-id="exchange.id">
|
||||
<i class="fa fa-times"></i> Annuler
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<h5>Créneau à échanger</h5>
|
||||
<p><strong>Date:</strong> <span t-field="exchange.source_slot_id.start_datetime" t-options="{'widget': 'date'}"/></p>
|
||||
<p><strong>Horaire:</strong> <span t-field="exchange.source_slot_id.start_datetime" t-options="{'widget': 'time', 'time_only': 'true'}"/> - <span t-field="exchange.source_slot_id.end_datetime" t-options="{'widget': 'time', 'time_only': 'true'}"/></p>
|
||||
<p><strong>Durée:</strong> <span t-esc="format_duration(exchange.source_slot_id.allocated_hours)"/></p>
|
||||
<p t-if="exchange.source_slot_id.role_id"><strong>Rôle:</strong> <span t-field="exchange.source_slot_id.role_id.name"/></p>
|
||||
<p><strong>Employé:</strong> <span t-field="exchange.source_slot_id.employee_id.name"/></p>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<t t-if="exchange.target_slot_id">
|
||||
<h5>Créneau souhaité</h5>
|
||||
<p><strong>Date:</strong> <span t-field="exchange.target_slot_id.start_datetime" t-options="{'widget': 'date'}"/></p>
|
||||
<p><strong>Horaire:</strong> <span t-field="exchange.target_slot_id.start_datetime" t-options="{'widget': 'time', 'time_only': 'true'}"/> - <span t-field="exchange.target_slot_id.end_datetime" t-options="{'widget': 'time', 'time_only': 'true'}"/></p>
|
||||
<p><strong>Durée:</strong> <span t-esc="format_duration(exchange.target_slot_id.allocated_hours)"/></p>
|
||||
<p t-if="exchange.target_slot_id.role_id"><strong>Rôle:</strong> <span t-field="exchange.target_slot_id.role_id.name"/></p>
|
||||
<p><strong>Employé:</strong> <span t-field="exchange.target_slot_id.employee_id.name"/></p>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<h5>Dates préférées</h5>
|
||||
<t t-if="exchange.preferred_start">
|
||||
<p><strong>Date de début:</strong> <span t-field="exchange.preferred_start" t-options="{'widget': 'datetime'}"/></p>
|
||||
<p><strong>Date de fin:</strong> <span t-field="exchange.preferred_end" t-options="{'widget': 'datetime'}"/></p>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p>Aucune date préférée spécifiée (demande ouverte)</p>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-12">
|
||||
<h5>Informations complémentaires</h5>
|
||||
<p><strong>Date de création:</strong> <span t-field="exchange.create_date" t-options="{'widget': 'datetime'}"/></p>
|
||||
<p><strong>Dernière mise à jour:</strong> <span t-field="exchange.write_date" t-options="{'widget': 'datetime'}"/></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4" t-if="exchange.notes">
|
||||
<div class="col-lg-12">
|
||||
<h5>Notes</h5>
|
||||
<p t-field="exchange.notes"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4" t-if="exchange.response">
|
||||
<div class="col-lg-12">
|
||||
<h5>Réponse</h5>
|
||||
<div t-attf-class="alert #{exchange.state == 'approved' and 'alert-success' or exchange.state == 'rejected' and 'alert-danger' or 'alert-info'}">
|
||||
<p t-field="exchange.response"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
153
portal_planning/views/portal_planning_exchange_views.xml
Normal file
153
portal_planning/views/portal_planning_exchange_views.xml
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vue form pour portal.planning.exchange -->
|
||||
<record id="view_portal_planning_exchange_form" model="ir.ui.view">
|
||||
<field name="name">portal.planning.exchange.form</field>
|
||||
<field name="model">portal.planning.exchange</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Échange de planning">
|
||||
<header>
|
||||
<button name="action_submit" string="Soumettre" type="object" class="oe_highlight" invisible="not can_submit"/>
|
||||
<button name="action_accept" string="Accepter" type="object" class="oe_highlight" invisible="not can_accept"/>
|
||||
<button name="action_approve" string="Approuver" type="object" class="oe_highlight" invisible="not can_approve"/>
|
||||
<button name="action_reject" string="Refuser" type="object" invisible="not can_reject"/>
|
||||
<button name="action_cancel" string="Annuler" type="object" invisible="not can_cancel"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,pending,accepted,approved,rejected"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="source_user_id" readonly="1"/>
|
||||
<field name="source_employee_id" readonly="1"/>
|
||||
<field name="date" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="state" invisible="1"/>
|
||||
<field name="can_submit" invisible="1"/>
|
||||
<field name="can_accept" invisible="1"/>
|
||||
<field name="can_approve" invisible="1"/>
|
||||
<field name="can_reject" invisible="1"/>
|
||||
<field name="can_cancel" invisible="1"/>
|
||||
<field name="approver_id" readonly="1" invisible="approver_id == False"/>
|
||||
<field name="approval_date" readonly="1" invisible="approval_date == False"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Créneau à échanger">
|
||||
<group>
|
||||
<field name="source_slot_id" domain="[('employee_id.user_id', '=', source_user_id)]" readonly="state != 'draft'"/>
|
||||
<field name="source_start" readonly="1"/>
|
||||
<field name="source_end" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="source_role_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Demande ouverte" invisible="state != 'draft'">
|
||||
<field name="is_open_request" readonly="state != 'draft'"/>
|
||||
</group>
|
||||
<group string="Dates préférées" invisible="is_open_request == False or state != 'draft'">
|
||||
<group>
|
||||
<field name="preferred_start" required="is_open_request == True" readonly="state != 'draft'"/>
|
||||
<field name="preferred_end" required="is_open_request == True" readonly="state != 'draft'"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Échange avec" invisible="is_open_request == True">
|
||||
<group>
|
||||
<field name="target_employee_id" required="is_open_request == False" readonly="state != 'draft'"/>
|
||||
<field name="target_slot_id" domain="[('employee_id', '=', target_employee_id)]" required="is_open_request == False" readonly="state != 'draft'"/>
|
||||
<field name="target_start" readonly="1"/>
|
||||
<field name="target_end" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="target_user_id" readonly="1"/>
|
||||
<field name="target_role_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" placeholder="Notes concernant l'échange..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue list pour portal.planning.exchange -->
|
||||
<record id="view_portal_planning_exchange_tree" model="ir.ui.view">
|
||||
<field name="name">portal.planning.exchange.list</field>
|
||||
<field name="model">portal.planning.exchange</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Échanges de planning" decoration-info="state == 'pending'" decoration-warning="state == 'accepted'" decoration-success="state == 'approved'" decoration-danger="state == 'rejected'" decoration-muted="state == 'cancelled'">
|
||||
<field name="name"/>
|
||||
<field name="date"/>
|
||||
<field name="source_employee_id"/>
|
||||
<field name="source_slot_id"/>
|
||||
<field name="target_employee_id"/>
|
||||
<field name="is_open_request"/>
|
||||
<field name="state"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue search pour portal.planning.exchange -->
|
||||
<record id="view_portal_planning_exchange_search" model="ir.ui.view">
|
||||
<field name="name">portal.planning.exchange.search</field>
|
||||
<field name="model">portal.planning.exchange</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Rechercher Échanges de Planning">
|
||||
<field name="name"/>
|
||||
<field name="source_employee_id"/>
|
||||
<field name="target_employee_id"/>
|
||||
<field name="source_slot_id"/>
|
||||
<filter string="Brouillons" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="En attente" name="pending" domain="[('state', '=', 'pending')]"/>
|
||||
<filter string="Acceptés" name="accepted" domain="[('state', '=', 'accepted')]"/>
|
||||
<filter string="Approuvés" name="approved" domain="[('state', '=', 'approved')]"/>
|
||||
<filter string="Refusés" name="rejected" domain="[('state', '=', 'rejected')]"/>
|
||||
<filter string="Annulés" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
|
||||
<filter string="Demandes ouvertes" name="open_requests" domain="[('is_open_request', '=', True)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Employé source" name="source_employee" context="{'group_by': 'source_employee_id'}"/>
|
||||
<filter string="Employé cible" name="target_employee" context="{'group_by': 'target_employee_id'}"/>
|
||||
<filter string="État" name="state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Date de demande" name="date" context="{'group_by': 'date:day'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour portal.planning.exchange -->
|
||||
<record id="action_portal_planning_exchange" model="ir.actions.act_window">
|
||||
<field name="name">Échanges de Planning</field>
|
||||
<field name="res_model">portal.planning.exchange</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="view_portal_planning_exchange_search"/>
|
||||
<field name="context">{'search_default_pending': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucun échange de planning disponible
|
||||
</p>
|
||||
<p>
|
||||
Les demandes d'échange de planning des utilisateurs du portail seront affichées ici.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu pour portal.planning.exchange -->
|
||||
<menuitem id="menu_portal_planning_exchange"
|
||||
name="Échanges de Planning"
|
||||
parent="planning.planning_menu_root"
|
||||
action="action_portal_planning_exchange"
|
||||
sequence="30"/>
|
||||
|
||||
</odoo>
|
||||
133
portal_planning/views/portal_planning_modification_views.xml
Normal file
133
portal_planning/views/portal_planning_modification_views.xml
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vue form pour portal.planning.modification -->
|
||||
<record id="view_portal_planning_modification_form" model="ir.ui.view">
|
||||
<field name="name">portal.planning.modification.form</field>
|
||||
<field name="model">portal.planning.modification</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Modification de planning">
|
||||
<header>
|
||||
<button name="action_approve" string="Approuver" type="object" class="oe_highlight" invisible="not can_approve"/>
|
||||
<button name="action_reject" string="Refuser" type="object" invisible="not can_reject"/>
|
||||
<button name="action_cancel" string="Annuler" type="object" invisible="not can_cancel"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="pending,approved,rejected"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="slot_id"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="user_id" readonly="1"/>
|
||||
<field name="employee_id" readonly="1"/>
|
||||
<field name="date" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="state" invisible="1"/>
|
||||
<field name="can_approve" invisible="1"/>
|
||||
<field name="can_reject" invisible="1"/>
|
||||
<field name="can_cancel" invisible="1"/>
|
||||
<field name="approver_id" readonly="1" invisible="approver_id == False"/>
|
||||
<field name="approval_date" readonly="1" invisible="approval_date == False"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Valeurs originales">
|
||||
<group>
|
||||
<field name="old_start" readonly="1"/>
|
||||
<field name="old_end" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="old_role_id" readonly="1"/>
|
||||
<field name="allocated_hours" widget="float_time" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Nouvelles valeurs">
|
||||
<group>
|
||||
<field name="new_start" readonly="1"/>
|
||||
<field name="new_end" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="new_role_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" placeholder="Notes concernant la modification..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue list pour portal.planning.modification -->
|
||||
<record id="view_portal_planning_modification_tree" model="ir.ui.view">
|
||||
<field name="name">portal.planning.modification.list</field>
|
||||
<field name="model">portal.planning.modification</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Modifications de planning" decoration-info="state == 'pending'" decoration-success="state == 'approved'" decoration-danger="state == 'rejected'" decoration-muted="state == 'cancelled'">
|
||||
<field name="date"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="slot_id"/>
|
||||
<field name="new_start"/>
|
||||
<field name="new_end"/>
|
||||
<field name="new_role_id"/>
|
||||
<field name="state"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue search pour portal.planning.modification -->
|
||||
<record id="view_portal_planning_modification_search" model="ir.ui.view">
|
||||
<field name="name">portal.planning.modification.search</field>
|
||||
<field name="model">portal.planning.modification</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Rechercher Modifications de Planning">
|
||||
<field name="employee_id"/>
|
||||
<field name="slot_id"/>
|
||||
<field name="user_id"/>
|
||||
<filter string="En attente" name="pending" domain="[('state', '=', 'pending')]"/>
|
||||
<filter string="Approuvées" name="approved" domain="[('state', '=', 'approved')]"/>
|
||||
<filter string="Refusées" name="rejected" domain="[('state', '=', 'rejected')]"/>
|
||||
<filter string="Annulées" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Employé" name="employee" context="{'group_by': 'employee_id'}"/>
|
||||
<filter string="Créneau" name="slot" context="{'group_by': 'slot_id'}"/>
|
||||
<filter string="État" name="state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Date de demande" name="date" context="{'group_by': 'date:day'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour portal.planning.modification -->
|
||||
<record id="action_portal_planning_modification" model="ir.actions.act_window">
|
||||
<field name="name">Modifications de Planning</field>
|
||||
<field name="res_model">portal.planning.modification</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="view_portal_planning_modification_search"/>
|
||||
<field name="context">{'search_default_pending': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucune modification de planning disponible
|
||||
</p>
|
||||
<p>
|
||||
Les demandes de modification de planning des utilisateurs du portail seront affichées ici.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu pour portal.planning.modification -->
|
||||
<menuitem id="menu_portal_planning_modification"
|
||||
name="Modifications de Planning"
|
||||
parent="planning.planning_menu_root"
|
||||
action="action_portal_planning_modification"
|
||||
sequence="25"/>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="portal_planning_slot_create" name="Create Planning Slot">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h2>Créer un créneau de planning</h2>
|
||||
<form action="/my/planning/slot/submit" method="post" class="mt-4">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="employee_id" t-att-value="employee.id"/>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="start_datetime" class="col-sm-3 col-form-label">Date et heure de début</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="datetime-local" class="form-control" id="start_datetime" name="start_datetime"
|
||||
t-att-value="default_values.get('start_datetime', '').strftime('%Y-%m-%dT%H:%M') if default_values.get('start_datetime') else ''" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="end_datetime" class="col-sm-3 col-form-label">Date et heure de fin</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="datetime-local" class="form-control" id="end_datetime" name="end_datetime"
|
||||
t-att-value="default_values.get('end_datetime', '').strftime('%Y-%m-%dT%H:%M') if default_values.get('end_datetime') else ''" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="role_id" class="col-sm-3 col-form-label">Rôle</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control" id="role_id" name="role_id">
|
||||
<option value="">-- Sélectionner un rôle --</option>
|
||||
<t t-foreach="roles" t-as="role">
|
||||
<option t-att-value="role.id" t-esc="role.name"/>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-sm-3 col-form-label">Description</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="Description du créneau"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-sm-9 offset-sm-3">
|
||||
<button type="submit" class="btn btn-primary">Créer</button>
|
||||
<a href="/my/planning/week" class="btn btn-secondary ml-2">Annuler</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
943
portal_planning/views/portal_planning_slot_templates.xml
Normal file
943
portal_planning/views/portal_planning_slot_templates.xml
Normal file
|
|
@ -0,0 +1,943 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Liste des créneaux de planning -->
|
||||
<template id="portal_my_planning_slots" name="My Planning Slots">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Mes créneaux de planning</t>
|
||||
</t>
|
||||
|
||||
<div class="o_portal_planning">
|
||||
<!-- Message si l'utilisateur n'est pas associé à un employé -->
|
||||
<t t-if="no_employee">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h4 class="alert-heading">Votre compte n'est pas associé à un employé</h4>
|
||||
<p>Pour accéder à votre planning, votre compte utilisateur doit être associé à un employé dans le système.</p>
|
||||
<p>Veuillez contacter votre administrateur pour associer votre compte à un employé.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<!-- Sélecteur de vue -->
|
||||
<div class="mb-3 d-flex justify-content-between align-items-center">
|
||||
<div class="btn-group" role="group" aria-label="Sélecteur de vue">
|
||||
<a t-att-href="'/my/planning/slots'" t-attf-class="btn btn-sm {{ 'btn-primary' if page_name == 'planning_slots' else 'btn-secondary' }}">
|
||||
<i class="fa fa-list"></i> Liste
|
||||
</a>
|
||||
<a t-att-href="'/my/planning/day?date=%s' % (date or '')" t-attf-class="btn btn-sm {{ 'btn-primary' if page_name == 'planning_day' else 'btn-secondary' }}">
|
||||
<i class="fa fa-calendar-day"></i> Jour
|
||||
</a>
|
||||
<a t-att-href="'/my/planning/week?date_start=%s' % (date_start or '')" t-attf-class="btn btn-sm {{ 'btn-primary' if page_name == 'planning_week' else 'btn-secondary' }}">
|
||||
<i class="fa fa-calendar-week"></i> Semaine
|
||||
</a>
|
||||
<a t-att-href="'/my/planning/month?date=%s' % (date or '')" t-attf-class="btn btn-sm {{ 'btn-primary' if page_name == 'planning_month' else 'btn-secondary' }}">
|
||||
<i class="fa fa-calendar-alt"></i> Mois
|
||||
</a>
|
||||
</div>
|
||||
<a t-att-href="'/my/planning/slot/create'" class="btn btn-sm btn-success">
|
||||
<i class="fa fa-plus"></i> Nouveau créneau
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Onglets pour les différentes vues -->
|
||||
<ul class="nav nav-tabs mb-4">
|
||||
<li class="nav-item">
|
||||
<a t-att-class="'nav-link %s' % ('' if page_name != 'planning_week' and page_name != 'planning_day' and page_name != 'planning_month' else 'active')" href="/my/planning/slots">
|
||||
<i class="fa fa-list"></i> Liste
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a t-att-class="'nav-link %s' % ('active' if page_name == 'planning_week' else '')" href="/my/planning/week">
|
||||
<i class="fa fa-calendar-week"></i> Semaine
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a t-att-class="'nav-link %s' % ('active' if page_name == 'planning_day' else '')" href="/my/planning/day">
|
||||
<i class="fa fa-calendar-day"></i> Jour
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a t-att-class="'nav-link %s' % ('active' if page_name == 'planning_month' else '')" href="/my/planning/month">
|
||||
<i class="fa fa-calendar-alt"></i> Mois
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Statistiques -->
|
||||
<div class="planning-stats mb-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-info">
|
||||
<t t-esc="len([s for s in planning_slots or [] if s.portal_status == 'draft'])"/>
|
||||
</div>
|
||||
<div class="stat-label">À confirmer</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-success">
|
||||
<t t-esc="len([s for s in planning_slots or [] if s.portal_status == 'confirmed'])"/>
|
||||
</div>
|
||||
<div class="stat-label">Confirmés</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-warning">
|
||||
<t t-esc="len([s for s in planning_slots or [] if s.portal_status == 'modified'])"/>
|
||||
</div>
|
||||
<div class="stat-label">Modifiés</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-danger">
|
||||
<t t-esc="len([s for s in planning_slots or [] if s.portal_status == 'pending_approval'])"/>
|
||||
</div>
|
||||
<div class="stat-label">En attente</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Filtres par état -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<ul class="nav nav-tabs">
|
||||
<t t-foreach="searchbar_filters" t-as="filter_key">
|
||||
<li class="nav-item">
|
||||
<a t-att-href="'/my/planning/slots?filterby=%s' % filter_key" t-attf-class="nav-link #{'active' if filterby == filter_key else ''}">
|
||||
<t t-esc="searchbar_filters[filter_key]['label']"/>
|
||||
</a>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue selon le mode sélectionné -->
|
||||
<t t-if="page_name == 'planning_week'">
|
||||
<!-- Vue hebdomadaire -->
|
||||
<div class="planning-week">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a t-att-href="'/my/planning/week?date_start=%s' % prev_week" class="btn btn-sm btn-secondary">
|
||||
<i class="fa fa-chevron-left"></i> Semaine précédente
|
||||
</a>
|
||||
<h4 class="m-0">
|
||||
Semaine du <span t-esc="start_date.strftime('%d %b')"/> au <span t-esc="end_date.strftime('%d %b %Y')"/>
|
||||
</h4>
|
||||
<a t-att-href="'/my/planning/week?date_start=%s' % next_week" class="btn btn-sm btn-secondary">
|
||||
Semaine suivante <i class="fa fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Jour</th>
|
||||
<th>Créneaux</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="days" t-as="day">
|
||||
<tr t-att-class="'table-active' if day['is_today'] else ''">
|
||||
<td class="text-nowrap">
|
||||
<strong t-esc="day['name']"/><br/>
|
||||
<span t-esc="day['date'].strftime('%d/%m/%Y')"/>
|
||||
<div class="d-flex">
|
||||
<a t-att-href="'/my/planning/day?date=%s' % day['date'].strftime('%Y-%m-%d')" class="btn btn-sm btn-link mr-2">
|
||||
<i class="fa fa-search"></i> Détails
|
||||
</a>
|
||||
<a t-att-href="'/my/planning/slot/create?date=%s' % day['date'].strftime('%Y-%m-%d')" class="btn btn-sm btn-success">
|
||||
<i class="fa fa-plus"></i> Ajouter
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<t t-if="day['day_slots']">
|
||||
<t t-foreach="day['day_slots']" t-as="slot">
|
||||
<div class="mb-2 p-2 border rounded" t-att-class="'bg-light' if slot.portal_confirmed else ''">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<strong t-if="slot.role_id" t-field="slot.role_id.name"/>
|
||||
<strong t-else="">Créneau</strong>
|
||||
<div>
|
||||
<i class="fa fa-clock"></i>
|
||||
<span t-esc="slot.start_datetime.strftime('%H:%M')"/> - <span t-esc="slot.end_datetime.strftime('%H:%M')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span t-if="slot.portal_status == 'draft'" class="badge badge-info">À confirmer</span>
|
||||
<span t-if="slot.portal_status == 'confirmed'" class="badge badge-success">Confirmé</span>
|
||||
<span t-if="slot.portal_status == 'modified'" class="badge badge-warning">Modifié</span>
|
||||
<span t-if="slot.portal_status == 'pending_approval'" class="badge badge-danger">En attente</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a t-att-href="'/my/planning/slot/%s' % slot.id" class="btn btn-sm btn-primary">
|
||||
<i class="fa fa-eye"></i> Voir
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="text-muted">Aucun créneau</div>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="page_name == 'planning_day'">
|
||||
<!-- Vue quotidienne -->
|
||||
<div class="planning-day">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a t-att-href="'/my/planning/day?date=%s' % prev_day" class="btn btn-sm btn-secondary">
|
||||
<i class="fa fa-chevron-left"></i> Jour précédent
|
||||
</a>
|
||||
<h4 class="m-0" t-att-class="'text-primary' if is_today else ''">
|
||||
<t t-esc="selected_date.strftime('%A %d %B %Y')"/>
|
||||
<span t-if="is_today" class="badge badge-primary ml-2">Aujourd'hui</span>
|
||||
</h4>
|
||||
<a t-att-href="'/my/planning/day?date=%s' % next_day" class="btn btn-sm btn-secondary">
|
||||
Jour suivant <i class="fa fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Créneaux du jour</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-if="planning_slots">
|
||||
<div class="timeline">
|
||||
<t t-foreach="planning_slots" t-as="slot">
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-badge" t-att-class="'bg-success' if slot.portal_confirmed else 'bg-info'">
|
||||
<i t-if="slot.portal_confirmed" class="fa fa-check"></i>
|
||||
<i t-else="" class="fa fa-clock"></i>
|
||||
</div>
|
||||
<div class="timeline-panel card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<h5 class="mb-0">
|
||||
<t t-if="slot.role_id">
|
||||
<span t-field="slot.role_id.name"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
Créneau de planning
|
||||
</t>
|
||||
</h5>
|
||||
<div>
|
||||
<span t-if="slot.portal_status == 'draft'" class="badge badge-info">À confirmer</span>
|
||||
<span t-if="slot.portal_status == 'confirmed'" class="badge badge-success">Confirmé</span>
|
||||
<span t-if="slot.portal_status == 'modified'" class="badge badge-warning">Modifié</span>
|
||||
<span t-if="slot.portal_status == 'pending_approval'" class="badge badge-danger">En attente</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<i class="fa fa-clock"></i>
|
||||
<strong t-esc="slot.start_datetime.strftime('%H:%M')"/> - <strong t-esc="slot.end_datetime.strftime('%H:%M')"/>
|
||||
<span class="text-muted ml-2">(<t t-esc="format_duration(slot.allocated_hours)"/>)</span>
|
||||
</div>
|
||||
<div t-if="slot.name" class="mb-2">
|
||||
<i class="fa fa-info-circle"></i> <span t-field="slot.name"/>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a t-att-href="'/my/planning/slot/%s' % slot.id" class="btn btn-sm btn-primary">
|
||||
<i class="fa fa-eye"></i> Voir
|
||||
</a>
|
||||
<t t-if="not slot.portal_confirmed">
|
||||
<a t-att-href="'/my/planning/slot/%s/confirm' % slot.id" class="btn btn-sm btn-success ml-1">
|
||||
<i class="fa fa-check"></i> Confirmer
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="slot.portal_can_modify">
|
||||
<a t-att-href="'/my/planning/slot/%s/modify' % slot.id" class="btn btn-sm btn-warning ml-1">
|
||||
<i class="fa fa-pencil"></i> Modifier
|
||||
</a>
|
||||
</t>
|
||||
<a t-att-href="'/my/planning/slot/%s/exchange' % slot.id" class="btn btn-sm btn-info ml-1">
|
||||
<i class="fa fa-exchange"></i> Échanger
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-info">
|
||||
<p class="mb-0">Aucun créneau prévu pour cette journée.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="page_name == 'planning_month'">
|
||||
<!-- Vue mensuelle -->
|
||||
<div class="planning-month">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a t-att-href="'/my/planning/month?date=%s' % prev_month" class="btn btn-sm btn-secondary">
|
||||
<i class="fa fa-chevron-left"></i> Mois précédent
|
||||
</a>
|
||||
<h4 class="m-0" t-esc="month_name"/>
|
||||
<a t-att-href="'/my/planning/month?date=%s' % next_month" class="btn btn-sm btn-secondary">
|
||||
Mois suivant <i class="fa fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered calendar-month">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lundi</th>
|
||||
<th>Mardi</th>
|
||||
<th>Mercredi</th>
|
||||
<th>Jeudi</th>
|
||||
<th>Vendredi</th>
|
||||
<th>Samedi</th>
|
||||
<th>Dimanche</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="weeks" t-as="week">
|
||||
<tr>
|
||||
<t t-foreach="week" t-as="day">
|
||||
<td t-attf-class="calendar-day {{ 'calendar-day-today' if day['is_today'] else '' }} {{ 'calendar-day-other-month' if day['day'] == 0 else '' }} {{ 'calendar-day-has-slots' if day['has_slots'] else '' }}" t-att-data-date="day['date'] and day['date'].strftime('%Y-%m-%d')">
|
||||
<t t-if="day['day'] != 0">
|
||||
<div class="calendar-day-header d-flex justify-content-between">
|
||||
<span class="calendar-day-number" t-esc="day['day']"/>
|
||||
<t t-if="day['date']">
|
||||
<a t-att-href="'/my/planning/day?date=%s' % day['date'].strftime('%Y-%m-%d')" class="btn btn-sm btn-link p-0" title="Voir les détails">
|
||||
<i class="fa fa-search"></i>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
<div class="calendar-day-slots">
|
||||
<t t-foreach="day['slots']" t-as="slot">
|
||||
<a t-att-href="'/my/planning/slot/%s' % slot.id" class="calendar-slot d-block mb-1 p-1 rounded" t-attf-style="background-color: {{ slot.role_id.color and '#%s' % slot.role_id.color or '#6c757d' }}20; border-left: 3px solid {{ slot.role_id.color and '#%s' % slot.role_id.color or '#6c757d' }};">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small t-esc="slot.start_datetime.strftime('%H:%M')"/>
|
||||
<small t-esc="slot.end_datetime.strftime('%H:%M')"/>
|
||||
</div>
|
||||
<small t-if="slot.role_id" t-esc="slot.role_id.name"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="day['date']">
|
||||
<a t-att-href="'/my/planning/slot/create?date=%s' % day['date'].strftime('%Y-%m-%d')" class="btn btn-sm btn-light btn-block mt-1">
|
||||
<i class="fa fa-plus"></i>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<!-- Vue liste (par défaut) -->
|
||||
<!-- Liste des créneaux -->
|
||||
<div class="planning-list">
|
||||
<t t-foreach="planning_slots" t-as="slot">
|
||||
<div class="card mb-3 planning-slot-card" t-att-data-slot-id="slot.id">
|
||||
<div class="card-body">
|
||||
<i class="fa fa-clock-o"></i> <span t-esc="slot.start_datetime.strftime('%H:%M')"/> - <span t-esc="slot.end_datetime.strftime('%H:%M')"/>
|
||||
</div>
|
||||
<div class="slot-duration">
|
||||
<i class="fa fa-hourglass-half"></i> <span t-esc="format_duration(slot.allocated_hours)"/>
|
||||
</div>
|
||||
|
||||
<!-- Bouton pour afficher/masquer les détails -->
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-link o_planning_show_details">
|
||||
<i class="fa fa-chevron-down"></i> <span>Afficher les détails</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Détails du créneau (masqués par défaut) -->
|
||||
<div class="slot-details d-none mt-3">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Employé:</strong> <span t-field="slot.employee_id.name"/></p>
|
||||
<p t-if="slot.role_id"><strong>Rôle:</strong> <span t-field="slot.role_id.name"/></p>
|
||||
<p t-if="slot.name"><strong>Description:</strong> <span t-field="slot.name"/></p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p t-if="slot.portal_confirmed"><strong>Date de confirmation:</strong> <span t-field="slot.portal_confirmation_date"/></p>
|
||||
<p t-if="slot.portal_modified"><strong>Date de modification:</strong> <span t-field="slot.portal_modification_date"/></p>
|
||||
<p t-if="slot.portal_created"><strong>Date de création:</strong> <span t-field="slot.portal_creation_date"/></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes de modification -->
|
||||
<div t-if="slot.portal_modification_notes" class="mt-3">
|
||||
<h6>Notes de modification:</h6>
|
||||
<p t-field="slot.portal_modification_notes"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slot-actions mt-3">
|
||||
<a t-att-href="'/my/planning/slot/%s' % slot.id" class="btn btn-sm btn-primary">
|
||||
<i class="fa fa-eye"></i> Voir
|
||||
</a>
|
||||
<t t-if="not slot.portal_confirmed">
|
||||
<a t-att-href="'/my/planning/slot/%s/confirm' % slot.id" class="btn btn-sm btn-success o_planning_confirm" t-att-data-slot-id="slot.id">
|
||||
<i class="fa fa-check"></i> Confirmer
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="slot.portal_can_modify">
|
||||
<a t-att-href="'/my/planning/slot/%s/modify' % slot.id" class="btn btn-sm btn-warning o_planning_modify" t-att-data-slot-id="slot.id">
|
||||
<i class="fa fa-pencil"></i> Modifier
|
||||
</a>
|
||||
</t>
|
||||
<a t-att-href="'/my/planning/slot/%s/exchange' % slot.id" class="btn btn-sm btn-info o_planning_exchange" t-att-data-slot-id="slot.id">
|
||||
<i class="fa fa-exchange"></i> Échanger
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div t-if="pager" class="o_portal_pager text-center">
|
||||
<t t-call="portal.pager"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Détail d'un créneau de planning -->
|
||||
<template id="portal_planning_slot" name="Planning Slot Detail">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container o_portal_planning">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<h4>
|
||||
<t t-if="slot.role_id">
|
||||
<span t-field="slot.role_id.name"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
Créneau de planning
|
||||
</t>
|
||||
</h4>
|
||||
<span t-if="slot.portal_status == 'draft'" class="badge badge-info">À confirmer</span>
|
||||
<span t-if="slot.portal_status == 'confirmed'" class="badge badge-success">Confirmé</span>
|
||||
<span t-if="slot.portal_status == 'modified'" class="badge badge-warning">Modifié</span>
|
||||
<span t-if="slot.portal_status == 'pending_approval'" class="badge badge-danger">En attente d'approbation</span>
|
||||
</div>
|
||||
<div class="col-lg-6 text-right">
|
||||
<t t-if="not slot.portal_confirmed">
|
||||
<a t-att-href="'/my/planning/slot/%s/confirm' % slot.id" class="btn btn-success o_planning_confirm" t-att-data-slot-id="slot.id">
|
||||
<i class="fa fa-check"></i> Confirmer
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="slot.portal_can_modify">
|
||||
<a t-att-href="'/my/planning/slot/%s/modify' % slot.id" class="btn btn-warning o_planning_modify" t-att-data-slot-id="slot.id">
|
||||
<i class="fa fa-pencil"></i> Modifier
|
||||
</a>
|
||||
</t>
|
||||
<a t-att-href="'/my/planning/slot/%s/exchange' % slot.id" class="btn btn-info o_planning_exchange" t-att-data-slot-id="slot.id">
|
||||
<i class="fa fa-exchange"></i> Échanger
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<strong>Date de début:</strong> <span t-field="slot.start_datetime" t-options="{'widget': 'datetime'}"/>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<strong>Date de fin:</strong> <span t-field="slot.end_datetime" t-options="{'widget': 'datetime'}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<strong>Durée:</strong> <span t-esc="format_duration(slot.allocated_hours)"/>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<strong>Rôle:</strong> <span t-field="slot.role_id.name"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-12">
|
||||
<strong>Description:</strong>
|
||||
<p t-field="slot.name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informations de confirmation -->
|
||||
<div class="row mb-4" t-if="slot.portal_confirmed">
|
||||
<div class="col-lg-12">
|
||||
<div class="alert alert-success">
|
||||
<strong>Confirmation:</strong>
|
||||
<p>Ce créneau a été confirmé le <span t-field="slot.portal_confirmation_date" t-options="{'widget': 'datetime'}"/>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informations de modification -->
|
||||
<div class="row mb-4" t-if="slot.portal_modified">
|
||||
<div class="col-lg-12">
|
||||
<div class="alert alert-warning">
|
||||
<strong>Modification:</strong>
|
||||
<p>Ce créneau a été modifié le <span t-field="slot.portal_modification_date" t-options="{'widget': 'datetime'}"/>.</p>
|
||||
<t t-if="slot.portal_modification_notes">
|
||||
<p><strong>Notes:</strong> <span t-field="slot.portal_modification_notes"/></p>
|
||||
</t>
|
||||
<t t-if="slot.portal_modification_approved == 'pending'">
|
||||
<p>La modification est en attente d'approbation.</p>
|
||||
</t>
|
||||
<t t-if="slot.portal_modification_approved == 'approved'">
|
||||
<p>La modification a été approuvée.</p>
|
||||
</t>
|
||||
<t t-if="slot.portal_modification_approved == 'rejected'">
|
||||
<p>La modification a été refusée.</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Valeurs originales en cas de modification -->
|
||||
<div class="row mb-4" t-if="slot.portal_modified and slot.portal_original_start">
|
||||
<div class="col-lg-12">
|
||||
<h5>Valeurs originales:</h5>
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<p><strong>Date de début:</strong> <span t-field="slot.portal_original_start" t-options="{'widget': 'datetime'}"/></p>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<p><strong>Date de fin:</strong> <span t-field="slot.portal_original_end" t-options="{'widget': 'datetime'}"/></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" t-if="slot.portal_original_role_id">
|
||||
<div class="col-lg-6">
|
||||
<p><strong>Rôle:</strong> <span t-field="slot.portal_original_role_id.name"/></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Formulaire de modification d'un créneau -->
|
||||
<template id="portal_planning_slot_modify" name="Modify Planning Slot">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container o_portal_planning_form">
|
||||
<h2 class="mb-4">Modifier le créneau de planning</h2>
|
||||
|
||||
<t t-if="error" t-raw="0">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<t t-esc="error"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>
|
||||
<t t-if="slot.role_id">
|
||||
<span t-field="slot.role_id.name"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
Créneau de planning
|
||||
</t>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" t-attf-action="/my/planning/slot/#{slot.id}/modify" class="o_planning_modification_form">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="start_datetime">Date et heure de début</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="datetime-local" class="form-control o_planning_datetime_start" name="start_datetime" id="start_datetime" t-att-value="slot.start_datetime.strftime('%Y-%m-%dT%H:%M')" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="end_datetime">Date et heure de fin</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="datetime-local" class="form-control o_planning_datetime_end" name="end_datetime" id="end_datetime" t-att-value="slot.end_datetime.strftime('%Y-%m-%dT%H:%M')" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="role_id">Rôle</label>
|
||||
<div class="col-lg-9">
|
||||
<select class="form-control" name="role_id" id="role_id">
|
||||
<option value="">-- Sélectionner un rôle --</option>
|
||||
<t t-foreach="roles" t-as="role">
|
||||
<option t-att-value="role.id" t-att-selected="role.id == slot.role_id.id if slot.role_id else None">
|
||||
<t t-esc="role.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="notes">Notes</label>
|
||||
<div class="col-lg-9">
|
||||
<textarea class="form-control" name="notes" id="notes" rows="4" placeholder="Raison de la modification..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save"></i> Enregistrer
|
||||
</button>
|
||||
<a t-att-href="'/my/planning/slot/%s' % slot.id" class="btn btn-secondary">
|
||||
<i class="fa fa-times"></i> Annuler
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Formulaire d'échange d'un créneau -->
|
||||
<template id="portal_planning_slot_exchange" name="Exchange Planning Slot">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container o_portal_planning_form">
|
||||
<h2 class="mb-4">Demande d'échange de créneau</h2>
|
||||
|
||||
<t t-if="error" t-raw="0">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<t t-esc="error"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Créneau à échanger</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<p><strong>Date:</strong> <span t-field="slot.start_datetime" t-options="{'widget': 'date'}"/></p>
|
||||
<p><strong>Horaire:</strong> <span t-field="slot.start_datetime" t-options="{'widget': 'time', 'time_only': 'true'}"/> - <span t-field="slot.end_datetime" t-options="{'widget': 'time', 'time_only': 'true'}"/></p>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<p><strong>Durée:</strong> <span t-esc="format_duration(slot.allocated_hours)"/></p>
|
||||
<p t-if="slot.role_id"><strong>Rôle:</strong> <span t-field="slot.role_id.name"/></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" t-attf-action="/my/planning/slot/#{slot.id}/exchange" class="o_planning_exchange_form">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input" name="is_open_request" id="is_open_request"/>
|
||||
<label class="custom-control-label" for="is_open_request">
|
||||
Demande ouverte (sans créneau spécifique)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sélection du créneau cible (pour demande spécifique) -->
|
||||
<div id="target_slot_section">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Créneau souhaité</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="target_slot_id">Sélectionnez un créneau</label>
|
||||
<select class="form-control" name="target_slot_id" id="target_slot_id">
|
||||
<option value="">-- Sélectionner un créneau --</option>
|
||||
<t t-foreach="available_slots" t-as="target_slot">
|
||||
<option t-att-value="target_slot.id">
|
||||
<t t-esc="target_slot.employee_id.name"/> -
|
||||
<t t-esc="target_slot.start_datetime.strftime('%d/%m/%Y %H:%M')"/> -
|
||||
<t t-esc="target_slot.end_datetime.strftime('%H:%M')"/>
|
||||
<t t-if="target_slot.role_id"> (<t t-esc="target_slot.role_id.name"/>)</t>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dates préférées (pour demande ouverte) -->
|
||||
<div id="preferred_dates_section" style="display: none;">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Dates préférées</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="preferred_start">Date et heure de début</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="datetime-local" class="form-control o_planning_datetime_start" name="preferred_start" id="preferred_start"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="preferred_end">Date et heure de fin</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="datetime-local" class="form-control o_planning_datetime_end" name="preferred_end" id="preferred_end"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea class="form-control" name="notes" id="notes" rows="4" placeholder="Raison de l'échange..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-paper-plane"></i> Soumettre la demande
|
||||
</button>
|
||||
<a t-att-href="'/my/planning/slot/%s' % slot.id" class="btn btn-secondary">
|
||||
<i class="fa fa-times"></i> Annuler
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#is_open_request').change(function() {
|
||||
if($(this).is(':checked')) {
|
||||
$('#target_slot_section').hide();
|
||||
$('#preferred_dates_section').show();
|
||||
} else {
|
||||
$('#target_slot_section').show();
|
||||
$('#preferred_dates_section').hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Page d'erreur -->
|
||||
<template id="portal_planning_error" name="Planning Error">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="alert alert-danger">
|
||||
<h4>Erreur</h4>
|
||||
<p t-esc="error"/>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<a t-att-href="back_url" class="btn btn-primary">
|
||||
<i class="fa fa-arrow-left"></i> Retour
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<!-- Template pour la modification d'un créneau -->
|
||||
<template id="portal_planning_slot_modify" name="Modify Planning Slot">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container o_portal_planning">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Modifier le créneau de planning</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-if="error" t-call="portal_planning.portal_planning_error_display"/>
|
||||
|
||||
<form method="post" t-attf-action="/my/planning/slot/#{slot.id}/modify">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="start_datetime">Date et heure de début</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="datetime-local" class="form-control" name="start_datetime" id="start_datetime"
|
||||
t-att-value="slot.start_datetime.strftime('%Y-%m-%dT%H:%M')" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="end_datetime">Date et heure de fin</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="datetime-local" class="form-control" name="end_datetime" id="end_datetime"
|
||||
t-att-value="slot.end_datetime.strftime('%Y-%m-%dT%H:%M')" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="role_id">Rôle</label>
|
||||
<div class="col-lg-9">
|
||||
<select class="form-control" name="role_id" id="role_id">
|
||||
<option value="">-- Sélectionner un rôle --</option>
|
||||
<t t-foreach="roles" t-as="role">
|
||||
<option t-att-value="role.id" t-att-selected="role.id == slot.role_id.id"><t t-esc="role.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="notes">Notes de modification</label>
|
||||
<div class="col-lg-9">
|
||||
<textarea class="form-control" name="notes" id="notes" rows="4"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-lg-9 offset-lg-3">
|
||||
<button type="submit" class="btn btn-primary">Enregistrer les modifications</button>
|
||||
<a t-att-href="'/my/planning/slot/%s' % slot.id" class="btn btn-secondary ml-2">Annuler</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template pour l'échange d'un créneau -->
|
||||
<template id="portal_planning_slot_exchange" name="Exchange Planning Slot">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container o_portal_planning">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Demande d'échange de créneau</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-if="error" t-call="portal_planning.portal_planning_error_display"/>
|
||||
|
||||
<div class="mb-4">
|
||||
<h5>Créneau à échanger</h5>
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<p><strong>Date de début:</strong> <span t-esc="slot.start_datetime.strftime('%d/%m/%Y %H:%M')"/></p>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<p><strong>Date de fin:</strong> <span t-esc="slot.end_datetime.strftime('%d/%m/%Y %H:%M')"/></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" t-attf-action="/my/planning/slot/#{slot.id}/exchange">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input" id="is_open_request" name="is_open_request"/>
|
||||
<label class="custom-control-label" for="is_open_request">Demande ouverte (sans créneau spécifique)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="target_slot_section">
|
||||
<div class="form-group">
|
||||
<label for="target_slot_id">Créneau souhaité</label>
|
||||
<select class="form-control" name="target_slot_id" id="target_slot_id">
|
||||
<option value="">-- Sélectionner un créneau --</option>
|
||||
<t t-foreach="available_slots" t-as="available_slot">
|
||||
<option t-att-value="available_slot.id">
|
||||
<t t-esc="available_slot.employee_id.name"/> -
|
||||
<t t-esc="available_slot.start_datetime.strftime('%d/%m/%Y %H:%M')"/> à
|
||||
<t t-esc="available_slot.end_datetime.strftime('%H:%M')"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="preferred_dates_section" style="display: none;">
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="preferred_start">Date et heure de début préférée</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="datetime-local" class="form-control" name="preferred_start" id="preferred_start"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-3 col-form-label" for="preferred_end">Date et heure de fin préférée</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="datetime-local" class="form-control" name="preferred_end" id="preferred_end"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes pour la demande d'échange</label>
|
||||
<textarea class="form-control" name="notes" id="notes" rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Envoyer la demande d'échange</button>
|
||||
<a t-att-href="'/my/planning/slot/%s' % slot.id" class="btn btn-secondary ml-2">Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var openRequestCheckbox = document.getElementById('is_open_request');
|
||||
var targetSlotSection = document.getElementById('target_slot_section');
|
||||
var preferredDatesSection = document.getElementById('preferred_dates_section');
|
||||
|
||||
openRequestCheckbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
targetSlotSection.style.display = 'none';
|
||||
preferredDatesSection.style.display = 'block';
|
||||
} else {
|
||||
targetSlotSection.style.display = 'block';
|
||||
preferredDatesSection.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template pour afficher les erreurs -->
|
||||
<template id="portal_planning_error" name="Planning Error">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container o_portal_planning">
|
||||
<div class="alert alert-danger">
|
||||
<h4 class="alert-heading">Erreur</h4>
|
||||
<p t-esc="error"/>
|
||||
<hr/>
|
||||
<p class="mb-0">
|
||||
<a t-att-href="back_url" class="btn btn-secondary">Retour</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template pour afficher les erreurs dans un formulaire -->
|
||||
<template id="portal_planning_error_display" name="Planning Error Display">
|
||||
<div class="alert alert-danger">
|
||||
<p t-esc="error"/>
|
||||
</div>
|
||||
</template>
|
||||
</odoo>
|
||||
395
portal_planning/views/portal_planning_templates.xml
Normal file
395
portal_planning/views/portal_planning_templates.xml
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Les assets sont maintenant définis dans le fichier __manifest__.py -->
|
||||
<!-- Ajouter Planning dans le menu du portail -->
|
||||
<template id="portal_my_home_planning" name="Portal My Home Planning" inherit_id="portal.portal_my_home" priority="30">
|
||||
<xpath expr="//div[@id='portal_common_category']" position="inside">
|
||||
<t t-call="portal.portal_docs_entry">
|
||||
<t t-set="icon" t-value="'/portal_planning/static/src/img/portal-planning.svg'"/>
|
||||
<t t-set="title">Mon Planning</t>
|
||||
<t t-set="text">Consultez et gérez votre planning</t>
|
||||
<t t-set="url" t-value="'/my/planning/slots'"/>
|
||||
<t t-set="config_card" t-value="True"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Ajouter Planning dans la barre de navigation du portail -->
|
||||
<template id="portal_breadcrumbs_planning" name="Portal Breadcrumbs Planning" inherit_id="portal.portal_breadcrumbs" priority="30">
|
||||
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
|
||||
<li t-if="page_name == 'planning' or page_name == 'planning_detail' or page_name == 'planning_calendar'" t-attf-class="breadcrumb-item #{'active ' if page_name == 'planning' else ''}">
|
||||
<a t-if="page_name != 'planning'" t-attf-href="/my/planning">Planning</a>
|
||||
<t t-else="">Planning</t>
|
||||
</li>
|
||||
<li t-if="page_name == 'planning_detail'" class="breadcrumb-item active">
|
||||
<t t-esc="planning.role_id.name or 'Shift'"/>
|
||||
</li>
|
||||
<li t-if="page_name == 'planning_calendar'" class="breadcrumb-item active">
|
||||
Calendrier
|
||||
</li>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Page principale du planning -->
|
||||
<template id="portal_my_planning" name="My Planning">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Planning</t>
|
||||
</t>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 text-right">
|
||||
<a href="/my/planning/calendar" class="btn btn-info mr-2">
|
||||
<i class="fa fa-calendar"/> Vue calendrier
|
||||
</a>
|
||||
<a href="/my/planning/create" class="btn btn-primary">
|
||||
<i class="fa fa-plus"/> Nouvelle demande de planning
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="not planning_requests">
|
||||
<div class="alert alert-info">
|
||||
<p>Aucune demande de planning n'est disponible pour le moment.</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="planning_requests">
|
||||
<!-- Filtres par état -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<ul class="nav nav-tabs">
|
||||
<t t-foreach="searchbar_filters" t-as="filter_key">
|
||||
<li class="nav-item">
|
||||
<a t-att-href="'/my/planning?filterby=%s' % filter_key" t-attf-class="nav-link #{'active' if filterby == filter_key else ''}">
|
||||
<t t-esc="searchbar_filters[filter_key]['label']"/>
|
||||
</a>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-hover o_portal_my_doc_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Titre</th>
|
||||
<th>Date</th>
|
||||
<th>Rôle</th>
|
||||
<th>Durée</th>
|
||||
<th>État</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="planning_requests" t-as="planning">
|
||||
<tr>
|
||||
<td>
|
||||
<span t-field="planning.name"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-field="planning.start_datetime" t-options="{'widget': 'datetime'}"/>
|
||||
<span> - </span>
|
||||
<span t-field="planning.end_datetime" t-options="{'widget': 'datetime'}"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-field="planning.role_id.name"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="format_duration(planning.allocated_hours)"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-if="planning.state == 'draft'" class="badge badge-secondary">Brouillon</span>
|
||||
<span t-if="planning.state == 'submitted'" class="badge badge-info">Soumis</span>
|
||||
<span t-if="planning.state == 'approved'" class="badge badge-success">Approuvé</span>
|
||||
<span t-if="planning.state == 'rejected'" class="badge badge-danger">Refusé</span>
|
||||
<span t-if="planning.state == 'cancelled'" class="badge badge-dark">Annulé</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a t-att-href="'/my/planning/%s' % planning.id" class="btn btn-sm btn-primary">
|
||||
<i class="fa fa-eye"/> Voir
|
||||
</a>
|
||||
<t t-if="planning.can_submit">
|
||||
<a t-att-href="'/my/planning/submit/%s' % planning.id" class="btn btn-sm btn-success">
|
||||
<i class="fa fa-check"/> Soumettre
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="planning.can_cancel">
|
||||
<a t-att-href="'/my/planning/cancel/%s' % planning.id" class="btn btn-sm btn-danger">
|
||||
<i class="fa fa-times"/> Annuler
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="planning.state in ['rejected', 'cancelled'] and planning.user_id.id == request.env.user.id">
|
||||
<a t-att-href="'/my/planning/reset/%s' % planning.id" class="btn btn-sm btn-secondary">
|
||||
<i class="fa fa-refresh"/> Remettre en brouillon
|
||||
</a>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div t-if="pager" class="o_portal_pager text-center">
|
||||
<t t-call="portal.pager"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Page de détail d'une demande de planning -->
|
||||
<template id="portal_my_planning_detail" name="My Planning Detail">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<h4>
|
||||
<span t-field="planning.name"/>
|
||||
</h4>
|
||||
<span t-if="planning.state == 'draft'" class="badge badge-secondary">Brouillon</span>
|
||||
<span t-if="planning.state == 'submitted'" class="badge badge-info">Soumis</span>
|
||||
<span t-if="planning.state == 'approved'" class="badge badge-success">Approuvé</span>
|
||||
<span t-if="planning.state == 'rejected'" class="badge badge-danger">Refusé</span>
|
||||
<span t-if="planning.state == 'cancelled'" class="badge badge-dark">Annulé</span>
|
||||
</div>
|
||||
<div class="col-lg-6 text-right">
|
||||
<t t-if="planning.can_submit">
|
||||
<a t-att-href="'/my/planning/submit/%s' % planning.id" class="btn btn-success">
|
||||
<i class="fa fa-check"/> Soumettre
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="planning.can_cancel">
|
||||
<a t-att-href="'/my/planning/cancel/%s' % planning.id" class="btn btn-danger">
|
||||
<i class="fa fa-times"/> Annuler
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="planning.state in ['rejected', 'cancelled'] and planning.user_id.id == request.env.user.id">
|
||||
<a t-att-href="'/my/planning/reset/%s' % planning.id" class="btn btn-secondary">
|
||||
<i class="fa fa-refresh"/> Remettre en brouillon
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="planning.planning_slot_id">
|
||||
<a t-att-href="'/my/planning/view_slot/%s' % planning.id" class="btn btn-info">
|
||||
<i class="fa fa-calendar"/> Voir le créneau
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<strong>Date de début:</strong> <span t-field="planning.start_datetime" t-options="{'widget': 'datetime'}"/>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<strong>Date de fin:</strong> <span t-field="planning.end_datetime" t-options="{'widget': 'datetime'}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<strong>Durée:</strong> <span t-esc="format_duration(planning.allocated_hours)"/>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<strong>Rôle:</strong> <span t-field="planning.role_id.name"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-12">
|
||||
<strong>Description:</strong>
|
||||
<p t-field="planning.description"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4" t-if="planning.state == 'rejected'">
|
||||
<div class="col-lg-12">
|
||||
<div class="alert alert-danger">
|
||||
<strong>Motif du refus:</strong>
|
||||
<p>Votre demande a été refusée. Veuillez contacter votre responsable pour plus d'informations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Formulaire de création d'une demande de planning -->
|
||||
<template id="portal_planning_create" name="Create Planning Request">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Nouvelle demande de planning</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/my/planning/create" method="post" class="o_form_planning_request">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-lg-3 col-form-label">Titre</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="text" name="name" class="form-control" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="start_datetime" class="col-lg-3 col-form-label">Date de début</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="datetime-local" name="start_datetime" class="form-control" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="end_datetime" class="col-lg-3 col-form-label">Date de fin</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="datetime-local" name="end_datetime" class="form-control" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="role_id" class="col-lg-3 col-form-label">Rôle</label>
|
||||
<div class="col-lg-9">
|
||||
<select name="role_id" class="form-control">
|
||||
<option value="">-- Sélectionner un rôle --</option>
|
||||
<t t-foreach="roles" t-as="role">
|
||||
<option t-att-value="role.id"><t t-esc="role.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="description" class="col-lg-3 col-form-label">Description</label>
|
||||
<div class="col-lg-9">
|
||||
<textarea name="description" class="form-control" rows="4"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="offset-lg-3 col-lg-9">
|
||||
<button type="submit" class="btn btn-primary">Créer</button>
|
||||
<a href="/my/planning" class="btn btn-secondary ml-2">Annuler</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Page de détail d'un créneau de planning -->
|
||||
<template id="portal_planning_slot" name="Planning Slot Detail">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<h4>Créneau de planning</h4>
|
||||
<span class="badge badge-success">Publié</span>
|
||||
</div>
|
||||
<div class="col-lg-6 text-right">
|
||||
<a t-att-href="'/my/planning/%s' % planning.id" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left"/> Retour à la demande
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<strong>Date de début:</strong> <span t-field="slot.start_datetime" t-options="{'widget': 'datetime'}"/>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<strong>Date de fin:</strong> <span t-field="slot.end_datetime" t-options="{'widget': 'datetime'}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<strong>Durée:</strong> <span t-esc="format_duration(slot.allocated_hours)"/>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<strong>Rôle:</strong> <span t-field="slot.role_id.name"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-12">
|
||||
<strong>Description:</strong>
|
||||
<p t-field="slot.name"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<!-- Vue calendrier du planning -->
|
||||
<template id="portal_planning_calendar" name="Planning Calendar View">
|
||||
<t t-call="portal.portal_layout">
|
||||
<!-- Chargement des CDN pour FullCalendar.js et Moment.js -->
|
||||
<t t-set="head">
|
||||
<!-- FullCalendar Core -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css"/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/locales/fr.js"></script>
|
||||
|
||||
<!-- Moment.js pour la manipulation des dates -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script>
|
||||
</t>
|
||||
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Calendrier de planning</t>
|
||||
</t>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 text-right">
|
||||
<a href="/my/planning" class="btn btn-secondary mr-2">
|
||||
<i class="fa fa-list"/> Vue liste
|
||||
</a>
|
||||
<a href="/my/planning/create" class="btn btn-primary">
|
||||
<i class="fa fa-plus"/> Nouvelle demande de planning
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="mb-0">Mon calendrier de planning</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="o_portal_planning_calendar">
|
||||
<div class="o_calendar_container" style="height: 600px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h5>Légende</h5>
|
||||
<div class="d-flex flex-wrap">
|
||||
<div class="mr-4 mb-2">
|
||||
<span class="badge badge-info mr-1">■</span> À confirmer
|
||||
</div>
|
||||
<div class="mr-4 mb-2">
|
||||
<span class="badge badge-success mr-1">■</span> Confirmé
|
||||
</div>
|
||||
<div class="mr-4 mb-2">
|
||||
<span class="badge badge-warning mr-1">■</span> Modifié
|
||||
</div>
|
||||
<div class="mr-4 mb-2">
|
||||
<span class="badge badge-danger mr-1">■</span> En attente d'approbation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
125
portal_planning/views/portal_planning_views.xml
Normal file
125
portal_planning/views/portal_planning_views.xml
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vue form pour portal.planning.request -->
|
||||
<record id="view_portal_planning_request_form" model="ir.ui.view">
|
||||
<field name="name">portal.planning.request.form</field>
|
||||
<field name="model">portal.planning.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Demande de planning">
|
||||
<header>
|
||||
<button name="action_submit" string="Soumettre" type="object" class="oe_highlight" invisible="state != 'draft'"/>
|
||||
<button name="action_approve" string="Approuver" type="object" class="oe_highlight" invisible="state != 'submitted'" groups="planning.group_planning_manager"/>
|
||||
<button name="action_reject" string="Refuser" type="object" invisible="state != 'submitted'" groups="planning.group_planning_manager"/>
|
||||
<button name="action_cancel" string="Annuler" type="object" invisible="state not in ('draft', 'submitted') or user_id != uid"/>
|
||||
<button name="action_reset_to_draft" string="Remettre en brouillon" type="object" invisible="state not in ('rejected', 'cancelled') or user_id != uid"/>
|
||||
<button name="action_view_planning_slot" string="Voir le créneau" type="object" invisible="not planning_slot_id"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,submitted,approved,rejected"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Titre de la demande"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="employee_id" required="1"/>
|
||||
<field name="user_id" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="planning_slot_id" readonly="1" invisible="not planning_slot_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="start_datetime" required="1"/>
|
||||
<field name="end_datetime" required="1"/>
|
||||
<field name="allocated_hours" widget="float_time"/>
|
||||
<field name="role_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Description">
|
||||
<field name="description" nolabel="1" placeholder="Description..."/>
|
||||
</group>
|
||||
<group string="Champs techniques" groups="base.group_no_one">
|
||||
<field name="can_edit"/>
|
||||
<field name="can_submit"/>
|
||||
<field name="can_approve"/>
|
||||
<field name="can_reject"/>
|
||||
<field name="can_cancel"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue list pour portal.planning.request -->
|
||||
<record id="view_portal_planning_request_list" model="ir.ui.view">
|
||||
<field name="name">portal.planning.request.list</field>
|
||||
<field name="model">portal.planning.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Demandes de planning" decoration-info="state == 'submitted'" decoration-success="state == 'approved'" decoration-danger="state == 'rejected'" decoration-muted="state == 'cancelled'">
|
||||
<field name="name"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="start_datetime"/>
|
||||
<field name="end_datetime"/>
|
||||
<field name="allocated_hours" widget="float_time" sum="Total"/>
|
||||
<field name="role_id"/>
|
||||
<field name="state"/>
|
||||
<field name="planning_slot_id" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue search pour portal.planning.request -->
|
||||
<record id="view_portal_planning_request_search" model="ir.ui.view">
|
||||
<field name="name">portal.planning.request.search</field>
|
||||
<field name="model">portal.planning.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Rechercher Demandes de Planning">
|
||||
<field name="name"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="role_id"/>
|
||||
<filter string="Mes demandes" name="my_requests" domain="[('user_id', '=', uid)]"/>
|
||||
<filter string="Brouillons" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Soumises" name="submitted" domain="[('state', '=', 'submitted')]"/>
|
||||
<filter string="Approuvées" name="approved" domain="[('state', '=', 'approved')]"/>
|
||||
<filter string="Refusées" name="rejected" domain="[('state', '=', 'rejected')]"/>
|
||||
<filter string="Annulées" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Employé" name="employee" context="{'group_by': 'employee_id'}"/>
|
||||
<filter string="Rôle" name="role" context="{'group_by': 'role_id'}"/>
|
||||
<filter string="État" name="state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Date de début" name="start_date" context="{'group_by': 'start_datetime:day'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour portal.planning.request -->
|
||||
<record id="action_portal_planning_request" model="ir.actions.act_window">
|
||||
<field name="name">Demandes de Planning</field>
|
||||
<field name="res_model">portal.planning.request</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="view_portal_planning_request_search"/>
|
||||
<field name="context">{'search_default_submitted': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucune demande de planning disponible
|
||||
</p>
|
||||
<p>
|
||||
Les demandes de planning des utilisateurs du portail seront affichées ici.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<!-- Menu pour portal.planning.request -->
|
||||
<menuitem id="menu_portal_planning_request"
|
||||
name="Demandes de Planning"
|
||||
parent="planning.planning_menu_root"
|
||||
action="action_portal_planning_request"
|
||||
sequence="20"/>
|
||||
|
||||
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue