feature/odoo18 #2

Merged
administrator merged 43 commits from feature/odoo18 into develop 2025-05-22 16:16:43 +05:30
393 changed files with 295376 additions and 0 deletions
Showing only changes of commit bc2d3d1b15 - Show all commits

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import report
from . import wizard
def _create_helpdesk_team(env):
team_1 = env.ref('helpdesk.helpdesk_team1', raise_if_not_found=False)
env['res.company'].search([('id', '!=', team_1.company_id.id)])._create_helpdesk_team()

View File

@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Helpdesk',
'version': '1.6',
'category': 'Services/Helpdesk',
'sequence': 110,
'summary': 'Track, prioritize, and solve customer tickets',
'website': 'https://www.odoo.com/app/helpdesk',
'depends': [
'base_setup',
'mail',
'utm',
'rating',
'web_tour',
'resource',
'portal',
'digest',
],
'description': """
Helpdesk - Ticket Management App
================================
Features:
- Process tickets through different stages to solve them.
- Add priorities, types, descriptions and tags to define your tickets.
- Use the chatter to communicate additional information and ping co-workers on tickets.
- Enjoy the use of an adapted dashboard, and an easy-to-use kanban view to handle your tickets.
- Make an in-depth analysis of your tickets through the pivot view in the reports menu.
- Create a team and define its members, use an automatic assignment method if you wish.
- Use a mail alias to automatically create tickets and communicate with your customers.
- Add Service Level Agreement deadlines automatically to your tickets.
- Get customer feedback by using ratings.
- Install additional features easily using your team form view.
""",
'data': [
'security/helpdesk_security.xml',
'security/ir.model.access.csv',
'data/digest_data.xml',
'data/mail_message_subtype_data.xml',
'data/mail_template_data.xml',
'data/helpdesk_data.xml',
'data/ir_cron_data.xml',
'data/ir_sequence_data.xml',
'data/helpdesk_tour.xml',
'views/helpdesk_ticket_views.xml',
'report/helpdesk_ticket_analysis_views.xml',
'report/helpdesk_sla_report_analysis_views.xml',
'views/helpdesk_tag_views.xml',
'views/helpdesk_stage_views.xml',
'views/helpdesk_sla_views.xml',
'views/helpdesk_team_views.xml',
'views/digest_views.xml',
'views/helpdesk_portal_templates.xml',
'views/rating_rating_views.xml',
'views/res_partner_views.xml',
'views/mail_activity_views.xml',
'views/helpdesk_templates.xml',
'views/helpdesk_menus.xml',
'wizard/helpdesk_stage_delete_views.xml',
],
'demo': ['data/helpdesk_demo.xml'],
'application': True,
'license': 'OEEL-1',
'post_init_hook': '_create_helpdesk_team',
'assets': {
'web.assets_backend': [
'helpdesk/static/src/scss/helpdesk.scss',
'helpdesk/static/src/css/portal_helpdesk.css',
'helpdesk/static/src/components/**/*',
'helpdesk/static/src/views/**/*',
'helpdesk/static/src/js/tours/helpdesk.js',
('remove', 'helpdesk/static/src/views/helpdesk_ticket_graph/**'),
('remove', 'helpdesk/static/src/views/helpdesk_ticket_pivot/**'),
],
'web.assets_backend_lazy': [
'helpdesk/static/src/views/helpdesk_ticket_graph/**',
'helpdesk/static/src/views/helpdesk_ticket_pivot/**',
],
'web.assets_unit_tests': [
'helpdesk/static/tests/**/*',
],
'web.assets_tests': [
'helpdesk/static/tests/tours/**/*',
],
}
}

View File

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

View File

@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from operator import itemgetter
from markupsafe import Markup
from odoo import http
from odoo.exceptions import AccessError, MissingError, UserError
from odoo.http import request
from odoo.tools.translate import _
from odoo.tools import groupby as groupbyelem
from odoo.addons.portal.controllers import portal
from odoo.addons.portal.controllers.portal import pager as portal_pager
from odoo.osv.expression import AND, FALSE_DOMAIN
class CustomerPortal(portal.CustomerPortal):
def _prepare_portal_layout_values(self):
values = super(CustomerPortal, self)._prepare_portal_layout_values()
return values
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'ticket_count' in counters:
values['ticket_count'] = (
request.env['helpdesk.ticket'].search_count(self._prepare_helpdesk_tickets_domain())
if request.env['helpdesk.ticket'].has_access('read')
else 0
)
return values
def _prepare_helpdesk_tickets_domain(self):
return []
def _ticket_get_page_view_values(self, ticket, access_token, **kwargs):
values = {
'page_name': 'ticket',
'ticket': ticket,
'ticket_link_section': [],
'ticket_closed': kwargs.get('ticket_closed', False),
'preview_object': ticket,
}
return self._get_page_view_values(ticket, access_token, values, 'my_tickets_history', False, **kwargs)
def _ticket_get_searchbar_inputs(self):
return {
'name': {'input': 'name', 'label': _(
'Search%(left)s Tickets%(right)s',
left=Markup('<span class="nolabel">'),
right=Markup('</span>'),
), 'sequence': 10},
'user_id': {'input': 'user_id', 'label': _('Search in Assigned to'), 'sequence': 20},
'partner_id': {'input': 'partner_id', 'label': _('Search in Customer'), 'sequence': 30},
'team_id': {'input': 'team_id', 'label': _('Search in Helpdesk Team'), 'sequence': 40},
'stage_id': {'input': 'stage_id', 'label': _('Search in Stage'), 'sequence': 50},
}
def _ticket_get_searchbar_groupby(self):
return {
'none': {'label': _('None'), 'sequence': 10},
'user_id': {'label': _('Assigned to'), 'sequence': 20},
'team_id': {'label': _('Helpdesk Team'), 'sequence': 30},
'stage_id': {'label': _('Stage'), 'sequence': 40},
'kanban_state': {'label': _('Status'), 'sequence': 50},
'partner_id': {'label': _('Customer'), 'sequence': 60},
}
def _ticket_get_search_domain(self, search_in, search):
if search_in == 'name':
return ['|', ('name', 'ilike', search), ('ticket_ref', 'ilike', search)]
elif search_in == 'user_id':
assignees = request.env['res.users'].sudo()._search([('name', 'ilike', search)])
return [('user_id', 'in', assignees)]
elif search_in in self._ticket_get_searchbar_inputs():
return [(search_in, 'ilike', search)]
else:
return FALSE_DOMAIN
def _prepare_my_tickets_values(self, page=1, date_begin=None, date_end=None, sortby=None, filterby='all', search=None, groupby='none', search_in='name'):
values = self._prepare_portal_layout_values()
domain = self._prepare_helpdesk_tickets_domain()
searchbar_sortings = {
'create_date desc': {'label': _('Newest')},
'id desc': {'label': _('Reference')},
'name': {'label': _('Subject')},
'user_id': {'label': _('Assigned to')},
'stage_id': {'label': _('Stage')},
'date_last_stage_update desc': {'label': _('Last Stage Update')},
}
searchbar_filters = {
'all': {'label': _('All'), 'domain': []},
'assigned': {'label': _('Assigned'), 'domain': [('user_id', '!=', False)]},
'unassigned': {'label': _('Unassigned'), 'domain': [('user_id', '=', False)]},
'open': {'label': _('Open'), 'domain': [('close_date', '=', False)]},
'closed': {'label': _('Closed'), 'domain': [('close_date', '!=', False)]},
}
searchbar_inputs = dict(sorted(self._ticket_get_searchbar_inputs().items(), key=lambda item: item[1]['sequence']))
searchbar_groupby = dict(sorted(self._ticket_get_searchbar_groupby().items(), key=lambda item: item[1]['sequence']))
# default sort by value
if not sortby:
sortby = 'create_date desc'
domain = AND([domain, searchbar_filters[filterby]['domain']])
if date_begin and date_end:
domain = AND([domain, [('create_date', '>', date_begin), ('create_date', '<=', date_end)]])
# search
if search and search_in:
domain = AND([domain, self._ticket_get_search_domain(search_in, search)])
# pager
tickets_count = request.env['helpdesk.ticket'].search_count(domain)
pager = portal_pager(
url="/my/tickets",
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'search_in': search_in, 'search': search, 'groupby': groupby, 'filterby': filterby},
total=tickets_count,
page=page,
step=self._items_per_page
)
order = f'{groupby}, {sortby}' if groupby != 'none' else sortby
tickets = request.env['helpdesk.ticket'].search(domain, order=order, limit=self._items_per_page, offset=pager['offset'])
request.session['my_tickets_history'] = tickets.ids[:100]
if not tickets:
grouped_tickets = []
elif groupby != 'none':
grouped_tickets = [request.env['helpdesk.ticket'].concat(*g) for k, g in groupbyelem(tickets, itemgetter(groupby))]
else:
grouped_tickets = [tickets]
values.update({
'date': date_begin,
'grouped_tickets': grouped_tickets,
'page_name': 'ticket',
'default_url': '/my/tickets',
'pager': pager,
'searchbar_sortings': searchbar_sortings,
'searchbar_filters': searchbar_filters,
'searchbar_inputs': searchbar_inputs,
'searchbar_groupby': searchbar_groupby,
'sortby': sortby,
'groupby': groupby,
'search_in': search_in,
'search': search,
'filterby': filterby,
})
return values
@http.route(['/my/tickets', '/my/tickets/page/<int:page>'], type='http', auth="user", website=True)
def my_helpdesk_tickets(self, page=1, date_begin=None, date_end=None, sortby=None, filterby='all', search=None, groupby='none', search_in='name', **kw):
values = self._prepare_my_tickets_values(page, date_begin, date_end, sortby, filterby, search, groupby, search_in)
return request.render("helpdesk.portal_helpdesk_ticket", values)
@http.route([
"/helpdesk/ticket/<int:ticket_id>",
"/helpdesk/ticket/<int:ticket_id>/<access_token>",
'/my/ticket/<int:ticket_id>',
'/my/ticket/<int:ticket_id>/<access_token>'
], type='http', auth="public", website=True)
def tickets_followup(self, ticket_id=None, access_token=None, **kw):
try:
ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
values = self._ticket_get_page_view_values(ticket_sudo, access_token, **kw)
return request.render("helpdesk.tickets_followup", values)
@http.route([
'/my/ticket/close/<int:ticket_id>',
'/my/ticket/close/<int:ticket_id>/<access_token>',
], type='http', auth="public", website=True)
def ticket_close(self, ticket_id=None, access_token=None, **kw):
try:
ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
if not ticket_sudo.team_id.allow_portal_ticket_closing:
raise UserError(_("The team does not allow ticket closing through portal"))
if not ticket_sudo.closed_by_partner:
closing_stage = ticket_sudo.team_id._get_closing_stage()
if ticket_sudo.stage_id != closing_stage:
ticket_sudo.write({'stage_id': closing_stage[0].id, 'closed_by_partner': True})
else:
ticket_sudo.write({'closed_by_partner': True})
body = _('Ticket closed by the customer')
ticket_sudo.with_context(mail_create_nosubscribe=True).message_post(body=body, message_type='comment', subtype_xmlid='mail.mt_note')
return request.redirect('/my/ticket/%s/%s?ticket_closed=1' % (ticket_id, access_token or ''))

View File

@ -0,0 +1,28 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<data noupdate="1">
<record id="digest.digest_digest_default" model="digest.digest">
<field name="kpi_helpdesk_tickets_closed">True</field>
</record>
</data>
<data>
<record id="digest_tip_helpdesk_0" model="digest.tip">
<field name="name">Tip: Create tickets from incoming emails</field>
<field name="sequence">1800</field>
<field name="group_id" ref="helpdesk.group_helpdesk_manager" />
<field name="tip_description" type="html">
<div>
<t t-set="record" t-value="object.env['helpdesk.team'].search([('alias_name', '!=', False), ('alias_domain_id', '!=', False)],limit=1)" />
<b class="tip_title">Tip: Create tickets from incoming emails</b>
<t t-if="record.alias_email">
<p class="tip_content">Emails sent to <a t-attf-href="mailto:{{record.alias_email}}" target="_blank" style="color: #714B67; text-decoration: none;"><t t-out="record.alias_email" /></a> generate tickets in your pipeline.</p>
</t>
<t t-else="">
<p class="tip_content">Emails sent to a Helpdesk Team alias generate tickets in your pipeline.</p>
</t>
</div>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="helpdesk_team1" model="helpdesk.team">
<field name="name">Customer Care</field>
<field name="alias_name">customer-care</field>
<field name="stage_ids" eval="False"/> <!-- eval=False to don't get the default stage. New stages are setted below-->
<field name="use_sla" eval="True"/>
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
</record>
<!-- stage "New" gets created by default with sequence 0-->
<record id="stage_new" model="helpdesk.stage">
<field name="name">New</field>
<field name="sequence">0</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
<field name="template_id" ref="helpdesk.new_ticket_request_email_template"/>
</record>
<record id="stage_in_progress" model="helpdesk.stage">
<field name="name">In Progress</field>
<field name="sequence">1</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
</record>
<record id="stage_on_hold" model="helpdesk.stage">
<field name="name">On Hold</field>
<field name="sequence">2</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
</record>
<record id="stage_solved" model="helpdesk.stage">
<field name="name">Solved</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
<field name="sequence">3</field>
<field name="fold" eval="True"/>
</record>
<record id="stage_cancelled" model="helpdesk.stage">
<field name="name">Cancelled</field>
<field name="sequence">4</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
<field name="fold" eval="True"/>
</record>
</odoo>

View File

@ -0,0 +1,506 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="base.user_demo" model="res.users">
<field name="groups_id" eval="[(3, ref('helpdesk.group_helpdesk_manager'))]"/>
</record>
</data>
<record id="tag_crm" model="helpdesk.tag">
<field name="name">CRM</field>
</record>
<record id="tag_website" model="helpdesk.tag">
<field name="name">Website</field>
</record>
<record id="tag_service" model="helpdesk.tag">
<field name="name">Service</field>
</record>
<record id="tag_repair" model="helpdesk.tag">
<field name="name">Repair</field>
</record>
<!-- Set target on user -->
<record id="base.user_admin" model="res.users">
<field name="helpdesk_target_closed">5</field>
<field name="helpdesk_target_rating">4.5</field>
<field name="helpdesk_target_success">87</field>
</record>
<!-- helpdesk team -->
<record id="helpdesk_team3" model="helpdesk.team">
<field name="name">VIP Support</field>
<field name="stage_ids" eval="[(6, 0, [ref('helpdesk.stage_new'), ref('helpdesk.stage_in_progress'), ref('helpdesk.stage_solved'), ref('helpdesk.stage_cancelled')])]"/>
<field name="use_sla" eval="True"/>
<field name="use_rating" eval="True"/>
<field name="color">10</field>
<field name="assign_method">randomly</field>
<field name="member_ids" eval="[Command.link(ref('base.user_admin')), Command.link(ref('base.user_demo'))]"/>
<field name="privacy_visibility">invited_internal</field>
<field name="description" type="html">
<p>We provide 24/7 support, Monday through Friday. Ticket responses are usually provided within 2 working days.<br/>
Support is mainly provided in English. We can also assist in Spanish, French, and Dutch.</p>
</field>
</record>
<record id="helpdesk.helpdesk_team1" model="helpdesk.team">
<field name="use_rating" eval="True"/>
<field name="description" type="html">
<p>We provide 24/7 support, Monday through Friday. Ticket responses are usually provided within 2 working days.<br/>
Support is mainly provided in English. We can also assist in Spanish, French, and Dutch.</p>
</field>
</record>
<!-- SLA's -->
<record id="helpdesk_sla_1" model="helpdesk.sla">
<field name="name">2 days to start</field>
<field name="team_id" ref="helpdesk_team1"/>
<field name="stage_id" ref="stage_in_progress"/>
<field name="time">16</field>
</record>
<record id="helpdesk_sla_2" model="helpdesk.sla">
<field name="name">7 days to finish</field>
<field name="team_id" ref="helpdesk_team1"/>
<field name="stage_id" ref="stage_solved"/>
<field name="time">56</field>
<field name="exclude_stage_ids" eval="[Command.link(ref('helpdesk.stage_on_hold'))]"/>
</record>
<record id="helpdesk_sla_3" model="helpdesk.sla">
<field name="name">8 hours to finish</field>
<field name="team_id" ref="helpdesk_team3"/>
<field name="priority">3</field>
<field name="stage_id" ref="stage_solved"/>
<field name="time">8</field>
<field name="partner_ids" eval="[Command.link(ref('base.res_partner_10')), Command.link(ref('base.res_partner_2'))]"/>
</record>
<!-- Tickets -->
<record id="helpdesk_ticket_1" model="helpdesk.ticket">
<field name="name">Kitchen collapsing</field>
<field name="user_id" ref="base.user_admin"/>
<field name="priority">3</field>
<field name="partner_id" ref="base.res_partner_12"/>
<field name="stage_id" ref="helpdesk.stage_in_progress"/>
</record>
<record id="helpdesk_ticket_2" model="helpdesk.ticket">
<field name="name">Where can I download a catalog?</field>
<field name="team_id" ref="helpdesk.helpdesk_team1"/>
<field name="priority">0</field>
<field name="partner_id" ref="base.res_partner_4"/>
</record>
<record id="helpdesk_ticket_3" model="helpdesk.ticket">
<field name="name">Warranty</field>
<field name="team_id" ref="helpdesk.helpdesk_team1"/>
<field name="user_id" ref="base.user_admin"/>
<field name="priority">2</field>
<field name="partner_id" ref="base.res_partner_main1" />
<field name="stage_id" ref="helpdesk.stage_new"/>
<field name="kanban_state">blocked</field>
<field name="description" type="html">
<field name="sla_reached_late" eval="True"/>
<p>Hello,<br/><br/>
I would like to know what kind of warranties you are offering for your products.<br/>
Here is my contact number: 123456789<br/><br/>
Thank you,<br/>
Chester Reed</p>
</field>
</record>
<!-- fail the sla status -->
<function model="helpdesk.sla.status" name="write">
<value model="helpdesk.sla.status" search="[('ticket_id', '=', ref('helpdesk_ticket_3'))]"/>
<value eval="{'deadline': DateTime.now() - relativedelta(days=2)}"/>
</function>
<record id="helpdesk_ticket_4" model="helpdesk.ticket">
<field name="name">Wood Treatment</field>
<field name="team_id" ref="helpdesk.helpdesk_team1"/>
<field name="user_id" ref="base.user_demo"/>
<field name="partner_id" ref="base.res_partner_4" />
<field name="stage_id" ref="helpdesk.stage_solved"/>
<field name="close_date" eval="DateTime.now()"/>
<field name="description" type="html">
<p>Hello,<br/><br/>
Is the wood from your furniture treated with a particular product? What would you recommend to maintain the quality of a dining table?<br/>
Your assistance would be greatly appreciated.<br/><br/>
Thanks in Advance,<br/>
Azure Interior</p>
</field>
</record>
<record id="helpdesk_ticket_5" model="helpdesk.ticket">
<field name="name">Chair dimensions</field>
<field name="team_id" ref="helpdesk.helpdesk_team1"/>
<field name="user_id" ref="base.user_admin"/>
<field name="partner_id" ref="base.res_partner_12"/>
<field name="stage_id" ref="helpdesk.stage_solved"/>
<field name="create_date" eval="DateTime.now()- relativedelta(days=1)"/>
<field name="close_date" eval="DateTime.now()"/>
<field name="description" type="html">
<p>Can you please tell me the dimensions of your “Office chair Black”? Also I am unable to find the information on your official site.<br/>
I look forward to your kind response.<br/><br/>
Thank you!</p>
</field>
</record>
<record id="helpdesk_ticket_6" model="helpdesk.ticket">
<field name="name">Lost key</field>
<field name="team_id" ref="helpdesk.helpdesk_team1"/>
<field name="user_id" ref="base.user_admin"/>
<field name="partner_id" ref="base.res_partner_3"/>
<field name="stage_id" ref="helpdesk.stage_in_progress"/>
<field name="kanban_state">done</field>
<field name="description" type="html">
<p>Hello,<br/><br/>
I bought a locker a few years ago and I, unfortunately, lost the key. I cannot retrieve the documents I had left in there without damaging the furniture item. What solution do you offer?<br/><br/>
Thanks in advance for your help.<br/>
Kind regards,<br/>
Gemini Furniture</p>
</field>
</record>
<record id="helpdesk_ticket_7" model="helpdesk.ticket">
<field name="name">Furniture delivery</field>
<field name="team_id" ref="helpdesk.helpdesk_team1"/>
<field name="user_id" ref="base.user_admin"/>
<field name="partner_id" ref="base.res_partner_2"/>
<field name="stage_id" ref="helpdesk.stage_in_progress"/>
<field name="create_date" eval="DateTime.now()- relativedelta(days=9)"/>
<field name="description" type="html">
<p>Hi,<br/><br/>
I was wondering if you were delivering the furniture or if we needed to pick it up at your warehouse?<br/>
If you do take care of the delivery, are there any extra costs?<br/><br/>
Regards,<br/>
Deco Addict</p>
</field>
</record>
<!-- change the stage to cancelled and add close date to update success rate-->
<function model="helpdesk.ticket" name="write">
<value model="helpdesk.ticket" search="[('id', '=', ref('helpdesk_ticket_7'))]"/>
<value eval="{'stage_id': ref('helpdesk.stage_cancelled'), 'close_date': DateTime.now()}"/>
</function>
<record id="helpdesk_ticket_8" model="helpdesk.ticket">
<field name="name">Cabinets in kit</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="partner_id" ref="base.res_partner_10"/>
<field name="stage_id" ref="helpdesk.stage_new"/>
<field name="description" type="html">
<p>Hello,<br/><br/>
I would like to know if your cabinets come in a kit? They seem quite large and I am not sure they will fit through my front door.<br/><br/>
Thank you for your help.<br/>
Best regards,<br/>
Jackson Group</p>
</field>
</record>
<record id="helpdesk_ticket_9" model="helpdesk.ticket">
<field name="name">Missing user manual</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="partner_id" ref="base.res_partner_12"/>
<field name="stage_id" ref="helpdesk.stage_new"/>
<field name="kanban_state">blocked</field>
<field name="description" type="html">
<p>Hello,<br/><br/>
I recently purchased one of your wardrobes in a kit. Unfortunately, I didnt receive the user manual, so I cannot assemble the item. Could you send me this document?<br/><br/>
Thank you.<br/>
Kind regards,</p>
</field>
</record>
<record id="helpdesk_ticket_10" model="helpdesk.ticket">
<field name="name">Ugly Chair</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="user_id" ref="base.user_admin"/>
<field name="partner_id" ref="base.res_partner_2"/>
<field name="stage_id" ref="helpdesk.stage_solved"/>
<field name="kanban_state">done</field>
<field name="close_date" eval="DateTime.now()"/>
<field name="description" type="html">
<p>Hello,<br/><br/>
I purchased a chair from you last week. I now realize it doesnt go well with the rest of my furniture, so I would like to return it and to get a refund.<br/><br/>
Regards,<br/>
Deco Addict</p>
</field>
</record>
<record id="helpdesk_ticket_11" model="helpdesk.ticket">
<field name="name">Couch</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="user_id" ref="base.user_demo"/>
<field name="partner_id" ref="base.res_partner_1"/>
<field name="stage_id" ref="helpdesk.stage_in_progress"/>
<field name="description" type="html">
<p>Hello,<br/><br/>
The couch I ordered was scratched during the delivery. Would it be possible to have a gesture of goodwill?<br/>
Thank you for considering my request.<br/><br/>
Best regards,</p>
</field>
</record>
<record id="helpdesk_ticket_12" model="helpdesk.ticket">
<field name="name">Chair wheels arent working</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="partner_id" ref="base.res_partner_main1"/>
<field name="stage_id" ref="helpdesk.stage_new"/>
<field name="priority">3</field>
<field name="tag_ids" eval="[(6,0,[ref('helpdesk.tag_repair'),ref('helpdesk.tag_service')])]"/>
<field name="create_date" eval="DateTime.now()- relativedelta(days=1)"/>
<field name="kanban_state">done</field>
<field name="description" type="html">
<p>The chair I bought last year isn't turning correctly anymore. Are you selling spare parts for the wheels?<br/><br/>
Thank you in advance for your help.<br/>
Chester Reed</p>
</field>
</record>
<!-- Fail the sla on ticket -->
<function model="helpdesk.sla.status" name="write">
<value model="helpdesk.sla.status" search="[('ticket_id', '=', ref('helpdesk_ticket_12'))]"/>
<value eval="{'deadline': DateTime.now()}"/>
</function>
<record id="helpdesk_ticket_13" model="helpdesk.ticket">
<field name="name">Cabinet Colour and Lock aren't proper</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="user_id" ref="base.user_admin"/>
<field name="partner_id" ref="base.res_partner_10"/>
<field name="stage_id" ref="helpdesk.stage_new"/>
<field name="priority">3</field>
<field name="tag_ids" eval="[(6,0,[ref('helpdesk.tag_repair'),ref('helpdesk.tag_service')])]"/>
<field name="kanban_state">done</field>
<field name="description" type="html">
<p>Hi,<br/><br/>
I purchased a "Cabinet With Doors" from your store a few days ago. The lock is not working properly and the color is wrong. This is unacceptable! I am asking for a product that corresponds to my order and that matches the quality you are advertising.<br/><br/>
Regards,<br/>
The Jackson Group</p>
</field>
</record>
<record id="helpdesk_ticket_14" model="helpdesk.ticket">
<field name="name">Lamp Stand is bent</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="user_id" ref="base.user_demo"/>
<field name="partner_id" ref="base.res_partner_4"/>
<field name="stage_id" ref="helpdesk.stage_new"/>
<field name="priority">2</field>
<field name="description" type="html">
<p>Hello,<br/><br/>
Yesterday I purchased a lamp stand from your site but the product I received is bent.<br/>
Would it be possible to get a replacement?<br/><br/>
Regards,<br/>
Ready Mat</p>
</field>
</record>
<record id="helpdesk_ticket_15" model="helpdesk.ticket">
<field name="name">Table legs are unbalanced</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="user_id" ref="base.user_admin"/>
<field name="partner_id" ref="base.res_partner_12"/>
<field name="stage_id" ref="helpdesk.stage_in_progress"/>
<field name="priority">3</field>
<field name="tag_ids" eval="[(6,0,[ref('helpdesk.tag_repair'),ref('helpdesk.tag_service')])]"/>
<field name="kanban_state">done</field>
<field name="description" type="html">
<p>Hi,<br/><br/>
A few days ago, I bought a Four Persons Desk. While assembling it in my office, I found that the legs of the table were not properly balanced. Could you please come and fix this?<br/>
Kindly do this as early as possible.<br/><br/>
Best,<br/>
Azure Interior</p>
</field>
</record>
<record id="helpdesk_ticket_16" model="helpdesk.ticket">
<field name="name">Drawers slides and handle have a defect</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="user_id" ref="base.user_admin"/>
<field name="priority">3</field>
<field name="partner_id" ref="base.res_partner_2"/>
<field name="stage_id" ref="helpdesk.stage_in_progress"/>
<field name="kanban_state">done</field>
<field name="description" type="html">
<p>Hi,<br/><br/>
I have purchased a "Drawer" from your store but the slides and the handle seem to have a defect.<br/>
Would it be possible for you to fix it?<br/><br/>
Regards,<br/>
Deco</p>
</field>
</record>
<record id="helpdesk_ticket_17" model="helpdesk.ticket">
<field name="name">Want to change the place of the dining area</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="user_id" ref="base.user_demo"/>
<field name="partner_id" ref="base.res_partner_3"/>
<field name="stage_id" ref="helpdesk.stage_in_progress"/>
<field name="description" type="html">
<p>Hello,<br/><br/>
I want to change the location of the dining area and would like your advice.<br/>
Hope to hear from you soon.<br/><br/>
Best,<br/>
Gemini Furniture</p>
</field>
</record>
<record id="helpdesk_ticket_18" model="helpdesk.ticket">
<field name="name">Received Product is damaged</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="user_id" ref="base.user_demo"/>
<field name="partner_id" ref="base.res_partner_12"/>
<field name="stage_id" ref="helpdesk.stage_solved"/>
<field name="create_date" eval="DateTime.now()- relativedelta(days=10)"/>
<field name="close_date" eval="DateTime.now()- relativedelta(days=5)"/>
<field name="description" type="html">
<p>Hi,<br/><br/>
I ordered a "Table Kit" from your store but the delivered product is damaged. I demand a refund as soon as possible.<br/><br/>
Regards,</p>
</field>
</record>
<record id="helpdesk_ticket_19" model="helpdesk.ticket">
<field name="name">Delivered wood panel is not what I ordered</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="user_id" ref="base.user_admin"/>
<field name="priority">2</field>
<field name="partner_id" ref="base.res_partner_1"/>
<field name="stage_id" ref="helpdesk.stage_solved"/>
<field name="create_date" eval="DateTime.now()- relativedelta(days=4)"/>
<field name="close_date" eval="DateTime.now()- relativedelta(days=2)"/>
<field name="description" type="html">
<p>Hello,<br/><br/>
I ordered a wood panel from your online store, but the delivered product is not what I had ordered.<br/><br/>
Could you please replace it with the right product?<br/>
Waiting for your response.<br/><br/>
Best,<br/>
Wood Corner</p>
</field>
</record>
<record id="helpdesk_ticket_20" model="helpdesk.ticket">
<field name="name">Table not delivered on time</field>
<field name="team_id" ref="helpdesk.helpdesk_team3"/>
<field name="user_id" ref="base.user_admin"/>
<field name="partner_id" ref="base.res_partner_12"/>
<field name="stage_id" ref="helpdesk.stage_in_progress"/>
<field name="priority">3</field>
<field name="tag_ids" eval="[Command.link(ref('helpdesk.tag_service'))]"/>
<field name="active" eval="False"/>
<field name="description" type="html">
<p>Hi,<br/><br/>
A few days ago, I bought a Four Persons Desk. but it's still not delivered?<br/>
Kindly do this as early as possible.<br/><br/>
Best,<br/>
Azure Interior</p>
</field>
</record>
<record id="helpdesk_ticket_21" model="helpdesk.ticket">
<field name="name">Where can I shop the same product?</field>
<field name="team_id" ref="helpdesk.helpdesk_team1"/>
<field name="priority">0</field>
<field name="partner_id" ref="base.res_partner_4"/>
<field name="active" eval="False"/>
</record>
<record id="helpdesk_ticket_22" model="helpdesk.ticket">
<field name="name">What's the best kitchen cabinet varnish for your home?</field>
<field name="team_id" ref="helpdesk.helpdesk_team1"/>
<field name="priority">1</field>
<field name="user_id" ref="base.user_admin"/>
<field name="partner_id" ref="base.res_partner_main1"/>
<field name="description" type="html">
<p>Hello,<br/><br/>
I would like to know What's the best kitchen cabinet varnish for your home?<br/><br/>
Thank you,<br/>
Chester Reed</p>
</field>
</record>
<!-- fail the sla status -->
<function model="helpdesk.sla.status" name="write">
<value model="helpdesk.sla.status" search="[('ticket_id', '=', ref('helpdesk_ticket_22'))]"/>
<value eval="{'deadline': DateTime.now() - relativedelta(days=1)}"/>
</function>
<record id="helpdesk_ticket_23" model="helpdesk.ticket">
<field name="name">What customization features are available for cabinet?</field>
<field name="team_id" ref="helpdesk.helpdesk_team1"/>
<field name="priority">1</field>
<field name="stage_id" ref="stage_in_progress"/>
<field name="user_id" ref="base.user_admin"/>
<field name="partner_id" ref="base.res_partner_main1"/>
<field name="description" type="html">
<p>Hello,<br/><br/>
I would like to know What customization features are available for cabinet?<br/><br/>
Thank you,<br/>
Chester Reed</p>
</field>
</record>
<!-- fail the sla status -->
<function model="helpdesk.sla.status" name="write">
<value model="helpdesk.sla.status" search="[('ticket_id', '=', ref('helpdesk_ticket_23')), ('status', '=', 'ongoing')]"/>
<value eval="{'deadline': DateTime.now() - relativedelta(days=4)}"/>
</function>
<record id="rating_ticket_1" model="rating.rating">
<field name="access_token">HELPDESK_1</field>
<field name="res_model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="rated_partner_id" ref="base.partner_demo"/>
<field name="partner_id" ref="base.res_partner_12"/>
<field name="res_id" ref="helpdesk.helpdesk_ticket_18"/>
</record>
<record id="rating_ticket_2" model="rating.rating">
<field name="access_token">HELPDESK_2</field>
<field name="res_model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="rated_partner_id" ref="base.partner_admin"/>
<field name="partner_id" ref="base.res_partner_1"/>
<field name="res_id" ref="helpdesk.helpdesk_ticket_19"/>
</record>
<record id="rating_ticket_3" model="rating.rating">
<field name="access_token">HELPDESK_3</field>
<field name="res_model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="rated_partner_id" ref="base.partner_admin"/>
<field name="partner_id" ref="base.res_partner_12"/>
<field name="res_id" ref="helpdesk.helpdesk_ticket_5"/>
</record>
<record id="rating_ticket_4" model="rating.rating">
<field name="access_token">HELPDESK_4</field>
<field name="res_model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="rated_partner_id" ref="base.partner_admin"/>
<field name="partner_id" ref="base.res_partner_2"/>
<field name="res_id" ref="helpdesk.helpdesk_ticket_10"/>
</record>
<record id="rating_ticket_5" model="rating.rating">
<field name="access_token">HELPDESK_5</field>
<field name="res_model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="rated_partner_id" ref="base.partner_demo"/>
<field name="partner_id" ref="base.res_partner_4"/>
<field name="res_id" ref="helpdesk.helpdesk_ticket_4"/>
</record>
<function model="helpdesk.ticket" name="rating_apply"
eval="([ref('helpdesk_ticket_18')], 3, 'HELPDESK_1', None, 'Good Service')"/>
<function model="helpdesk.ticket" name="rating_apply"
eval="([ref('helpdesk_ticket_19')], 5, 'HELPDESK_2', None, 'Awesome Service.\nLove to use your product')"/>
<function model="helpdesk.ticket" name="rating_apply"
eval="([ref('helpdesk_ticket_5')], 5, 'HELPDESK_3', None, 'Quick response with detailed information.')"/>
<function model="helpdesk.ticket" name="rating_apply"
eval="([ref('helpdesk_ticket_10')], 3, 'HELPDESK_4', None, 'Quick replacement. \n Love that you did the quick replacement and refund.')"/>
<function model="helpdesk.ticket" name="rating_apply"
eval="([ref('helpdesk_ticket_4')], 1, 'HELPDESK_5', None, 'Too late. \n dissatisfied answer.')"/>
<!-- Activities -->
<record id="helpdesk_activity_1" model="mail.activity">
<field name="res_id" ref="helpdesk.helpdesk_ticket_3"/>
<field name="res_model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="activity_type_id" ref="mail.mail_activity_data_todo"/>
<field name="date_deadline" eval="DateTime.today()"/>
<field name="summary">Provide warranty details</field>
<field name="create_uid" ref="base.user_admin"/>
<field name="user_id" ref="base.user_admin"/>
</record>
<record id="helpdesk_activity_2" model="mail.activity">
<field name="res_id" ref="helpdesk.helpdesk_ticket_12"/>
<field name="res_model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="activity_type_id" ref="mail.mail_activity_data_todo"/>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(days=2))"/>
<field name="summary">Provide the details about the spare part of chair</field>
<field name="create_uid" ref="base.user_admin"/>
<field name="user_id" ref="base.user_admin"/>
</record>
<record id="helpdesk_activity_3" model="mail.activity">
<field name="res_id" ref="helpdesk.helpdesk_ticket_11"/>
<field name="res_model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="activity_type_id" ref="mail.mail_activity_data_todo"/>
<field name="date_deadline" eval="(DateTime.today() - relativedelta(days=2))" />
<field name="summary">Give gesture of goodwill</field>
<field name="create_uid" ref="base.user_admin"/>
<field name="user_id" ref="base.user_admin"/>
</record>
<!-- Mail template Auto delete false for demo data -->
<record id="helpdesk.rating_ticket_request_email_template" model="mail.template">
<field name="auto_delete" eval="False"/>
</record>
</odoo>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="helpdesk_tour" model="web_tour.tour">
<field name="name">helpdesk_tour</field>
<field name="sequence">220</field>
<field name="rainbow_man_message"><![CDATA[
<center><strong><b>Good job!</b> You walked through all steps of this tour.</strong></center>
]]></field>
</record>
</odoo>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="ir_cron_auto_close_ticket" model="ir.cron">
<field name="name">Helpdesk Ticket: Automatically close the tickets</field>
<field name="model_id" ref="model_helpdesk_team"/>
<field name="state">code</field>
<field name="code">model._cron_auto_close_tickets()</field>
<field name="active" eval="False"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="nextcall" eval="(DateTime.now().replace(hour=1, minute=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
</odoo>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Sequences for helpdesk.ticket -->
<record id="seq_helpdesk_ticket" model="ir.sequence">
<field name="name">Helpdesk Ticket</field>
<field name="code">helpdesk.ticket</field>
<field name="padding">2</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<!-- Ticket related subtypes for messaging / Chatter -->
<record id="mt_ticket_new" model="mail.message.subtype">
<field name="name">Ticket Created</field>
<field name="sequence">0</field>
<field name="res_model">helpdesk.ticket</field>
<field name="default" eval="False"/>
<field name="hidden" eval="True"/>
<field name="description">Ticket created</field>
</record>
<record id="mt_ticket_rated" model="mail.message.subtype">
<field name="name">Ticket Rated</field>
<field name="sequence">20</field>
<field name="res_model">helpdesk.ticket</field>
<field name="default" eval="True"/>
<field name="internal" eval="True"/>
<field name="hidden" eval="False"/>
</record>
<record id="mt_ticket_stage" model="mail.message.subtype">
<field name="name">Stage Changed</field>
<field name="sequence">10</field>
<field name="res_model">helpdesk.ticket</field>
<field name="default" eval="False"/>
<field name="internal" eval="True"/>
<field name="description">Stage Changed</field>
</record>
<record id="mt_ticket_refund_status" model="mail.message.subtype">
<field name="name">Refund Status</field>
<field name="sequence">11</field>
<field name="res_model">helpdesk.ticket</field>
<field name="default" eval="False"/>
<field name="internal" eval="True"/>
<field name="description"></field>
</record>
<record id="mt_ticket_return_status" model="mail.message.subtype">
<field name="name">Return Status</field>
<field name="sequence">13</field>
<field name="res_model">helpdesk.ticket</field>
<field name="default" eval="False"/>
<field name="internal" eval="True"/>
<field name="description"></field>
</record>
<record id="mt_ticket_repair_status" model="mail.message.subtype">
<field name="name">Repair Status</field>
<field name="sequence">15</field>
<field name="res_model">helpdesk.ticket</field>
<field name="default" eval="False"/>
<field name="internal" eval="True"/>
<field name="description"></field>
</record>
<!-- Team related subtypes for messaging / Chatter -->
<record id="mt_team_ticket_new" model="mail.message.subtype">
<field name="name">Ticket Created</field>
<field name="sequence">0</field>
<field name="res_model">helpdesk.team</field>
<field name="default" eval="True"/>
<field name="parent_id" ref="mt_ticket_new"/>
<field name="relation_field">team_id</field>
</record>
<record id="mt_team_ticket_rated" model="mail.message.subtype">
<field name="name">Ticket Rated</field>
<field name="sequence">15</field>
<field name="res_model">helpdesk.team</field>
<field name="default" eval="True"/>
<field name="internal" eval="True"/>
<field name="parent_id" ref="mt_ticket_rated"/>
<field name="relation_field">team_id</field>
</record>
</data></odoo>

View File

@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record id="new_ticket_request_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Received</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">{{ object.name }}</field>
<field name="email_from">{{ (object.team_id.alias_email_from or object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ (object.partner_email if not object.sudo().partner_id.email or object.sudo().partner_id.email != object.partner_email else '') }}</field>
<field name="partner_to">{{ object.partner_id.id if object.sudo().partner_id.email and object.sudo().partner_id.email == object.partner_email else '' }}</field>
<field name="description">Send customers a confirmation email to notify them that their helpdesk ticket has been received and is currently being reviewed by the helpdesk team. Automatically send an email to customers when a ticket reaches a specific stage in a helpdesk team by setting this template on that stage.</field>
<field name="body_html" type="html">
<div>
Dear <t t-out="object.sudo().partner_id.name or object.sudo().partner_name or 'Madam/Sir'">Madam/Sir</t>,<br /><br />
Your request
<t t-if="hasattr(object.team_id, 'website_id') and object.get_portal_url()">
<a t-attf-href="{{ object.team_id.website_id.domain }}/my/ticket/{{ object.id }}/{{ object.access_token }}" t-out="object.name or ''">Table legs are unbalanced</a>
</t>
has been received and is being reviewed by our <t t-out="object.team_id.name or ''">VIP Support</t> team.<br/><br/>
The reference for your ticket is <strong><t t-out="object.ticket_ref or ''">15</t></strong>.<br /><br/>
To provide any additional information, simply reply to this email.<br/><br/>
<t t-if="object.team_id.show_knowledge_base">
Don't hesitate to visit our <a t-attf-href="{{ object.team_id.get_knowledge_base_url() }}">Help Center</a>. You might find the answer to your question.
<br/><br/>
</t>
<t t-if="object.team_id.allow_portal_ticket_closing">
Feel free to close your ticket if our help is no longer needed. Thank you for your collaboration.<br/><br/>
</t>
<div style="text-align: center; padding: 16px 0px 16px 0px;">
<t t-if="hasattr(object.team_id, 'website_id') and object.team_id.use_website_helpdesk_form">
<a style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 13px;" t-att-href="'%s%s' % (object.team_id.website_id.domain or '', object.get_portal_url())" target="_blank">View Ticket</a>
</t>
<t t-else="">
<a style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;" t-att-href="object.get_portal_url()" target="_blank">View Ticket</a>
</t>
<t t-if="hasattr(object.team_id, 'website_id') and object.team_id.allow_portal_ticket_closing">
<a style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;" t-att-href="'%s/my/ticket/close/%s/%s' % (object.team_id.website_id.domain or '', object.id, object.access_token)" target="_blank">Close Ticket</a>
</t>
<t t-elif="object.team_id.allow_portal_ticket_closing">
<a style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;" t-att-href="'/my/ticket/close/%s/%s' % (object.id, object.access_token)" target="_blank">Close Ticket</a>
</t>
<t t-if="object.team_id.use_website_helpdesk_forum or object.team_id.use_website_helpdesk_knowledge or object.team_id.use_website_helpdesk_slides">
<a style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;" t-att-href="object.team_id.feature_form_url" target="_blank">Visit Help Center</a>
</t><br/><br/>
</div>
Best regards,<br/><br/>
<t t-out="object.team_id.name or 'Helpdesk'">Helpdesk</t> Team
</div>
</field>
<field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="solved_ticket_request_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Closed</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">Ticket Closed - Reference {{ object.id if object.id else 15 }}</field>
<field name="email_from">{{ (object.team_id.alias_email_from or object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ (object.partner_email if not object.sudo().partner_id.email or object.sudo().partner_id.email != object.partner_email else '') }}</field>
<field name="partner_to">{{ object.partner_id.id if object.sudo().partner_id.email and object.sudo().partner_id.email == object.partner_email else '' }}</field>
<field name="description">Set this template on a project's stage to automate email when tasks reach stages</field>
<field name="body_html" type="html">
<div>
Dear <t t-out="object.sudo().partner_id.name or 'Madam/Sir'">Madam/Sir</t>,<br /><br />
We would like to inform you that we have closed your ticket (reference <t t-out="object.id or ''">15</t>).
We trust that the services provided have met your expectations and that you have found a satisfactory resolution to your issue.<br /><br />
However, if you have any further questions or comments, please do not hesitate to reply to this email to re-open your ticket.
Our team is always here to help you and we will be happy to assist you with any further concerns you may have.<br /><br />
Thank you for choosing our services and for your cooperation throughout this process. We truly value your business and appreciate the opportunity to serve you.<br /><br />
Kind regards,<br /><br />
<t t-out="object.team_id.name or 'Helpdesk'">Helpdesk</t> Team.
</div>
</field>
<field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="rating_ticket_request_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Rating Request</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">{{ object.company_id.name or object.user_id.company_id.name or 'Helpdesk' }}: Service Rating Request</field>
<field name="email_from">{{ (object.team_id.alias_email_from or object.company_id.email_formatted or object._rating_get_operator().email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ (object.partner_email if not object.sudo().partner_id.email or object.sudo().partner_id.email != object.partner_email else '') }}</field>
<field name="partner_to">{{ object.partner_id.id if object.sudo().partner_id.email and object.sudo().partner_id.email == object.partner_email else '' }}</field>
<field name="description">Enable "customer ratings" feature on the helpdesk team</field>
<field name="body_html" type="html">
<div>
<t t-set="access_token" t-value="object._rating_get_access_token()"/>
<t t-set="partner" t-value="object._rating_get_partner()"/>
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; margin:0;">
<tbody>
<tr><td valign="top" style="font-size: 14px;">
<t t-if="partner.name">
Hello <t t-out="partner.name or ''">Brandon Freeman</t>,<br/><br/>
</t>
<t t-else="">
Hello,<br/>
</t>
Please take a moment to rate our services related to the ticket "<strong t-out="object.name or ''">Table legs are unbalanced</strong>"
<t t-if="object._rating_get_operator().name">
assigned to <strong t-out="object._rating_get_operator().name or ''">Mitchell Admin</strong>.<br/>
</t>
<t t-else="">
.<br/><br/>
</t>
</td></tr>
<tr><td style="text-align: center;">
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; margin: 32px 0px 32px 0px; display: inline-table;">
<tr><td style="font-size: 14px; text-align:center;">
<strong>Tell us how you feel about our services</strong><br/>
<span style="text-color: #888888">(click on one of these smileys)</span>
</td></tr>
<tr><td style="font-size: 14px;">
<table style="width:100%;text-align:center;margin-top:2rem;">
<tr>
<td>
<a t-attf-href="/rate/{{ access_token }}/5" t-att-class="'pe-none' if object._rating_get_operator() else ''">
<img alt="Satisfied" src="/rating/static/src/img/rating_5.png" title="Satisfied"/>
</a>
</td>
<td>
<a t-attf-href="/rate/{{ access_token }}/3" t-att-class="'pe-none' if object._rating_get_operator() else ''">
<img alt="Okay" src="/rating/static/src/img/rating_3.png" title="Okay"/>
</a>
</td>
<td>
<a t-attf-href="/rate/{{ access_token }}/1" t-att-class="'pe-none' if object._rating_get_operator() else ''">
<img alt="Dissatisfied" src="/rating/static/src/img/rating_1.png" title="Dissatisfied"/>
</a>
</td>
</tr>
</table>
</td></tr>
</table>
</td></tr>
<tr><td valign="top" style="font-size: 14px;">
We appreciate your feedback. It helps us improve continuously.
<br/><br/><span style="margin: 0px 0px 0px 0px; font-size: 12px; opacity: 0.5; color: #454748;">This customer survey has been sent because your ticket has been moved to the stage <b t-out="object.stage_id.name or ''">In Progress</b>.</span>
</td></tr>
</tbody>
</table>
</div>
</field>
<field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- You have been invited to follow the ticket -->
<template id="ticket_invitation_follower">
<div>
Hello <t t-out="partner_name"/>,
<br/><br/>
<span style="margin-top: 8px;">You have been invited to follow Ticket Document : <t t-out="object.display_name"/>.</span>
</div>
</template>
</data></odoo>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
def migrate(cr, version):
cr.execute("""
UPDATE helpdesk_sla
SET time_days = COALESCE(time_days, 0),
time_hours = COALESCE(time_hours, 0),
time_minutes = COALESCE(time_minutes, 0)
""")

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import digest
from . import ir_module
from . import ir_ui_menu
from . import helpdesk_team
from . import helpdesk_stage
from . import helpdesk_sla_status
from . import helpdesk_sla
from . import helpdesk_ticket
from . import helpdesk_tag
from . import mail_message
from . import res_users
from . import res_partner
from . import res_company

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import AccessError
class Digest(models.Model):
_inherit = 'digest.digest'
kpi_helpdesk_tickets_closed = fields.Boolean('Tickets Closed')
kpi_helpdesk_tickets_closed_value = fields.Integer(compute='_compute_kpi_helpdesk_tickets_closed_value', export_string_translation=False)
def _compute_kpi_helpdesk_tickets_closed_value(self):
if not self.env.user.has_group('helpdesk.group_helpdesk_user'):
raise AccessError(_("Do not have access, skip this data for user's digest email"))
self._calculate_company_based_kpi(
'helpdesk.ticket',
'kpi_helpdesk_tickets_closed_value',
date_field='close_date',
)
def _compute_kpis_actions(self, company, user):
res = super(Digest, self)._compute_kpis_actions(company, user)
res['kpi_helpdesk_tickets_closed'] = 'helpdesk.helpdesk_team_dashboard_action_main&menu_id=%s' % self.env.ref('helpdesk.menu_helpdesk_root').id
return res

View File

@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.addons.helpdesk.models.helpdesk_ticket import TICKET_PRIORITY
class HelpdeskSLA(models.Model):
_name = "helpdesk.sla"
_order = "name"
_description = "Helpdesk SLA Policies"
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
if 'team_id' in fields_list or 'stage_id' in fields_list:
default_team_id = self._context.get('default_team_id')
team = self.env['helpdesk.team'].browse(default_team_id)
if not default_team_id:
defaults['team_id'] = team.id
team = self.env['helpdesk.team'].search([], limit=1)
stages = team.stage_ids.filtered(lambda x: x.fold)
defaults['stage_id'] = stages and stages.ids[0] or team.stage_ids and team.stage_ids.ids[-1]
return defaults
name = fields.Char(required=True, index=True, translate=True)
description = fields.Html('SLA Policy Description', translate=True)
active = fields.Boolean('Active', default=True)
team_id = fields.Many2one('helpdesk.team', 'Helpdesk Team', required=True)
tag_ids = fields.Many2many(
'helpdesk.tag', string='Tags')
stage_id = fields.Many2one(
'helpdesk.stage', 'Target Stage',
help='Minimum stage a ticket needs to reach in order to satisfy this SLA.')
exclude_stage_ids = fields.Many2many(
'helpdesk.stage', string='Excluding Stages', copy=True,
domain="[('id', '!=', stage_id.id)]",
help="The time spent in these stages won't be taken into account in the calculation of the SLA.")
priority = fields.Selection(
TICKET_PRIORITY, string='Priority',
default='0', required=True)
partner_ids = fields.Many2many(
'res.partner', string="Customers")
company_id = fields.Many2one('res.company', 'Company', related='team_id.company_id', readonly=True, store=True)
time = fields.Float('Within', default=0, required=True,
help='Maximum number of working hours a ticket should take to reach the target stage, starting from the date it was created.')
ticket_count = fields.Integer(compute='_compute_ticket_count')
def _compute_ticket_count(self):
res = self.env['helpdesk.ticket']._read_group(
[('sla_ids', 'in', self.ids), ('stage_id.fold', '=', False)],
['sla_ids'], ['__count'])
sla_data = {sla.id: count for sla, count in res}
for sla in self:
sla.ticket_count = sla_data.get(sla.id, 0)
@api.depends('team_id')
@api.depends_context('with_team_name')
def _compute_display_name(self):
if not self._context.get('with_team_name'):
return super()._compute_display_name()
for sla in self:
sla.display_name = f'{sla.name} - {sla.team_id.name}'
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", sla.name)) for sla, vals in zip(self, vals_list)]
def action_open_helpdesk_ticket(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("helpdesk.helpdesk_ticket_action_main_tree")
action.update({
'domain': [('sla_ids', 'in', self.ids)],
'context': {
'search_default_is_open': True,
'create': False,
},
})
return action

View File

@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import math
from odoo import fields, models, api
from odoo.osv import expression
class HelpdeskSLAStatus(models.Model):
_name = 'helpdesk.sla.status'
_description = "Ticket SLA Status"
_table = 'helpdesk_sla_status'
_order = 'deadline ASC, sla_stage_id'
_rec_name = 'sla_id'
ticket_id = fields.Many2one('helpdesk.ticket', string='Ticket', required=True, ondelete='cascade', index=True)
sla_id = fields.Many2one('helpdesk.sla', required=True, ondelete='cascade')
sla_stage_id = fields.Many2one('helpdesk.stage', related='sla_id.stage_id', store=True, export_string_translation=False) # need to be stored for the search in `_sla_reach`
deadline = fields.Datetime("Deadline", compute='_compute_deadline', compute_sudo=True, store=True)
reached_datetime = fields.Datetime("Reached Date", help="Datetime at which the SLA stage was reached for the first time")
status = fields.Selection([('failed', 'Failed'), ('reached', 'Reached'), ('ongoing', 'Ongoing')], string="Status", compute='_compute_status', compute_sudo=True, search='_search_status')
color = fields.Integer("Color Index", compute='_compute_color')
exceeded_hours = fields.Float("Exceeded Working Hours", compute='_compute_exceeded_hours', compute_sudo=True, store=True, help="Working hours exceeded for reached SLAs compared with deadline. Positive number means the SLA was reached after the deadline.")
@api.depends('ticket_id.create_date', 'sla_id', 'ticket_id.stage_id')
def _compute_deadline(self):
for status in self:
if (status.deadline and status.reached_datetime) or (status.deadline and not status.sla_id.exclude_stage_ids) or (status.status == 'failed'):
continue
deadline = status.ticket_id.create_date
working_calendar = status.ticket_id.team_id.resource_calendar_id
if not working_calendar:
# Normally, having a working_calendar is mandatory
status.deadline = deadline
continue
if status.sla_id.exclude_stage_ids:
if status.ticket_id.stage_id in status.sla_id.exclude_stage_ids:
# We are in the freezed time stage: No deadline
status.deadline = False
continue
avg_hour = working_calendar.hours_per_day or 8 # default to 8 working hours/day
time_days = math.floor(status.sla_id.time / avg_hour)
if time_days > 0:
deadline = working_calendar.plan_days(time_days + 1, deadline, compute_leaves=True)
# We should also depend on ticket creation time, otherwise for 1 day SLA, all tickets
# created on monday will have their deadline filled with tuesday 8:00
create_dt = working_calendar.plan_hours(0, status.ticket_id.create_date)
deadline = deadline and deadline.replace(hour=create_dt.hour, minute=create_dt.minute, second=create_dt.second, microsecond=create_dt.microsecond)
sla_hours = status.sla_id.time % avg_hour
if status.sla_id.exclude_stage_ids:
sla_hours += status._get_freezed_hours(working_calendar)
# Except if ticket creation time is later than the end time of the working day
deadline_for_working_cal = working_calendar.plan_hours(0, deadline)
if deadline_for_working_cal and deadline.day < deadline_for_working_cal.day and time_days > 0:
deadline = deadline.replace(hour=0, minute=0, second=0, microsecond=0)
# We should execute the function plan_hours in any case because, in a 1 day SLA environment,
# if I create a ticket knowing that I'm not working the day after at the same time, ticket
# deadline will be set at time I don't work (ticket creation time might not be in working calendar).
status.deadline = deadline and working_calendar.plan_hours(sla_hours, deadline, compute_leaves=True)
@api.depends('deadline', 'reached_datetime')
def _compute_status(self):
for status in self:
if status.reached_datetime and status.deadline: # if reached_datetime, SLA is finished: either failed or succeeded
status.status = 'reached' if status.reached_datetime < status.deadline else 'failed'
else: # if not finished, deadline should be compared to now()
status.status = 'ongoing' if not status.deadline or status.deadline > fields.Datetime.now() else 'failed'
@api.model
def _search_status(self, operator, value):
""" Supported operators: '=', 'in' and their negative form. """
# constants
datetime_now = fields.Datetime.now()
positive_domain = {
'failed': ['|', '&', ('reached_datetime', '=', True), ('deadline', '<=', 'reached_datetime'), '&', ('reached_datetime', '=', False), ('deadline', '<=', fields.Datetime.to_string(datetime_now))],
'reached': ['&', ('reached_datetime', '=', True), ('reached_datetime', '<', 'deadline')],
'ongoing': ['|', ('deadline', '=', False), '&', ('reached_datetime', '=', False), ('deadline', '>', fields.Datetime.to_string(datetime_now))]
}
# in/not in case: we treat value as a list of selection item
if not isinstance(value, list):
value = [value]
# transform domains
if operator in expression.NEGATIVE_TERM_OPERATORS:
# "('status', 'not in', [A, B])" tranformed into "('status', '=', C) OR ('status', '=', D)"
domains_to_keep = [dom for key, dom in positive_domain if key not in value]
return expression.OR(domains_to_keep)
else:
return expression.OR(positive_domain[value_item] for value_item in value)
@api.depends('status')
def _compute_color(self):
for status in self:
if status.status == 'failed':
status.color = 1
elif status.status == 'reached':
status.color = 10
else:
status.color = 0
@api.depends('deadline', 'reached_datetime')
def _compute_exceeded_hours(self):
for status in self:
if status.deadline and status.ticket_id.team_id.resource_calendar_id:
reached_datetime = status.reached_datetime or fields.Datetime.now()
if reached_datetime <= status.deadline:
start_dt = reached_datetime
end_dt = status.deadline
factor = -1
else:
start_dt = status.deadline
end_dt = reached_datetime
factor = 1
duration_data = status.ticket_id.team_id.resource_calendar_id.get_work_duration_data(start_dt, end_dt, compute_leaves=True)
status.exceeded_hours = duration_data['hours'] * factor
else:
status.exceeded_hours = False
def _get_freezed_hours(self, working_calendar):
self.ensure_one()
hours_freezed = 0
field_stage = self.env['ir.model.fields']._get(self.ticket_id._name, "stage_id")
freeze_stages = self.sla_id.exclude_stage_ids.ids
tracking_lines = self.ticket_id.message_ids.tracking_value_ids.filtered(lambda tv: tv.field_id == field_stage).sorted(key="create_date")
if not tracking_lines:
return 0
old_time = self.ticket_id.create_date
for tracking_line in tracking_lines:
if tracking_line.old_value_integer in freeze_stages:
# We must use get_work_hours_count to compute real waiting hours (as the deadline computation is also based on calendar)
hours_freezed += working_calendar.get_work_hours_count(old_time, tracking_line.create_date)
old_time = tracking_line.create_date
if tracking_lines[-1].new_value_integer in freeze_stages:
# the last tracking line is not yet created
hours_freezed += working_calendar.get_work_hours_count(old_time, fields.Datetime.now())
return hours_freezed

View File

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.tools.misc import unique
class HelpdeskStage(models.Model):
_name = 'helpdesk.stage'
_description = 'Helpdesk Stage'
_order = 'sequence, id'
def _default_team_ids(self):
team_id = self.env.context.get('default_team_id')
if team_id:
return [(4, team_id, 0)]
active = fields.Boolean(default=True)
name = fields.Char(required=True, translate=True)
description = fields.Text(translate=True)
sequence = fields.Integer(export_string_translation=False, default=10)
fold = fields.Boolean(
'Folded in Kanban',
help='Tickets in a folded stage are considered as closed.')
team_ids = fields.Many2many(
'helpdesk.team', relation='team_stage_rel', string='Helpdesk Teams',
default=_default_team_ids, required=True)
template_id = fields.Many2one(
'mail.template', 'Email Template',
domain="[('model', '=', 'helpdesk.ticket')]",
help="Email automatically sent to the customer when the ticket reaches this stage.\n"
"By default, the email will be sent from the email alias of the helpdesk team.\n"
"Otherwise it will be sent from the company's email address, or from the catchall (as defined in the System Parameters).")
legend_blocked = fields.Char(
'Red Kanban Label', default=lambda s: s.env._('Blocked'), translate=True, required=True)
legend_done = fields.Char(
'Green Kanban Label', default=lambda s: s.env._('Ready'), translate=True, required=True)
legend_normal = fields.Char(
'Grey Kanban Label', default=lambda s: s.env._('In Progress'), translate=True, required=True)
ticket_count = fields.Integer(compute='_compute_ticket_count', export_string_translation=False)
def _compute_ticket_count(self):
res = self.env['helpdesk.ticket']._read_group(
[('stage_id', 'in', self.ids)],
['stage_id'], ['__count'])
stage_data = {stage.id: count for stage, count in res}
for stage in self:
stage.ticket_count = stage_data.get(stage.id, 0)
def write(self, vals):
if 'active' in vals and not vals['active']:
self.env['helpdesk.ticket'].search([('stage_id', 'in', self.ids)]).write({'active': False})
return super(HelpdeskStage, self).write(vals)
def toggle_active(self):
res = super().toggle_active()
stage_active = self.filtered('active')
if stage_active and sum(stage_active.with_context(active_test=False).mapped('ticket_count')) > 0:
wizard = self.env['helpdesk.stage.delete.wizard'].create({
'stage_ids': stage_active.ids,
})
return {
'name': _('Unarchive Tickets'),
'view_mode': 'form',
'res_model': 'helpdesk.stage.delete.wizard',
'views': [(self.env.ref('helpdesk.view_helpdesk_stage_unarchive_wizard').id, 'form')],
'type': 'ir.actions.act_window',
'res_id': wizard.id,
'target': 'new',
}
return res
def action_unlink_wizard(self, stage_view=False):
self = self.with_context(active_test=False)
# retrieves all the teams with a least 1 ticket in that stage
# a ticket can be in a stage even if the team is not assigned to the stage
readgroup = self.with_context(active_test=False).env['helpdesk.ticket']._read_group(
[('stage_id', 'in', self.ids), ('team_id', '!=', False)],
['team_id'])
team_ids = list(unique([team.id for [team] in readgroup] + self.team_ids.ids))
wizard = self.env['helpdesk.stage.delete.wizard'].create({
'team_ids': team_ids,
'stage_ids': self.ids
})
context = dict(self.env.context)
context['stage_view'] = stage_view
return {
'name': _('Delete Stage'),
'view_mode': 'form',
'res_model': 'helpdesk.stage.delete.wizard',
'views': [(self.env.ref('helpdesk.view_helpdesk_stage_delete_wizard').id, 'form')],
'type': 'ir.actions.act_window',
'res_id': wizard.id,
'target': 'new',
'context': context,
}
def action_open_helpdesk_ticket(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("helpdesk.helpdesk_ticket_action_main_tree")
action.update({
'domain': [('stage_id', 'in', self.ids)],
'context': {
'default_stage_id': self.id,
},
})
return action

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from random import randint
from odoo import api, fields, models
class HelpdeskTag(models.Model):
_name = 'helpdesk.tag'
_description = 'Helpdesk Tags'
_order = 'name'
def _get_default_color(self):
return randint(1, 11)
name = fields.Char(required=True, translate=True)
color = fields.Integer('Color', default=_get_default_color)
_sql_constraints = [
('name_uniq', 'unique (name)', "A tag with the same name already exists."),
]
@api.model
def name_create(self, name):
existing_tag = self.search([('name', '=ilike', name.strip())], limit=1)
if existing_tag:
return existing_tag.id, existing_tag.display_name
return super().name_create(name)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,913 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
from dateutil.relativedelta import relativedelta
from odoo import api, Command, fields, models, tools, _
from odoo.exceptions import AccessError
from odoo.osv import expression
from odoo.addons.web.controllers.utils import clean_action
TICKET_PRIORITY = [
('0', 'Low priority'),
('1', 'Medium priority'),
('2', 'High priority'),
('3', 'Urgent'),
]
class HelpdeskTicket(models.Model):
_name = 'helpdesk.ticket'
_description = 'Helpdesk Ticket'
_order = 'priority desc, id desc'
_primary_email = 'partner_email'
_inherit = [
'portal.mixin',
'mail.thread.cc',
'utm.mixin',
'rating.mixin',
'mail.activity.mixin',
'mail.tracking.duration.mixin',
]
_track_duration_field = 'stage_id'
@api.model
def default_get(self, fields):
result = super(HelpdeskTicket, self).default_get(fields)
if result.get('team_id') and fields:
team = self.env['helpdesk.team'].browse(result['team_id'])
if 'user_id' in fields and 'user_id' not in result: # if no user given, deduce it from the team
result['user_id'] = team._determine_user_to_assign()[team.id].id
if 'stage_id' in fields and 'stage_id' not in result: # if no stage given, deduce it from the team
result['stage_id'] = team._determine_stage()[team.id].id
return result
def _default_team_id(self):
team_id = self.env['helpdesk.team'].search([('member_ids', 'in', self.env.uid)], limit=1).id
if not team_id:
team_id = self.env['helpdesk.team'].search([], limit=1).id
return team_id
@api.model
def _read_group_stage_ids(self, stages, domain):
# write the domain
# - ('id', 'in', stages.ids): add columns that should be present
# - OR ('team_ids', '=', team_id) if team_id: add team columns
search_domain = [('id', 'in', stages.ids)]
if self.env.context.get('default_team_id'):
search_domain = ['|', ('team_ids', 'in', self.env.context['default_team_id'])] + search_domain
return stages.search(search_domain)
name = fields.Char(string='Subject', required=True, index=True, tracking=True)
team_id = fields.Many2one('helpdesk.team', string='Helpdesk Team', default=_default_team_id, index=True, tracking=True)
use_sla = fields.Boolean(related='team_id.use_sla')
team_privacy_visibility = fields.Selection(related='team_id.privacy_visibility', export_string_translation=False)
description = fields.Html(sanitize_attributes=False)
active = fields.Boolean(default=True)
tag_ids = fields.Many2many('helpdesk.tag', string='Tags')
company_id = fields.Many2one(related='team_id.company_id', string='Company', store=True, readonly=True)
color = fields.Integer(string='Color Index')
kanban_state = fields.Selection([
('normal', 'In progress'),
('done', 'Ready'),
('blocked', 'Blocked')], string='Kanban State',
copy=False, default='normal', required=True)
kanban_state_label = fields.Char(compute='_compute_kanban_state_label', string='Kanban State Label', tracking=True)
legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True, related_sudo=False)
legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True, related_sudo=False)
legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True, related_sudo=False)
domain_user_ids = fields.Many2many('res.users', compute='_compute_domain_user_ids', export_string_translation=False)
user_id = fields.Many2one(
'res.users', string='Assigned to', compute='_compute_user_and_stage_ids', store=True,
readonly=False, tracking=True,
domain=lambda self: [('groups_id', 'in', self.env.ref('helpdesk.group_helpdesk_user').id)])
properties = fields.Properties(
'Properties', definition='team_id.ticket_properties',
copy=True)
partner_id = fields.Many2one('res.partner', string='Customer', tracking=True, index=True)
partner_ticket_ids = fields.Many2many('helpdesk.ticket', compute='_compute_partner_ticket_count', string="Partner Tickets")
partner_ticket_count = fields.Integer('Number of other tickets from the same partner', compute='_compute_partner_ticket_count')
partner_open_ticket_count = fields.Integer('Number of other open tickets from the same partner', compute='_compute_partner_ticket_count')
# Used to submit tickets from a contact form
partner_name = fields.Char(string='Customer Name', compute='_compute_partner_name', store=True, readonly=False)
partner_email = fields.Char(string='Customer Email', compute='_compute_partner_email', inverse="_inverse_partner_email", store=True, readonly=False)
partner_phone = fields.Char(string='Customer Phone', compute='_compute_partner_phone', inverse="_inverse_partner_phone", store=True, readonly=False)
commercial_partner_id = fields.Many2one(related="partner_id.commercial_partner_id")
closed_by_partner = fields.Boolean('Closed by Partner', readonly=True)
priority = fields.Selection(TICKET_PRIORITY, string='Priority', default='0', tracking=True)
stage_id = fields.Many2one(
'helpdesk.stage', string='Stage', compute='_compute_user_and_stage_ids', store=True,
readonly=False, ondelete='restrict', tracking=1, group_expand='_read_group_stage_ids',
copy=False, index=True, domain="[('team_ids', '=', team_id)]")
fold = fields.Boolean(related="stage_id.fold", export_string_translation=False)
date_last_stage_update = fields.Datetime("Last Stage Update", copy=False, readonly=True)
ticket_ref = fields.Char(string='Ticket IDs Sequence', copy=False, readonly=True, index=True)
# next 4 fields are computed in write (or create)
assign_date = fields.Datetime("First assignment date")
assign_hours = fields.Float("Time to first assignment (hours)", compute='_compute_assign_hours', store=True, aggregator="avg")
close_date = fields.Datetime("Close date", copy=False)
close_hours = fields.Float("Time to close (hours)", compute='_compute_close_hours', store=True, aggregator="avg")
open_hours = fields.Integer("Open Time (hours)", compute='_compute_open_hours', search='_search_open_hours', aggregator="avg")
# SLA relative
sla_ids = fields.Many2many('helpdesk.sla', 'helpdesk_sla_status', 'ticket_id', 'sla_id', string="SLAs", copy=False)
sla_status_ids = fields.One2many('helpdesk.sla.status', 'ticket_id', string="SLA Status")
sla_reached_late = fields.Boolean("Has SLA reached late", compute='_compute_sla_reached_late', compute_sudo=True, store=True)
sla_reached = fields.Boolean("Has SLA reached", compute='_compute_sla_reached', compute_sudo=True, store=True)
sla_deadline = fields.Datetime("SLA Deadline", compute='_compute_sla_deadline', compute_sudo=True, store=True)
sla_deadline_hours = fields.Float("Working Hours until SLA Deadline", compute='_compute_sla_deadline', compute_sudo=True, store=True, aggregator="avg")
sla_fail = fields.Boolean("Failed SLA Policy", compute='_compute_sla_fail', search='_search_sla_fail')
sla_success = fields.Boolean("Success SLA Policy", compute='_compute_sla_success', search='_search_sla_success')
use_credit_notes = fields.Boolean(related='team_id.use_credit_notes', export_string_translation=False)
use_coupons = fields.Boolean(related='team_id.use_coupons', string='Use Coupons')
use_product_returns = fields.Boolean(related='team_id.use_product_returns', export_string_translation=False)
use_product_repairs = fields.Boolean(related='team_id.use_product_repairs', export_string_translation=False)
use_rating = fields.Boolean(related='team_id.use_rating', export_string_translation=False)
is_partner_email_update = fields.Boolean(compute='_compute_is_partner_email_update', export_string_translation=False)
is_partner_phone_update = fields.Boolean(compute='_compute_is_partner_phone_update', export_string_translation=False)
# customer portal: include comment and (incoming/outgoing) emails in communication history
website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment', 'email_outgoing'])], export_string_translation=False)
first_response_hours = fields.Float("Hours to First Response", aggregator="avg")
avg_response_hours = fields.Float("Average Hours to Respond", aggregator="avg")
oldest_unanswered_customer_message_date = fields.Datetime("Oldest Unanswered Customer Message Date", export_string_translation=False)
answered_customer_message_count = fields.Integer('# Exchanges', aggregator="avg")
total_response_hours = fields.Float("Total Exchange Time in Hours", aggregator="avg")
display_extra_info = fields.Boolean(compute="_compute_display_extra_info", export_string_translation=False)
@api.depends('stage_id', 'kanban_state')
def _compute_kanban_state_label(self):
for ticket in self:
if ticket.kanban_state == 'normal':
ticket.kanban_state_label = ticket.legend_normal
elif ticket.kanban_state == 'blocked':
ticket.kanban_state_label = ticket.legend_blocked
else:
ticket.kanban_state_label = ticket.legend_done
@api.depends('team_id')
def _compute_domain_user_ids(self):
user_ids = self.env.ref('helpdesk.group_helpdesk_user').users.ids
for ticket in self:
ticket_user_ids = []
ticket_sudo = ticket.sudo()
if ticket_sudo.team_id and ticket_sudo.team_id.privacy_visibility == 'invited_internal':
ticket_user_ids = ticket_sudo.team_id.message_partner_ids.user_ids.ids
ticket.domain_user_ids = [Command.set(user_ids + ticket_user_ids)]
def _compute_access_url(self):
super(HelpdeskTicket, self)._compute_access_url()
for ticket in self:
ticket.access_url = '/my/ticket/%s' % ticket.id
@api.depends('sla_status_ids.deadline', 'sla_status_ids.reached_datetime')
def _compute_sla_reached_late(self):
""" Required to do it in SQL since we need to compare 2 columns value """
mapping = {}
if self.ids:
self.env.cr.execute("""
SELECT ticket_id, COUNT(id) AS reached_late_count
FROM helpdesk_sla_status
WHERE ticket_id IN %s AND (deadline < reached_datetime OR (deadline < %s AND reached_datetime IS NULL))
GROUP BY ticket_id
""", (tuple(self.ids), fields.Datetime.now()))
mapping = dict(self.env.cr.fetchall())
for ticket in self:
ticket.sla_reached_late = mapping.get(ticket.id, 0) > 0
@api.depends('sla_status_ids.deadline', 'sla_status_ids.reached_datetime')
def _compute_sla_reached(self):
sla_status_read_group = self.env['helpdesk.sla.status']._read_group(
[('exceeded_hours', '<', 0), ('ticket_id', 'in', self.ids)],
['ticket_id'],
)
sla_status_ids_per_ticket = {ticket.id for [ticket] in sla_status_read_group}
for ticket in self:
ticket.sla_reached = ticket.id in sla_status_ids_per_ticket
@api.depends('sla_status_ids.deadline', 'sla_status_ids.reached_datetime')
def _compute_sla_deadline(self):
""" Keep the deadline for the last stage (closed one), so a closed ticket can have a status failed.
Note: a ticket in a closed stage will probably have no deadline
"""
now = fields.Datetime.now()
for ticket in self:
# the current team is invalid, no need to compute new values since the transaction will be rolled back anyway.
if not ticket.team_id:
continue
min_deadline = False
for status in ticket.sla_status_ids:
if status.reached_datetime or not status.deadline:
continue
if not min_deadline or status.deadline < min_deadline:
min_deadline = status.deadline
ticket.update({
'sla_deadline': min_deadline,
'sla_deadline_hours': ticket.team_id.resource_calendar_id.get_work_duration_data\
(now, min_deadline, compute_leaves=True)['hours'] if min_deadline else 0.0,
})
@api.depends('sla_deadline', 'sla_reached_late')
def _compute_sla_fail(self):
now = fields.Datetime.now()
for ticket in self:
if ticket.sla_deadline:
ticket.sla_fail = (ticket.sla_deadline < now) or ticket.sla_reached_late
else:
ticket.sla_fail = ticket.sla_reached_late
@api.depends('partner_email', 'partner_id')
def _compute_is_partner_email_update(self):
for ticket in self:
ticket.is_partner_email_update = ticket._get_partner_email_update()
@api.depends('partner_phone', 'partner_id')
def _compute_is_partner_phone_update(self):
for ticket in self:
ticket.is_partner_phone_update = ticket._get_partner_phone_update()
@api.model
def _search_sla_fail(self, operator, value):
datetime_now = fields.Datetime.now()
if (value and operator in expression.NEGATIVE_TERM_OPERATORS) or (not value and operator not in expression.NEGATIVE_TERM_OPERATORS): # is not failed
return ['&', ('sla_reached_late', '=', False), '|', ('sla_deadline', '=', False), ('sla_deadline', '>=', datetime_now)]
return ['|', ('sla_reached_late', '=', True), ('sla_deadline', '<', datetime_now)] # is failed
@api.depends('sla_deadline', 'sla_reached_late')
def _compute_sla_success(self):
now = fields.Datetime.now()
for ticket in self:
ticket.sla_success = (ticket.sla_deadline and ticket.sla_deadline > now)
@api.model
def _search_sla_success(self, operator, value):
datetime_now = fields.Datetime.now()
if (value and operator in expression.NEGATIVE_TERM_OPERATORS) or (not value and operator not in expression.NEGATIVE_TERM_OPERATORS): # is failed
return [('sla_status_ids.reached_datetime', '>', datetime_now), ('sla_reached_late', '!=', False), '|', ('sla_deadline', '!=', False), ('sla_deadline', '<', datetime_now)]
return [('sla_status_ids.reached_datetime', '<', datetime_now), ('sla_reached', '=', True), ('sla_reached_late', '=', False), '|', ('sla_deadline', '=', False), ('sla_deadline', '>=', datetime_now)] # is success
@api.depends('team_id')
def _compute_user_and_stage_ids(self):
for ticket in self.filtered(lambda ticket: ticket.team_id):
if not ticket.user_id:
ticket.user_id = ticket.team_id._determine_user_to_assign()[ticket.team_id.id]
if not ticket.stage_id or ticket.stage_id not in ticket.team_id.stage_ids:
ticket.stage_id = ticket.team_id._determine_stage()[ticket.team_id.id]
@api.depends('partner_id')
def _compute_partner_name(self):
for ticket in self:
if ticket.partner_id:
ticket.partner_name = ticket.partner_id.name
@api.depends('partner_id.email')
def _compute_partner_email(self):
for ticket in self:
if ticket.partner_id:
ticket.partner_email = ticket.partner_id.email
def _inverse_partner_email(self):
for ticket in self:
if ticket._get_partner_email_update():
ticket.partner_id.email = ticket.partner_email
@api.depends('partner_id.phone')
def _compute_partner_phone(self):
for ticket in self:
if ticket.partner_id:
ticket.partner_phone = ticket.partner_id.phone
def _inverse_partner_phone(self):
for ticket in self:
if (ticket._get_partner_phone_update() or not ticket.partner_id.phone) and ticket.partner_phone:
ticket = ticket.sudo()
ticket.partner_id.phone = ticket.partner_phone
@api.depends('partner_id', 'partner_email', 'partner_phone')
def _compute_partner_ticket_count(self):
for ticket in self:
partner_tickets = self.search([("partner_id", "child_of", ticket.partner_id.commercial_partner_id.id)]) if ticket.partner_id else ticket
ticket.partner_ticket_ids = partner_tickets
partner_tickets = partner_tickets - ticket._origin
ticket.partner_ticket_count = len(partner_tickets) if partner_tickets else 0
partner_tickets.fetch(['stage_id']) # prevent over-fetching fields, leading to potential out-of-memory error
open_ticket = partner_tickets.filtered(lambda ticket: not ticket.stage_id.fold)
ticket.partner_open_ticket_count = len(open_ticket)
@api.depends('assign_date')
def _compute_assign_hours(self):
for ticket in self:
create_date = fields.Datetime.from_string(ticket.create_date)
if create_date and ticket.assign_date and ticket.team_id.resource_calendar_id:
duration_data = ticket.team_id.resource_calendar_id.get_work_duration_data(create_date, fields.Datetime.from_string(ticket.assign_date), compute_leaves=True)
ticket.assign_hours = duration_data['hours']
else:
ticket.assign_hours = False
@api.depends('create_date', 'close_date')
def _compute_close_hours(self):
for ticket in self:
create_date = fields.Datetime.from_string(ticket.create_date)
if create_date and ticket.close_date and ticket.team_id:
duration_data = ticket.team_id.resource_calendar_id.get_work_duration_data(create_date, fields.Datetime.from_string(ticket.close_date), compute_leaves=True)
ticket.close_hours = duration_data['hours']
else:
ticket.close_hours = False
@api.depends('close_hours')
def _compute_open_hours(self):
for ticket in self:
if ticket.create_date: # fix from https://github.com/odoo/enterprise/commit/928fbd1a16e9837190e9c172fa50828fae2a44f7
if ticket.close_date:
time_difference = ticket.close_date - fields.Datetime.from_string(ticket.create_date)
else:
time_difference = fields.Datetime.now() - fields.Datetime.from_string(ticket.create_date)
ticket.open_hours = (time_difference.seconds) / 3600 + time_difference.days * 24
else:
ticket.open_hours = 0
def _compute_display_extra_info(self):
self.display_extra_info = self.env.user.has_group('base.group_multi_company')
@api.model
def _search_open_hours(self, operator, value):
dt = fields.Datetime.now() - relativedelta(hours=value)
d1, d2 = False, False
if operator in ['<', '<=', '>', '>=']:
d1 = ['&', ('close_date', '=', False), ('create_date', expression.TERM_OPERATORS_NEGATION[operator], dt)]
d2 = ['&', ('close_date', '!=', False), ('close_hours', operator, value)]
elif operator in ['=', '!=']:
subdomain = ['&', ('create_date', '>=', dt.replace(minute=0, second=0, microsecond=0)), ('create_date', '<=', dt.replace(minute=59, second=59, microsecond=99))]
if operator in expression.NEGATIVE_TERM_OPERATORS:
subdomain = expression.distribute_not(subdomain)
d1 = expression.AND([[('close_date', '=', False)], subdomain])
d2 = ['&', ('close_date', '!=', False), ('close_hours', operator, value)]
return expression.OR([d1, d2])
def _get_partner_email_update(self):
self.ensure_one()
if self.partner_id.email and self.partner_email and self.partner_email != self.partner_id.email:
ticket_email_normalized = tools.email_normalize(self.partner_email) or self.partner_email or False
partner_email_normalized = tools.email_normalize(self.partner_id.email) or self.partner_id.email or False
return ticket_email_normalized != partner_email_normalized
return False
def _get_partner_phone_update(self):
self.ensure_one()
if self.partner_id.phone and self.partner_phone and self.partner_phone != self.partner_id.phone:
ticket_phone_formatted = self.partner_phone or False
partner_phone_formatted = self.partner_id.phone or False
return ticket_phone_formatted != partner_phone_formatted
return False
def action_customer_preview(self):
self.ensure_one()
if self.team_privacy_visibility != 'portal' or not self.partner_id:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'danger',
'message': _('At this time, there is no customer preview available to show. The current ticket cannot be accessed by the customer, as it belongs to a helpdesk team that is not publicly available, or there is no customer associated with the ticket.'),
}
}
return {
'type': 'ir.actions.act_url',
'url': self.get_portal_url(),
'target': 'self',
}
# ------------------------------------------------------------
# ORM overrides
# ------------------------------------------------------------
@api.depends('ticket_ref', 'partner_name')
@api.depends_context('with_partner')
def _compute_display_name(self):
display_partner_name = self._context.get('with_partner', False)
ticket_with_name = self.filtered('name')
for ticket in ticket_with_name:
name = ticket.name
if ticket.ticket_ref:
name += f' (#{ticket.ticket_ref})'
if display_partner_name and ticket.partner_name:
name += f' - {ticket.partner_name}'
ticket.display_name = name
return super(HelpdeskTicket, self - ticket_with_name)._compute_display_name()
@api.model
def get_empty_list_help(self, help_message):
self = self.with_context(
empty_list_help_id=self.env.context.get('default_team_id'),
empty_list_help_model='helpdesk.team',
empty_list_help_document_name=_("tickets"),
)
return super(HelpdeskTicket, self).get_empty_list_help(help_message)
def create_action(self, action_ref, title, search_view_ref):
action = self.env["ir.actions.actions"]._for_xml_id(action_ref)
action = clean_action(action, self.env)
if title:
action['display_name'] = title
if search_view_ref:
action['search_view_id'] = self.env.ref(search_view_ref).read()[0]
if 'views' not in action:
action['views'] = [(False, view) for view in action['view_mode'].split(",")]
return action
@api.model
def _find_or_create_partner(self, partner_name, partner_email, company=False):
parsed_name, parsed_email_normalized = tools.parse_contact_from_email(partner_email)
if not parsed_name:
parsed_name = partner_name
return self.env['res.partner'].with_context(default_company_id=company).find_or_create(
tools.formataddr((parsed_name, parsed_email_normalized))
)
@api.model_create_multi
def create(self, list_value):
now = fields.Datetime.now()
# determine user_id and stage_id if not given. Done in batch.
teams = self.env['helpdesk.team'].browse([vals['team_id'] for vals in list_value if vals.get('team_id')])
team_default_map = dict.fromkeys(teams.ids, dict())
for team in teams:
team_default_map[team.id] = {
'stage_id': team._determine_stage()[team.id].id,
'user_id': team._determine_user_to_assign()[team.id].id
}
# Manually create a partner now since '_generate_template_recipients' doesn't keep the name. This is
# to avoid intrusive changes in the 'mail' module
# TDE TODO: to extract and clean in mail thread
for vals in list_value:
partner_id = vals.get('partner_id', False)
partner_name = vals.get('partner_name', False)
partner_email = vals.get('partner_email', False)
if partner_name and partner_email and not partner_id:
company = False
if vals.get('team_id'):
team = self.env['helpdesk.team'].browse(vals.get('team_id'))
company = team.company_id.id
vals['partner_id'] = self._find_or_create_partner(partner_name, partner_email, company).id
# determine partner email for ticket with partner but no email given
partners = self.env['res.partner'].browse([vals['partner_id'] for vals in list_value if 'partner_id' in vals and vals.get('partner_id') and 'partner_email' not in vals])
partner_email_map = {partner.id: partner.email for partner in partners}
partner_name_map = {partner.id: partner.name for partner in partners}
company_per_team_id = {t.id: t.company_id for t in teams}
for vals in list_value:
company = company_per_team_id.get(vals.get('team_id', False))
vals['ticket_ref'] = self.env['ir.sequence'].with_company(company).sudo().next_by_code('helpdesk.ticket')
if vals.get('team_id'):
team_default = team_default_map[vals['team_id']]
if 'stage_id' not in vals:
vals['stage_id'] = team_default['stage_id']
# Note: this will break the randomly distributed user assignment. Indeed, it will be too difficult to
# equally assigned user when creating ticket in batch, as it requires to search after the last assigned
# after every ticket creation, which is not very performant. We decided to not cover this user case.
if 'user_id' not in vals:
vals['user_id'] = team_default['user_id']
if vals.get('user_id'): # if a user is finally assigned, force ticket assign_date and reset assign_hours
vals['assign_date'] = fields.Datetime.now()
vals['assign_hours'] = 0
# set partner email if in map of not given
if vals.get('partner_id') in partner_email_map:
vals['partner_email'] = partner_email_map.get(vals['partner_id'])
# set partner name if in map of not given
if vals.get('partner_id') in partner_name_map:
vals['partner_name'] = partner_name_map.get(vals['partner_id'])
if vals.get('stage_id'):
vals['date_last_stage_update'] = now
vals['oldest_unanswered_customer_message_date'] = now
# context: no_log, because subtype already handle this
tickets = super(HelpdeskTicket, self).create(list_value)
all_partner_emails = []
for ticket in tickets:
all_partner_emails += tools.email_split(ticket.email_cc)
partners = self.env['res.partner'].search([('email', 'in', all_partner_emails)])
partner_per_email = {
partner.email: partner
for partner in partners
if not all(u.share for u in partner.user_ids)
}
# make customer follower
for ticket in tickets:
partner_ids = []
if ticket.partner_id:
partner_ids = ticket.partner_id.ids
if ticket.email_cc:
partners_with_internal_user = self.env['res.partner']
for email in tools.email_split(ticket.email_cc):
new_partner = partner_per_email.get(email)
if new_partner:
partners_with_internal_user |= new_partner
if partners_with_internal_user:
ticket._send_email_notify_to_cc(partners_with_internal_user)
partner_ids += partners_with_internal_user.ids
if partner_ids:
ticket.message_subscribe(partner_ids)
ticket._portal_ensure_token()
# apply SLA
tickets.sudo()._sla_apply()
return tickets
def write(self, vals):
# we set the assignation date (assign_date) to now for tickets that are being assigned for the first time
# same thing for the closing date
assigned_tickets = closed_tickets = self.browse()
if vals.get('user_id'):
assigned_tickets = self.filtered(lambda ticket: not ticket.assign_date)
if vals.get('stage_id'):
if self.env['helpdesk.stage'].browse(vals.get('stage_id')).fold:
closed_tickets = self.filtered(lambda ticket: not ticket.close_date)
else: # auto reset the 'closed_by_partner' flag
vals['closed_by_partner'] = False
vals['close_date'] = False
now = fields.Datetime.now()
# update last stage date when changing stage
if 'stage_id' in vals:
vals['date_last_stage_update'] = now
if 'kanban_state' not in vals:
vals['kanban_state'] = 'normal'
res = super(HelpdeskTicket, self - assigned_tickets - closed_tickets).write(vals)
res &= super(HelpdeskTicket, assigned_tickets - closed_tickets).write(dict(vals, **{
'assign_date': now,
}))
res &= super(HelpdeskTicket, closed_tickets - assigned_tickets).write(dict(vals, **{
'close_date': now,
'oldest_unanswered_customer_message_date': False,
}))
res &= super(HelpdeskTicket, assigned_tickets & closed_tickets).write(dict(vals, **{
'assign_date': now,
'close_date': now,
}))
if vals.get('partner_id'):
self.message_subscribe([vals['partner_id']])
# SLA business
sla_triggers = self._sla_reset_trigger()
if any(field_name in sla_triggers for field_name in vals.keys()):
self.sudo()._sla_apply(keep_reached=True)
if 'stage_id' in vals:
self.sudo()._sla_reach(vals['stage_id'])
if 'stage_id' in vals and self.env['helpdesk.stage'].browse(vals['stage_id']).fold:
odoobot_partner_id = self.env['ir.model.data']._xmlid_to_res_id('base.partner_root')
for ticket in self:
exceeded_hours = ticket.sla_status_ids.mapped('exceeded_hours')
if exceeded_hours:
min_hours = min([hours for hours in exceeded_hours if hours > 0], default=min(exceeded_hours))
message = _("This ticket was successfully closed %s hours before its SLA deadline.", round(abs(min_hours))) if min_hours < 0 \
else _("This ticket was closed %s hours after its SLA deadline.", round(min_hours))
ticket.message_post(body=message, subtype_xmlid="mail.mt_note", author_id=odoobot_partner_id)
return res
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", ticket.name)) for ticket, vals in zip(self, vals_list)]
def _unsubscribe_portal_users(self):
self.message_unsubscribe(partner_ids=self.message_partner_ids.filtered('user_ids.share').ids)
# ------------------------------------------------------------
# Actions and Business methods
# ------------------------------------------------------------
@api.model
def _sla_reset_trigger(self):
""" Get the list of field for which we have to reset the SLAs (regenerate) """
return ['team_id', 'priority', 'tag_ids', 'partner_id']
def _sla_apply(self, keep_reached=False):
""" Apply SLA to current tickets: erase the current SLAs, then find and link the new SLAs to each ticket.
Note: transferring ticket to a team "not using SLA" (but with SLAs defined), SLA status of the ticket will be
erased but nothing will be recreated.
:returns recordset of new helpdesk.sla.status applied on current tickets
"""
# get SLA to apply
sla_per_tickets = self._sla_find()
# generate values of new sla status
sla_status_value_list = []
for tickets, slas in sla_per_tickets.items():
sla_status_value_list += tickets._sla_generate_status_values(slas, keep_reached=keep_reached)
sla_status_to_remove = self.mapped('sla_status_ids')
if keep_reached: # keep only the reached one to avoid losing reached_date info
sla_status_to_remove = sla_status_to_remove.filtered(lambda status: not status.reached_datetime)
# unlink status and create the new ones in 2 operations
sla_status_to_remove.unlink()
return self.env['helpdesk.sla.status'].create(sla_status_value_list)
@api.model
def _sla_find_false_domain(self):
return [('partner_ids', '=', False)]
def _sla_find_extra_domain(self):
self.ensure_one()
return [
'|',
('partner_ids', 'parent_of', self.partner_id.ids),
('partner_ids', 'child_of', self.partner_id.ids),
]
def _sla_find(self):
""" Find the SLA to apply on the current tickets
:returns a map with the tickets linked to the SLA to apply on them
:rtype : dict {<helpdesk.ticket>: <helpdesk.sla>}
"""
tickets_map = {}
sla_domain_map = {}
def _generate_key(ticket):
""" Return a tuple identifying the combinaison of field determining the SLA to apply on the ticket """
fields_list = self._sla_reset_trigger()
key = list()
for field_name in fields_list:
if ticket._fields[field_name].type == 'many2one':
key.append(ticket[field_name].id)
else:
key.append(ticket[field_name])
return tuple(key)
for ticket in self:
if ticket.team_id.use_sla: # limit to the team using SLA
key = _generate_key(ticket)
# group the ticket per key
tickets_map.setdefault(key, self.env['helpdesk.ticket'])
tickets_map[key] |= ticket
# group the SLA to apply, by key
if key not in sla_domain_map:
sla_domain_map[key] = expression.AND([[
('team_id', '=', ticket.team_id.id), ('priority', '=', ticket.priority),
('stage_id.sequence', '>=', ticket.stage_id.sequence),
], expression.OR([ticket._sla_find_extra_domain(), self._sla_find_false_domain()])])
result = {}
for key, tickets in tickets_map.items(): # only one search per ticket group
domain = sla_domain_map[key]
slas = self.env['helpdesk.sla'].search(domain)
result[tickets] = slas.filtered(lambda s: not s.tag_ids or (tickets.tag_ids & s.tag_ids)) # SLA to apply on ticket subset
return result
def _sla_generate_status_values(self, slas, keep_reached=False):
""" Return the list of values for given SLA to be applied on current ticket """
status_to_keep = dict.fromkeys(self.ids, list())
# generate the map of status to keep by ticket only if requested
if keep_reached:
for ticket in self:
for status in ticket.sla_status_ids:
if status.reached_datetime:
status_to_keep[ticket.id].append(status.sla_id.id)
# create the list of value, and maybe exclude the existing ones
result = []
for ticket in self:
for sla in slas:
if not (keep_reached and sla.id in status_to_keep[ticket.id]):
result.append({
'ticket_id': ticket.id,
'sla_id': sla.id,
'reached_datetime': fields.Datetime.now() if ticket.stage_id == sla.stage_id else False # in case of SLA on first stage
})
return result
def _sla_reach(self, stage_id):
""" Flag the SLA status of current ticket for the given stage_id as reached, and even the unreached SLA applied
on stage having a sequence lower than the given one.
"""
stage = self.env['helpdesk.stage'].browse(stage_id)
stages = self.env['helpdesk.stage'].search([('sequence', '<=', stage.sequence), ('team_ids', 'in', self.mapped('team_id').ids)]) # take previous stages
sla_status = self.env['helpdesk.sla.status'].search([('ticket_id', 'in', self.ids)])
sla_not_reached = sla_status.filtered(lambda sla: not sla.reached_datetime and sla.sla_stage_id in stages)
sla_not_reached.write({'reached_datetime': fields.Datetime.now()})
(sla_status - sla_not_reached).filtered(lambda x: x.sla_stage_id not in stages).write({'reached_datetime': False})
def action_open_helpdesk_ticket(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("helpdesk.helpdesk_ticket_action_main_tree")
action.update({
'domain': [('id', '!=', self.id), ('id', 'in', self.partner_ticket_ids.ids)],
'context': {
**ast.literal_eval(action.get('context', {})),
'create': False,
},
})
return action
def action_open_ratings(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id('helpdesk.rating_rating_action_helpdesk')
if self.rating_count == 1:
action.update({
'view_mode': 'form',
'res_id': self.rating_ids[0].id,
'views': [(view_id, view_type) for view_id, view_type in action['views'] if view_type == 'form'],
})
return action
# ------------------------------------------------------------
# Messaging API
# ------------------------------------------------------------
#DVE FIXME: if partner gets created when sending the message it should be set as partner_id of the ticket.
def _message_get_suggested_recipients(self):
recipients = super()._message_get_suggested_recipients()
try:
if self.partner_id and self.partner_id.email:
self._message_add_suggested_recipient(recipients, partner=self.partner_id, reason=_('Customer'))
elif self.partner_email:
self._message_add_suggested_recipient(recipients, email=self.partner_email, reason=_('Customer Email'))
except AccessError: # no read access rights -> just ignore suggested recipients because this implies modifying followers
pass
return recipients
def _get_customer_information(self):
email_normalized_to_values = super()._get_customer_information()
Partner = self.env['res.partner']
for record in self.filtered('partner_email'):
email_normalized = tools.email_normalize(record.partner_email)
if not email_normalized:
continue
values = email_normalized_to_values.setdefault(email_normalized, {})
values.update({
'name': record.partner_name or tools.parse_contact_from_email(record.partner_email)[0] or record.partner_email,
'phone': record.partner_phone,
})
return email_normalized_to_values
def _ticket_email_split(self, msg):
email_list = tools.email_split((msg.get('to') or '') + ',' + (msg.get('cc') or ''))
# check left-part is not already an alias
return [
x for x in email_list
if x.split('@')[0] not in self.mapped('team_id.alias_name')
]
@api.model
def message_new(self, msg, custom_values=None):
values = dict(custom_values or {}, partner_email=msg.get('from'), partner_name=msg.get('from'), partner_id=msg.get('author_id'))
ticket = super(HelpdeskTicket, self.with_context(mail_notify_author=True)).message_new(msg, custom_values=values)
thread_context = self.env['mail.thread']
if ticket.company_id:
thread_context = thread_context.with_context(default_company_id=ticket.company_id)
partner_ids = [x.id for x in thread_context._mail_find_partner_from_emails(ticket._ticket_email_split(msg), records=ticket, force_create=True) if x]
customer_ids = [p.id for p in thread_context._mail_find_partner_from_emails(tools.email_split(values['partner_email']), records=ticket, force_create=True) if p]
partner_ids += customer_ids
if customer_ids and not values.get('partner_id'):
ticket.partner_id = customer_ids[0]
if partner_ids:
ticket.message_subscribe(partner_ids)
return ticket
def message_update(self, msg, update_vals=None):
partner_ids = [x.id for x in self.env['mail.thread']._mail_find_partner_from_emails(self._ticket_email_split(msg), records=self) if x]
if partner_ids:
self.message_subscribe(partner_ids)
return super(HelpdeskTicket, self).message_update(msg, update_vals=update_vals)
def _message_compute_subject(self):
""" Override the display name by the actual name field for communication."""
self.ensure_one()
return self.name
def _message_post_after_hook(self, message, msg_vals):
if not self.partner_email:
return super()._message_post_after_hook(message, msg_vals)
if self.partner_id and not self.partner_id.email:
self.partner_id.email = self.partner_email
if not self.partner_id:
# we consider that posting a message with a specified recipient (not a follower, a specific one)
# on a document without customer means that it was created through the chatter using
# suggested recipients. This heuristic allows to avoid ugly hacks in JS.
email_normalized = tools.email_normalize(self.partner_email)
new_partner = message.partner_ids.filtered(
lambda partner: partner.email == self.partner_email or (email_normalized and partner.email_normalized == email_normalized)
)
if new_partner:
if new_partner[0].email_normalized:
email_domain = ('partner_email', 'in', [new_partner[0].email, new_partner[0].email_normalized])
else:
email_domain = ('partner_email', '=', new_partner[0].email)
self.search([
('partner_id', '=', False), email_domain,
]).write({'partner_id': new_partner[0].id})
# use the sanitized body of the email from the message thread to populate the ticket's description
if not self.description and message.subtype_id == self._creation_subtype() and tools.email_normalize(self.partner_email) == tools.email_normalize(message.email_from):
self.description = message.body
return super(HelpdeskTicket, self)._message_post_after_hook(message, msg_vals)
def _send_email_notify_to_cc(self, partners_to_notify):
self.ensure_one()
template_id = self.env['ir.model.data']._xmlid_to_res_id('helpdesk.ticket_invitation_follower', raise_if_not_found=False)
if not template_id:
return
ticket_model_description = self.env['ir.model']._get(self._name).display_name
values = {
'object': self,
}
for partner in partners_to_notify:
values['partner_name'] = partner.name
assignation_msg = self.env['ir.qweb']._render('helpdesk.ticket_invitation_follower', values, minimal_qcontext=True)
self.message_notify(
subject=_('You have been invited to follow %s', self.display_name),
body=assignation_msg,
partner_ids=partner.ids,
record_name=self.display_name,
email_layout_xmlid='mail.mail_notification_layout',
model_description=ticket_model_description,
mail_auto_delete=True,
)
def _track_template(self, changes):
res = super(HelpdeskTicket, self)._track_template(changes)
ticket = self[0]
if 'stage_id' in changes and ticket.stage_id.template_id and ticket.partner_email and (
not self.env.user.partner_id or not ticket.partner_id or ticket.partner_id != self.env.user.partner_id
or self.env.user._is_portal() or ticket._context.get('mail_notify_author')
):
res['stage_id'] = (ticket.stage_id.template_id, {
'auto_delete_keep_log': False,
'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'),
'email_layout_xmlid': 'mail.mail_notification_light'
}
)
return res
def _creation_subtype(self):
return self.env.ref('helpdesk.mt_ticket_new')
def _track_subtype(self, init_values):
self.ensure_one()
if 'stage_id' in init_values:
return self.env.ref('helpdesk.mt_ticket_stage')
return super(HelpdeskTicket, self)._track_subtype(init_values)
def _notify_get_recipients_groups(self, message, model_description, msg_vals=None):
"""
Give access button to portal and portal customers.
If they are notified they should probably have access to the document.
"""
return super()._notify_get_recipients_groups(
message, model_description, msg_vals=msg_vals
)
def _notify_get_reply_to(self, default=None):
""" Override to set alias of tickets to their team if any. """
aliases = self.mapped('team_id').sudo()._notify_get_reply_to(default=default)
res = {ticket.id: aliases.get(ticket.team_id.id) for ticket in self}
leftover = self.filtered(lambda rec: not rec.team_id)
if leftover:
res.update(super(HelpdeskTicket, leftover)._notify_get_reply_to(default=default))
return res
# ------------------------------------------------------------
# Rating Mixin
# ------------------------------------------------------------
def _rating_apply_get_default_subtype_id(self):
return self.env['ir.model.data']._xmlid_to_res_id("helpdesk.mt_ticket_rated")
def _rating_get_parent_field_name(self):
return 'team_id'
# ---------------------------------------------------
# Mail gateway
# ---------------------------------------------------
def _mail_get_message_subtypes(self):
res = super()._mail_get_message_subtypes()
if len(self) == 1 and self.team_id:
team = self.team_id
optional_subtypes = [('use_credit_notes', self.env.ref('helpdesk.mt_ticket_refund_status')),
('use_product_returns', self.env.ref('helpdesk.mt_ticket_return_status')),
('use_product_repairs', self.env.ref('helpdesk.mt_ticket_repair_status'))]
for field, subtype in optional_subtypes:
if not team[field] and subtype in res:
res -= subtype
return res

View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from odoo import models
class IrModuleModule(models.Model):
_inherit = 'ir.module.module'
def module_uninstall(self):
helpdesk_modules = self.env['helpdesk.team']._get_field_modules()
modules_field = {module: field for field, module in helpdesk_modules.items()}
fields_to_reset = [modules_field[name] for name in self.mapped('name') if name in modules_field.keys()]
if fields_to_reset:
self.env['helpdesk.team'].search([]).write({
field: False
for field in fields_to_reset
})
return super().module_uninstall()

View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrUiMenu(models.Model):
_inherit = 'ir.ui.menu'
def _load_menus_blacklist(self):
res = super()._load_menus_blacklist()
if not self.env.user.has_group('helpdesk.group_helpdesk_manager'):
res.append(self.env.ref('helpdesk.helpdesk_ticket_report_menu_ratings').id)
return res

View File

@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api
class MailMessage(models.Model):
_inherit = 'mail.message'
@api.model_create_multi
def create(self, values_list):
messages = super().create(values_list)
# We measure the time between the customer's message
# or ticket's create date, and the helpdesk response of subtype comment (not note).
# If several messages are sent before any response,
# in both directions, we take the first one.
# EVENT | CUSTOMER | HELPDESK | MEASURED
# create | x | | ↑
# msg | x | | |
# note | | x | |
# msg | | x | ↓
# msg | | x |
# note | x | |
# msg | x | | ↑
# msg | | x | ↓
# ...
if not any(values.get('model') == 'helpdesk.ticket' for values in values_list):
return messages
comment_subtype = self.env.ref('mail.mt_comment')
filtered_messages = messages.filtered(
lambda m: m.model == 'helpdesk.ticket' and m.subtype_id == comment_subtype
)
if not filtered_messages:
return messages
tickets = self.env['helpdesk.ticket'].sudo().search(
[('close_date', '=', False), ('id', 'in', filtered_messages.mapped('res_id'))]
)
ticket_per_id = {t.id: t for t in tickets}
if not tickets:
return messages
for message in filtered_messages.sorted(lambda m: m.date):
ticket = ticket_per_id.get(message.res_id)
if not ticket:
continue
oldest_unanswered_customer_message_date = ticket.oldest_unanswered_customer_message_date
is_helpdesk_msg = any(not user.share for user in message.author_id.user_ids)
if not oldest_unanswered_customer_message_date and not is_helpdesk_msg:
# customer initiated an exchange
ticket.oldest_unanswered_customer_message_date = message.date
elif oldest_unanswered_customer_message_date and is_helpdesk_msg:
# internal user responded to the customer
ticket.oldest_unanswered_customer_message_date = False
calendar = ticket.team_id.resource_calendar_id or self.env.company.resource_calendar_id
if not calendar:
continue
duration_data = calendar.get_work_duration_data(oldest_unanswered_customer_message_date, message.date, compute_leaves=True)
delta_hours = duration_data['hours']
if not ticket.answered_customer_message_count:
ticket.first_response_hours = delta_hours
ticket.answered_customer_message_count += 1
ticket.total_response_hours += delta_hours
ticket.avg_response_hours = ticket.total_response_hours / ticket.answered_customer_message_count
else:
# a new message is received, `write_date` should be updated
ticket.write({})
return messages

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command, api, models, _
class ResCompany(models.Model):
_inherit = 'res.company'
@api.model_create_multi
def create(self, values):
company = super().create(values)
company._create_helpdesk_team()
return company
def _create_helpdesk_team(self):
results = []
stage_ids = []
for xml_id in ['stage_new', 'stage_in_progress', 'stage_solved', 'stage_cancelled', 'stage_on_hold']:
record = self.env.ref(f'helpdesk.{xml_id}', False)
if record:
stage_ids.append(record.id)
team_name = _('Customer Care')
for company in self:
company = company.with_company(company)
results += [{
'name': team_name,
'company_id': company.id,
'use_sla': False,
'stage_ids': [Command.set(stage_ids)],
'alias_name': "%s-%s" % (team_name, company.name),
}]
# use sudo as the user could have the right to create a company
# but not to create a team for other company.
return self.env['helpdesk.team'].sudo().create(results)

View File

@ -0,0 +1,50 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
class ResPartner(models.Model):
_inherit = 'res.partner'
ticket_count = fields.Integer("Tickets", compute='_compute_ticket_count')
sla_ids = fields.Many2many(
'helpdesk.sla', 'helpdesk_sla_res_partner_rel',
'res_partner_id', 'helpdesk_sla_id', string='SLA Policies',
help="SLA Policies that will automatically apply to the tickets submitted by this customer.")
def _compute_ticket_count(self):
all_partners_subquery = self.with_context(active_test=False)._search([('id', 'child_of', self.ids)])
# group tickets by partner, and account for each partner in self
groups = self.env['helpdesk.ticket']._read_group(
[('partner_id', 'in', all_partners_subquery)],
groupby=['partner_id'], aggregates=['__count'],
)
self.ticket_count = 0
for partner, count in groups:
while partner:
if partner in self:
partner.ticket_count += count
partner = partner.with_context(prefetch_fields=False).parent_id
def action_open_helpdesk_ticket(self):
self.ensure_one()
action = {
**self.env["ir.actions.actions"]._for_xml_id("helpdesk.helpdesk_ticket_action_main_tree"),
'display_name': _("%(partner_name)s's Tickets", partner_name=self.name),
'context': {},
}
all_child = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
search_domain = [('partner_id', 'in', (self | all_child).ids)]
if self.ticket_count <= 1:
ticket_id = self.env['helpdesk.ticket'].search(search_domain, limit=1)
action['res_id'] = ticket_id.id
action['views'] = [(view_id, view_type) for view_id, view_type in action['views'] if view_type == "form"]
else:
action['domain'] = search_domain
action['views'] = [
(self.env['ir.model.data']._xmlid_to_res_id('helpdesk.helpdesk_tickets_view_tree_res_partner'), view_type) if view_type == 'list' else
(view_id, view_type)
for view_id, view_type in action['views']
]
return action

View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResUsers(models.Model):
_inherit = 'res.users'
helpdesk_target_closed = fields.Integer(export_string_translation=False, default=1)
helpdesk_target_rating = fields.Float(export_string_translation=False, default=4.5)
helpdesk_target_success = fields.Float(export_string_translation=False, default=85)
_sql_constraints = [
('target_closed_not_zero', 'CHECK(helpdesk_target_closed > 0)', 'You cannot have negative targets'),
('target_rating_not_zero', 'CHECK(helpdesk_target_rating > 0)', 'You cannot have negative targets'),
('target_success_not_zero', 'CHECK(helpdesk_target_success > 0)', 'You cannot have negative targets'),
]
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + [
'helpdesk_target_closed',
'helpdesk_target_rating',
'helpdesk_target_success',
]
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + [
'helpdesk_target_closed',
'helpdesk_target_rating',
'helpdesk_target_success',
]

View File

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

View File

@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools
from odoo.addons.helpdesk.models.helpdesk_ticket import TICKET_PRIORITY
from odoo.addons.rating.models.rating_data import RATING_LIMIT_MIN
class HelpdeskSLAReport(models.Model):
_name = 'helpdesk.sla.report.analysis'
_description = "SLA Status Analysis"
_auto = False
_order = 'create_date DESC'
ticket_id = fields.Many2one('helpdesk.ticket', string='Ticket', readonly=True)
description = fields.Text(readonly=True)
tag_ids = fields.Many2many('helpdesk.tag', relation='helpdesk_tag_helpdesk_ticket_rel',
column1='helpdesk_ticket_id', column2='helpdesk_tag_id',
string='Tags', readonly=True)
ticket_ref = fields.Char(string='Ticket IDs Sequence', readonly=True)
name = fields.Char(string='Subject', readonly=True)
create_date = fields.Datetime("Ticket Creation Date", readonly=True)
priority = fields.Selection(TICKET_PRIORITY, string='Minimum Priority', readonly=True)
user_id = fields.Many2one('res.users', string="Assigned To", readonly=True)
partner_id = fields.Many2one('res.partner', string="Customer", readonly=True)
partner_name = fields.Char(string='Customer Name', readonly=True)
partner_email = fields.Char(string='Customer Email', readonly=True)
partner_phone = fields.Char(string='Customer Phone', readonly=True)
stage_id = fields.Many2one('helpdesk.stage', string="Stage", readonly=True)
ticket_open_hours = fields.Float("Hours Open", aggregator="avg", readonly=True)
ticket_closed = fields.Boolean("Ticket Closed", readonly=True)
ticket_close_hours = fields.Integer("Working Hours to Close", aggregator="avg", readonly=True)
ticket_assignation_hours = fields.Integer("Working Hours to Assign", aggregator="avg", readonly=True)
close_date = fields.Datetime("Closing Date", readonly=True)
sla_id = fields.Many2one('helpdesk.sla', string='SLA', readonly=True)
sla_ids = fields.Many2many('helpdesk.sla', 'helpdesk_sla_status', 'ticket_id', 'sla_id', string="SLAs", copy=False)
sla_status_ids = fields.One2many('helpdesk.sla.status', 'ticket_id', string="SLA Status")
sla_stage_id = fields.Many2one('helpdesk.stage', string="SLA Stage", readonly=True)
sla_deadline = fields.Datetime("SLA Deadline", aggregator='min', readonly=True)
sla_status = fields.Selection([('failed', 'SLA Failed'), ('reached', 'SLA Success'), ('ongoing', 'SLA in Progress')], string="Status", readonly=True)
sla_fail = fields.Boolean("SLA Status Failed", aggregator='bool_or', readonly=True)
sla_success = fields.Boolean("SLA Status Success", aggregator='bool_or', readonly=True)
sla_exceeded_hours = fields.Integer("Working Hours until SLA Deadline", aggregator='avg', readonly=True, help="Day to reach the stage of the SLA, without taking the working calendar into account")
sla_status_failed = fields.Integer("Number of SLAs Failed", readonly=True, aggregator="avg")
active = fields.Boolean("Active", readonly=True)
rating_last_value = fields.Float("Rating (1-5)", aggregator="avg", readonly=True)
rating_avg = fields.Float('Average Rating', readonly=True, aggregator='avg')
team_id = fields.Many2one('helpdesk.team', string='Helpdesk Team', readonly=True)
company_id = fields.Many2one('res.company', string='Company', readonly=True)
message_is_follower = fields.Boolean(related='ticket_id.message_is_follower', export_string_translation=False)
kanban_state = fields.Selection([
('normal', 'Grey'),
('done', 'Green'),
('blocked', 'Red')], string='Kanban State', readonly=True)
avg_response_hours = fields.Float("Average Hours to Respond", aggregator="avg", readonly=True)
first_response_hours = fields.Float("Hours to First Response", aggregator="avg", readonly=True)
def _select(self):
return """
SELECT DISTINCT T.id as id,
T.id AS ticket_id,
T.description,
T.ticket_ref AS ticket_ref,
T.name AS name,
T.create_date AS create_date,
T.team_id,
T.active AS active,
T.stage_id AS stage_id,
T.user_id,
T.partner_id,
T.partner_name AS partner_name,
T.partner_email AS partner_email,
T.partner_phone AS partner_phone,
T.company_id,
T.kanban_state AS kanban_state,
NULLIF(T.rating_last_value, 0) AS rating_last_value,
AVG(rt.rating) as rating_avg,
T.priority AS priority,
NULLIF(T.close_hours, 0) AS ticket_close_hours,
CASE
WHEN EXTRACT(EPOCH FROM (COALESCE(T.assign_date, NOW() AT TIME ZONE 'UTC') - T.create_date)) / 3600 < 1 THEN NULL
ELSE EXTRACT(EPOCH FROM (COALESCE(T.assign_date, NOW() AT TIME ZONE 'UTC') - T.create_date)) / 3600
END AS ticket_open_hours,
NULLIF(T.assign_hours, 0) AS ticket_assignation_hours,
NULLIF(T.avg_response_hours, 0) AS avg_response_hours,
NULLIF(T.first_response_hours, 0) AS first_response_hours,
T.close_date AS close_date,
STAGE.fold AS ticket_closed,
SLA.stage_id as sla_stage_id,
SLA_S.deadline AS sla_deadline,
SLA.id as sla_id,
SLA_S.exceeded_hours AS sla_exceeded_hours,
SLA_S.reached_datetime >= SLA_S.deadline OR (SLA_S.reached_datetime IS NULL AND SLA_S.deadline < NOW() AT TIME ZONE 'UTC') AS sla_fail,
CASE
WHEN SLA_S.reached_datetime IS NOT NULL AND SLA_S.deadline IS NOT NULL AND SLA_S.reached_datetime >= SLA_S.deadline THEN 1
WHEN SLA_S.reached_datetime IS NULL AND SLA_S.deadline IS NOT NULL AND SLA_S.deadline < NOW() AT TIME ZONE 'UTC' THEN 1
ELSE NULL
END AS sla_status_failed,
CASE
WHEN SLA_S.reached_datetime IS NOT NULL AND (SLA_S.deadline IS NULL OR SLA_S.reached_datetime < SLA_S.deadline) THEN 'reached'
WHEN (SLA_S.reached_datetime IS NOT NULL AND SLA_S.deadline IS NOT NULL AND SLA_S.reached_datetime >= SLA_S.deadline) OR
(SLA_S.reached_datetime IS NULL AND SLA_S.deadline IS NOT NULL AND SLA_S.deadline < NOW() AT TIME ZONE 'UTC') THEN 'failed'
WHEN SLA_S.reached_datetime IS NULL AND (SLA_S.deadline IS NULL OR SLA_S.deadline > NOW() AT TIME ZONE 'UTC') THEN 'ongoing'
END AS sla_status,
CASE
WHEN (SLA_S.deadline IS NOT NULL AND SLA_S.deadline > NOW() AT TIME ZONE 'UTC') THEN TRUE ELSE FALSE
END AS sla_success
"""
def _group_by(self):
return """
t.id,
STAGE.fold,
SLA.stage_id,
SLA_S.deadline,
SLA_S.reached_datetime,
SLA.id,
SLA_S.exceeded_hours
"""
def _from(self):
return f"""
helpdesk_ticket T
LEFT JOIN rating_rating rt ON rt.res_id = t.id
AND rt.res_model = 'helpdesk.ticket'
AND rt.consumed = True
AND rt.rating >= {RATING_LIMIT_MIN}
LEFT JOIN helpdesk_stage STAGE ON T.stage_id = STAGE.id
RIGHT JOIN helpdesk_sla_status SLA_S ON T.id = SLA_S.ticket_id
LEFT JOIN helpdesk_sla SLA ON SLA.id = SLA_S.sla_id
"""
def _where(self):
return """
T.active = true
"""
def _order_by(self):
return """
id, sla_stage_id
"""
def init(self):
tools.drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute("""CREATE or REPLACE VIEW %s as (
%s
FROM %s
WHERE %s
GROUP BY %s
ORDER BY %s
)""" % (self._table, self._select(), self._from(),
self._where(), self._group_by(), self._order_by()))

View File

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="helpdesk_sla_report_analysis_view_pivot" model="ir.ui.view">
<field name="name">helpdesk.sla.report.analysis.pivot</field>
<field name="model">helpdesk.sla.report.analysis</field>
<field name="arch" type="xml">
<pivot string="SLA Status Analysis" disable_linking="1" sample="1">
<field name="team_id" type="row"/>
<field name="sla_id" type="row"/>
<field name="sla_status_failed"/>
<field name="sla_status" type="col"/>
<field name="ticket_close_hours" widget="float_time"/>
<field name="ticket_assignation_hours" widget="float_time"/>
<field name="sla_exceeded_hours" widget="float_time"/>
<field name="rating_avg" type="measure" invisible="1"/>
<field name="avg_response_hours" widget="float_time"/>
<field name="ticket_open_hours" widget="float_time"/>
<field name="first_response_hours" widget="float_time"/>
</pivot>
</field>
</record>
<record id="helpdesk_sla_report_analysis_view_pivot_dashboard" model="ir.ui.view">
<field name="name">helpdesk.sla.report.analysis.pivot.dashboard</field>
<field name="model">helpdesk.sla.report.analysis</field>
<field name="inherit_id" ref="helpdesk.helpdesk_sla_report_analysis_view_pivot"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='team_id']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
</field>
</record>
<record id="helpdesk_sla_report_analysis_view_graph" model="ir.ui.view">
<field name="name">helpdesk.sla.report.analysis.graph</field>
<field name="model">helpdesk.sla.report.analysis</field>
<field name="arch" type="xml">
<graph string="SLA Status Analysis" disable_linking="1" sample="1">
<field name="ticket_assignation_hours" widget="float_time"/>
<field name="ticket_close_hours" widget="float_time"/>
<field name="sla_id"/>
<field name="sla_exceeded_hours" widget="float_time"/>
<field name="sla_status"/>
</graph>
</field>
</record>
<record id="helpdesk_sla_report_graph_analysis_dashboard" model="ir.ui.view">
<field name="name">helpdesk.sla.report.graph.analysis.dashboard</field>
<field name="model">helpdesk.sla.report.analysis</field>
<field name="inherit_id" ref="helpdesk.helpdesk_sla_report_analysis_view_graph"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//graph" position="attributes">
<attribute name="stacked">False</attribute>
</xpath>
</field>
</record>
<!-- <record id="helpdesk_sla_report_analysis_view_cohort" model="ir.ui.view">-->
<!-- <field name="name">helpdesk.sla.report.analysis.cohort</field>-->
<!-- <field name="model">helpdesk.sla.report.analysis</field>-->
<!-- <field name="arch" type="xml">-->
<!-- <cohort string="SLA Status Analysis" date_start="create_date" date_stop="close_date" disable_linking="True" interval="day" sample="1"/>-->
<!-- </field>-->
<!-- </record>-->
<record id="helpdesk_sla_report_analysis_view_search" model="ir.ui.view">
<field name="name">helpdesk.sla.report.analysis.search</field>
<field name="model">helpdesk.sla.report.analysis</field>
<field name="mode">primary</field>
<field name="inherit_id" ref="helpdesk_tickets_view_search_base"/>
<field name="priority">20</field>
<field name="arch" type="xml">
<search position="attributes"/>
</field>
</record>
<!-- Action -->
<record id="helpdesk_sla_report_analysis_action" model="ir.actions.act_window">
<field name="name">SLA Status Analysis</field>
<field name="res_model">helpdesk.sla.report.analysis</field>
<field name="path">sla-status-analysis</field>
<field name="view_mode">pivot,graph</field>
<field name="search_view_id" ref="helpdesk_sla_report_analysis_view_search"/>
<field name="domain">['|', ('company_id', '=', False), ('company_id', 'in', allowed_company_ids)]</field>
<field name="context">{'search_default_month': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No data yet!
</p><p>
Track the performance of your teams, the success rate of your tickets, and how quickly you reach your service level agreements (SLAs).
</p>
</field>
</record>
<record id="action_appraisal_view_report_pivot" model="ir.actions.act_window.view">
<field name="sequence" eval="1"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="helpdesk_sla_report_analysis_view_pivot"/>
<field name="act_window_id" ref="helpdesk_sla_report_analysis_action"/>
</record>
<record id="action_appraisal_view_report_graph" model="ir.actions.act_window.view">
<field name="sequence" eval="5"/>
<field name="view_mode">graph</field>
<field name="view_id" ref="helpdesk_sla_report_analysis_view_graph"/>
<field name="act_window_id" ref="helpdesk_sla_report_analysis_action"/>
</record>
<!-- <record id="action_appraisal_view_report_cohort" model="ir.actions.act_window.view">-->
<!-- <field name="sequence" eval="10"/>-->
<!-- <field name="view_mode">cohort</field>-->
<!-- <field name="view_id" ref="helpdesk_sla_report_analysis_view_cohort"/>-->
<!-- <field name="act_window_id" ref="helpdesk_sla_report_analysis_action"/>-->
<!-- </record>-->
<record id="helpdesk_sla_report_analysis_dashboard_action" model="ir.actions.act_window">
<field name="name">SLA Status Analysis</field>
<field name="res_model">helpdesk.sla.report.analysis</field>
<field name="view_mode">pivot,graph</field>
<field name="context">{'search_default_month': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No data yet!
</p><p>
Track the performance of your teams, the success rate of your tickets, and how quickly you reach your service level agreements (SLAs).
</p>
</field>
</record>
<record id="helpdesk_sla_report_analysis_dashboard_pivot_view" model="ir.actions.act_window.view">
<field name="sequence" eval="5"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="helpdesk_sla_report_analysis_view_pivot_dashboard"/>
<field name="act_window_id" ref="helpdesk_sla_report_analysis_dashboard_action"/>
</record>
<record id="helpdesk_sla_report_analysis_dashboard_graph_view" model="ir.actions.act_window.view">
<field name="sequence" eval="10"/>
<field name="view_mode">graph</field>
<field name="view_id" ref="helpdesk_sla_report_graph_analysis_dashboard"/>
<field name="act_window_id" ref="helpdesk_sla_report_analysis_dashboard_action"/>
</record>
</odoo>

View File

@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, tools
from odoo.addons.helpdesk.models.helpdesk_ticket import TICKET_PRIORITY
from odoo.addons.rating.models.rating_data import RATING_LIMIT_MIN
class HelpdeskTicketReport(models.Model):
_name = 'helpdesk.ticket.report.analysis'
_description = "Ticket Analysis"
_auto = False
_order = 'create_date DESC'
ticket_id = fields.Many2one('helpdesk.ticket', string='Ticket', readonly=True)
description = fields.Text(readonly=True)
tag_ids = fields.Many2many('helpdesk.tag', relation='helpdesk_tag_helpdesk_ticket_rel',
column1='helpdesk_ticket_id', column2='helpdesk_tag_id',
string='Tags', readonly=True)
ticket_ref = fields.Char(string='Ticket IDs Sequence', readonly=True)
name = fields.Char(string='Subject', readonly=True)
sla_fail = fields.Boolean(related="ticket_id.sla_fail", readonly=True)
sla_success = fields.Boolean("SLA Status Success", aggregator='bool_or', readonly=True)
sla_ids = fields.Many2many('helpdesk.sla', 'helpdesk_sla_status', 'ticket_id', 'sla_id', string="SLAs", copy=False)
sla_status_ids = fields.One2many('helpdesk.sla.status', 'ticket_id', string="SLA Status")
create_date = fields.Datetime("Ticket Creation Date", readonly=True)
priority = fields.Selection(TICKET_PRIORITY, string='Minimum Priority', readonly=True)
user_id = fields.Many2one('res.users', string="Assigned To", readonly=True)
partner_id = fields.Many2one('res.partner', string="Customer", readonly=True)
partner_name = fields.Char(string='Customer Name', readonly=True)
partner_email = fields.Char(string='Customer Email', readonly=True)
partner_phone = fields.Char(string='Customer Phone', readonly=True)
stage_id = fields.Many2one('helpdesk.stage', string="Stage", readonly=True)
sla_deadline = fields.Datetime("Ticket Deadline", readonly=True)
ticket_deadline_hours = fields.Float("Working Hours until SLA Deadline", aggregator="avg", readonly=True)
ticket_close_hours = fields.Float("Working Hours to Close", aggregator="avg", readonly=True)
ticket_open_hours = fields.Float("Hours Open", aggregator="avg", readonly=True)
ticket_assignation_hours = fields.Float("Working Hours to Assign", aggregator="avg", readonly=True)
close_date = fields.Datetime("Closing Date", readonly=True)
assign_date = fields.Datetime("First assignment date", readonly=True)
rating_last_value = fields.Float("Rating (1-5)", aggregator="avg", readonly=True)
active = fields.Boolean("Active", readonly=True)
team_id = fields.Many2one('helpdesk.team', string='Helpdesk Team', readonly=True)
company_id = fields.Many2one('res.company', string='Company', readonly=True)
message_is_follower = fields.Boolean(related='ticket_id.message_is_follower', export_string_translation=False)
kanban_state = fields.Selection([
('normal', 'Grey'),
('done', 'Green'),
('blocked', 'Red')], string='Kanban State', readonly=True)
first_response_hours = fields.Float("Hours to First Response", aggregator="avg", readonly=True)
avg_response_hours = fields.Float("Average Hours to Respond", aggregator="avg", readonly=True)
rating_avg = fields.Float('Average Rating', readonly=True, aggregator='avg')
def _select(self):
select_str = """
SELECT T.id AS id,
T.id AS ticket_id,
T.description,
T.ticket_ref AS ticket_ref,
T.name AS name,
T.create_date AS create_date,
T.priority AS priority,
T.user_id AS user_id,
T.partner_id AS partner_id,
T.partner_name AS partner_name,
T.partner_email AS partner_email,
T.partner_phone AS partner_phone,
T.stage_id AS stage_id,
T.sla_deadline AS sla_deadline,
NULLIF(T.sla_deadline_hours, 0) AS ticket_deadline_hours,
NULLIF(T.close_hours, 0) AS ticket_close_hours,
CASE
WHEN EXTRACT(EPOCH FROM (COALESCE(T.close_date, NOW() AT TIME ZONE 'UTC') - T.create_date)) / 3600 < 1 THEN NULL
ELSE EXTRACT(EPOCH FROM (COALESCE(T.close_date, NOW() AT TIME ZONE 'UTC') - T.create_date)) / 3600
END AS ticket_open_hours,
NULLIF(T.assign_hours, 0) AS ticket_assignation_hours,
T.close_date AS close_date,
T.assign_date AS assign_date,
CASE WHEN ht.use_rating AND COUNT(rt.id) > 0 THEN T.rating_last_value ELSE NULL END as rating_last_value,
CASE WHEN ht.use_rating AND COUNT(rt.id) > 0 THEN AVG(rt.rating) ELSE NULL END as rating_avg,
T.active AS active,
T.team_id AS team_id,
T.company_id AS company_id,
T.kanban_state AS kanban_state,
NULLIF(T.first_response_hours, 0) AS first_response_hours,
NULLIF(T.avg_response_hours, 0) AS avg_response_hours,
CASE
WHEN (T.sla_deadline IS NOT NULL AND T.sla_deadline > NOW() AT TIME ZONE 'UTC') THEN TRUE ELSE FALSE
END AS sla_success
"""
return select_str
def _group_by(self):
return """
t.id,
ht.use_rating
"""
def _from(self):
from_str = f"""
helpdesk_ticket T
LEFT JOIN rating_rating rt ON rt.res_id = t.id
AND rt.res_model = 'helpdesk.ticket'
AND rt.consumed = True
AND rt.rating >= {RATING_LIMIT_MIN}
INNER JOIN helpdesk_team ht ON ht.id = t.team_id
"""
return from_str
def init(self):
tools.drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute("""CREATE or REPLACE VIEW %s as (
%s
FROM %s
GROUP BY %s
)""" % (self._table, self._select(), self._from(), self._group_by()))

View File

@ -0,0 +1,324 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="helpdesk_ticket_view_pivot_analysis" model="ir.ui.view">
<field name="name">helpdesk.ticket.report.analysis.pivot</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="arch" type="xml">
<pivot string="Ticket Analysis" display_quantity="1" disable_linking="1" sample="1">
<field name="team_id" type="row"/>
<field name="ticket_open_hours" widget="float_time"/>
<field name="ticket_assignation_hours" widget="float_time" type="measure"/>
<field name="first_response_hours" widget="float_time" type="measure"/>
<field name="avg_response_hours" widget="float_time" type="measure"/>
<field name="ticket_close_hours" widget="float_time" type="measure"/>
<field name="ticket_deadline_hours" widget="float_time"/>
<field name="rating_avg" type="measure" invisible="1"/>
</pivot>
</field>
</record>
<record id="helpdesk_ticket_view_pivot_analysis_dashboard" model="ir.ui.view">
<field name="name">helpdesk.ticket.report.analysis.pivot.dashboard</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_pivot_analysis"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='team_id']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
</field>
</record>
<record id="helpdesk_ticket_view_pivot_7days_analysis_inherit_dashboard" model="ir.ui.view">
<field name="name">helpdesk.ticket.view.pivot.7days.analysis.inherit</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_pivot_analysis"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='team_id']" position="replace">
<field name="close_date" interval="day" type="row"/>
</xpath>
<field name="ticket_close_hours" position="replace"/>
<field name="ticket_open_hours" position="replace"/>
<field name="ticket_assignation_hours" position="replace"/>
</field>
</record>
<record id="helpdesk_ticket_view_pivot_analysis_success_inherit_dashboard" model="ir.ui.view">
<field name="name">helpdesk.ticket.view.pivot.analysis.success.inherit</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="inherit_id" ref="helpdesk_ticket_view_pivot_7days_analysis_inherit_dashboard"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='close_date']" position="replace">
<field name="ticket_id" type="row"/>
</xpath>
</field>
</record>
<record id="helpdesk_ticket_view_pivot_analysis_7dayssuccess_inherit_dashboard" model="ir.ui.view">
<field name="name">helpdesk.ticket.view.pivot.analysis.7dayssuccess.inherit</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="inherit_id" ref="helpdesk_ticket_view_pivot_7days_analysis_inherit_dashboard"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='close_date']" position="attributes">
<attribute name="name">create_date</attribute>
</xpath>
</field>
</record>
<record id="helpdesk_ticket_view_pivot_analysis_inherit_dashboard" model="ir.ui.view">
<field name="name">helpdesk.ticket.view.pivot.analysis.inherit</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="inherit_id" ref="helpdesk_ticket_view_pivot_analysis_7dayssuccess_inherit_dashboard"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//pivot" position="attributes">
<attribute name="display_quantity">1</attribute>
</xpath>
<field name="create_date" position="after">
<field name="ticket_open_hours" widget="float_time" type="measure"/>
</field>
</field>
</record>
<record id="helpdesk_ticket_view_list_analysis" model="ir.ui.view">
<field name="name">helpdesk.ticket.report.analysis.list</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="arch" type="xml">
<list string="Tickets" multi_edit="1" sample="1">
<field name="ticket_id" string="Name" readonly="1"/>
<field name="team_id" optional="show" readonly="1" column_invisible="context.get('default_team_id', False)"/>
<field name="team_id" optional="hide" readonly="1" column_invisible="not context.get('default_team_id', False)"/>
<field name="user_id" optional="show" widget="many2one_avatar_user"/>
<field name="partner_id" optional="show"/>
<field name="priority" optional="show" widget="priority"/>
<field name="stage_id" optional="show" readonly="1"/>
<field name="sla_deadline" optional="show" widget="remaining_days"/>
<field name="company_id" groups="base.group_multi_company" optional="show" readonly="1" column_invisible="context.get('default_team_id', False)"/>
<field name="company_id" groups="base.group_multi_company" optional="hide" readonly="1" column_invisible="not context.get('default_team_id', False)"/>
</list>
</field>
</record>
<record id="helpdesk_ticket_view_graph_analysis" model="ir.ui.view">
<field name="name">helpdesk.ticket.report.analysis.graph</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="arch" type="xml">
<graph string="Tickets Analysis" sample="1" disable_linking="1">
<field name="team_id"/>
<field name="stage_id"/>
<field name="ticket_close_hours" widget="float_time"/>
<field name="avg_response_hours" widget="float_time"/>
<field name="ticket_assignation_hours" widget="float_time"/>
<field name="first_response_hours" widget="float_time"/>
<field name="ticket_deadline_hours" widget="float_time"/>
<field name="ticket_open_hours" widget="float_time"/>
<field name="rating_avg" type="measure" invisible="1"/>
</graph>
</field>
</record>
<record id="helpdesk_ticket_view_graph_analysis_dashboard" model="ir.ui.view">
<field name="name">helpdesk.ticket.view.graph.analysis.dashboard</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_graph_analysis"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//graph" position="attributes">
<attribute name="stacked">False</attribute>
</xpath>
</field>
</record>
<record id="helpdesk_ticket_view_graph_analysis_inherit_dashboard" model="ir.ui.view">
<field name="name">helpdesk.ticket.view.graph.analysis.inherit</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_graph_analysis"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//graph" position="attributes">
<attribute name="stacked">False</attribute>
<attribute name="order">DESC</attribute>
</xpath>
<xpath expr="//field[@name='team_id']" position="replace">
<field name="create_date" interval="day"/>
</xpath>
<field name="stage_id" position="replace"/>
</field>
</record>
<!-- <record id="helpdesk_ticket_report_view_cohort" model="ir.ui.view">-->
<!-- <field name="name">helpdesk.ticket.report.analysis.cohort</field>-->
<!-- <field name="model">helpdesk.ticket.report.analysis</field>-->
<!-- <field name="arch" type="xml">-->
<!-- <cohort string="Tickets Analysis" date_start="create_date" date_stop="close_date" disable_linking="True" interval="day" sample="1"/>-->
<!-- </field>-->
<!-- </record>-->
<record id="helpdesk_ticket_report_analysis_view_tree" model="ir.ui.view">
<field name="name">helpdesk.ticket.report.analysis.list</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="arch" type="xml">
<list string="Tickets Analysis">
<field name="ticket_id"/>
<field name="priority" optional="show" widget="priority"/>
<field name="team_id" optional="show"/>
<field name="partner_id" widget="res_partner_many2one" optional="show"/>
<field name="stage_id" optional="show"/>
</list>
</field>
</record>
<record id="helpdesk_ticket_report_analysis_view_search" model="ir.ui.view">
<field name="name">helpdesk.ticket.report.analysis.search</field>
<field name="model">helpdesk.ticket.report.analysis</field>
<field name="mode">primary</field>
<field name="inherit_id" ref="helpdesk_tickets_view_search_base"/>
<field name="priority">20</field>
<field name="arch" type="xml">
<search position="attributes"/>
</field>
</record>
<!-- Action -->
<record id="helpdesk_ticket_analysis_action" model="ir.actions.act_window">
<field name="name">Tickets Analysis</field>
<field name="res_model">helpdesk.ticket.report.analysis</field>
<field name="path">tickets-analysis</field>
<field name="view_mode">pivot,graph</field>
<field name="search_view_id" ref="helpdesk_ticket_report_analysis_view_search"/>
<field name="domain">['|', ('company_id', '=', False), ('company_id', 'in', allowed_company_ids)]</field>
<field name="context">{
'search_default_group_by_create_date': 1,
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No data yet!
</p><p>
Get statistics on your tickets and how long it takes to assign and resolve them.
</p>
</field>
</record>
<record id="action_helpdesk_ticket_analysis_graph" model="ir.actions.act_window.view">
<field name="sequence" eval="1"/>
<field name="view_mode">graph</field>
<field name="view_id" ref="helpdesk_ticket_view_graph_analysis"/>
<field name="act_window_id" ref="helpdesk_ticket_analysis_action"/>
</record>
<record id="action_helpdesk_ticket_analysis_pivot" model="ir.actions.act_window.view">
<field name="sequence" eval="1"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="helpdesk_ticket_view_pivot_analysis"/>
<field name="act_window_id" ref="helpdesk_ticket_analysis_action"/>
</record>
<!-- <record id="action_helpdesk_ticket_analysis_cohort" model="ir.actions.act_window.view">-->
<!-- <field name="sequence" eval="10"/>-->
<!-- <field name="view_mode">cohort</field>-->
<!-- <field name="view_id" ref="helpdesk_ticket_report_view_cohort"/>-->
<!-- <field name="act_window_id" ref="helpdesk_ticket_analysis_action"/>-->
<!-- </record>-->
<record id="helpdesk_ticket_analysis_dashboard_action" model="ir.actions.act_window">
<field name="name">Ticket Analysis</field>
<field name="res_model">helpdesk.ticket.report.analysis</field>
<field name="view_mode">pivot,graph</field>
<field name="context">{
'search_default_group_by_create_date': 1,
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No data yet!
</p><p>
Get statistics on your tickets and how long it takes to assign and resolve them.
</p>
</field>
</record>
<record id="action_helpdesk_ticket_analysis_dashboard_graph_view" model="ir.actions.act_window.view">
<field name="sequence" eval="5"/>
<field name="view_mode">graph</field>
<field name="view_id" ref="helpdesk_ticket_view_graph_analysis_dashboard"/>
<field name="act_window_id" ref="helpdesk_ticket_analysis_dashboard_action"/>
</record>
<record id="action_helpdesk_ticket_analysis_dashboard_pivot_view" model="ir.actions.act_window.view">
<field name="sequence" eval="10"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="helpdesk_ticket_view_pivot_analysis_dashboard"/>
<field name="act_window_id" ref="helpdesk_ticket_analysis_dashboard_action"/>
</record>
<record id="helpdesk_ticket_action_7dayssuccess" model="ir.actions.act_window">
<field name="name">Success Rate Analysis</field>
<field name="res_model">helpdesk.ticket.report.analysis</field>
<field name="view_mode">pivot,graph</field>
<field name="domain" eval="[('close_date', '>=', (DateTime.today() - relativedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S'))]"/>
<field name="search_view_id" ref="helpdesk.helpdesk_ticket_report_analysis_view_search"/>
<field name="context">{
'search_default_is_close': True,
'search_default_my_ticket': True,
'search_default_sla_success': True,
'search_default_closed_on': 'custom_closed_on_last_7_days',
'pivot_measures': ['__count__'],
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No data yet!
</p><p>
Create tickets to get statistics.
</p>
</field>
</record>
<record id="helpdesk_ticket_action_7dayssuccess_pivot" model="ir.actions.act_window.view">
<field name="sequence" eval="19"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="helpdesk.helpdesk_ticket_view_pivot_analysis_7dayssuccess_inherit_dashboard"/>
<field name="act_window_id" ref="helpdesk_ticket_action_7dayssuccess"/>
</record>
<record id="helpdesk_ticket_action_7dayssuccess_graph" model="ir.actions.act_window.view">
<field name="sequence" eval="29"/>
<field name="view_mode">graph</field>
<field name="act_window_id" ref="helpdesk_ticket_action_7dayssuccess"/>
</record>
<record id="helpdesk_ticket_action_dashboard" model="ir.actions.act_window">
<field name="name">Ticket Analysis</field>
<field name="res_model">helpdesk.ticket.report.analysis</field>
<field name="view_mode">pivot,graph</field>
<field name="search_view_id" ref="helpdesk.helpdesk_ticket_report_analysis_view_search"/>
<field name="domain">[('stage_id.fold', '=', False)]</field>
<field name="context">{
'search_default_my_ticket': True,
'search_default_is_open': True,
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No data yet!
</p><p>
Create tickets to get statistics.
</p>
</field>
</record>
<record id="helpdesk_ticket_action_dashboard_pivot" model="ir.actions.act_window.view">
<field name="sequence" eval="16"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="helpdesk.helpdesk_ticket_view_pivot_analysis_inherit_dashboard"/>
<field name="act_window_id" ref="helpdesk_ticket_action_dashboard"/>
</record>
<record id="helpdesk_ticket_action_dashboard_graph" model="ir.actions.act_window.view">
<field name="sequence" eval="25"/>
<field name="view_mode">graph</field>
<field name="view_id" ref="helpdesk.helpdesk_ticket_view_graph_analysis_inherit_dashboard"/>
<field name="act_window_id" ref="helpdesk_ticket_action_dashboard"/>
</record>
</odoo>

View File

@ -0,0 +1,144 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="group_helpdesk_user" model="res.groups">
<field name="name">User</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="category_id" ref="base.module_category_services_helpdesk"/>
</record>
<record id="group_helpdesk_manager" model="res.groups">
<field name="name">Administrator</field>
<field name="category_id" ref="base.module_category_services_helpdesk"/>
<field name="implied_ids" eval="[(4, ref('group_helpdesk_user')), (4, ref('mail.group_mail_canned_response_admin'))]"/>
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
<record id="group_use_sla" model="res.groups">
<field name="name">Show SLA Policies</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="group_use_rating" model="res.groups">
<field name="name">Show Customer Ratings</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="group_auto_assignment" model="res.groups">
<field name="name">Auto Assigment</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="base.default_user" model="res.users">
<field name="groups_id" eval="[(4, ref('helpdesk.group_helpdesk_manager'))]"/>
</record>
<data noupdate="1">
<!-- Manager gets all team access rights -->
<record id="helpdesk_manager_rule" model="ir.rule">
<field name="name">Helpdesk Administrator</field>
<field name="model_id" ref="model_helpdesk_team"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="groups" eval="[(4, ref('group_helpdesk_manager'))]"/>
</record>
<record id="helpdesk_ticket_manager_rule" model="ir.rule">
<field name="name">Helpdesk Ticket Administrator</field>
<field name="model_id" ref="model_helpdesk_ticket"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="groups" eval="[(4, ref('group_helpdesk_manager'))]"/>
</record>
<!-- user only gets to read his own teams (or open teams) -->
<record id="helpdesk_user_rule" model="ir.rule">
<field name="name">Helpdesk User</field>
<field name="model_id" ref="model_helpdesk_team"/>
<field name="domain_force">['|',
('privacy_visibility', '!=', 'invited_internal'),
('message_partner_ids', 'in', [user.partner_id.id])
]</field>
<field name="groups" eval="[(4, ref('group_helpdesk_user'))]"/>
</record>
<record id="helpdesk_ticket_user_rule" model="ir.rule">
<field name="name">Helpdesk Ticket User</field>
<field name="model_id" ref="model_helpdesk_ticket"/>
<field name="domain_force">['|',
'|',
('team_id.privacy_visibility', '!=', 'invited_internal'),
('team_id.message_partner_ids', 'in', [user.partner_id.id]),
('message_partner_ids', 'in', [user.partner_id.id]),
]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Split by company on tickets, teams and SLAs -->
<record id="helpdesk_ticket_company_rule" model="ir.rule">
<field name="name">Ticket: multi-company</field>
<field name="model_id" ref="model_helpdesk_ticket"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="helpdesk_team_company_rule" model="ir.rule">
<field name="name">Team: multi-company</field>
<field name="model_id" ref="model_helpdesk_team"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
</record>
<record id="helpdesk_sla_company_rule" model="ir.rule">
<field name="name">SLA: multi-company</field>
<field name="model_id" ref="model_helpdesk_sla"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
</record>
<record id="helpdesk_portal_ticket_rule" model="ir.rule">
<field name="name">Tickets: portal users: following</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="domain_force">[
('team_privacy_visibility', '=', 'portal'),
'|',
('message_partner_ids', 'in', [user.partner_id.id]),
('team_id.message_partner_ids', 'in', [user.partner_id.id])
]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
<record id="helpdesk_sla_report_analysis_rule_manager" model="ir.rule">
<field name="name">Helpdesk SLA Report: multi-company</field>
<field name="model_id" ref="model_helpdesk_sla_report_analysis"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="helpdesk_sla_report_analysis_rule_user" model="ir.rule">
<field name="name">Helpdesk SLA Report: Helpdesk Ticket User</field>
<field name="model_id" ref="model_helpdesk_sla_report_analysis"/>
<field name="domain_force">[
'|',
('team_id.privacy_visibility', '!=', 'invited_internal'),
'|',
('team_id.message_partner_ids', 'in', [user.partner_id.id]),
('ticket_id.message_partner_ids', 'in', [user.partner_id.id]),
]</field>
<field name="groups" eval="[(4, ref('group_helpdesk_user'))]"/>
</record>
<record id="helpdesk_ticket_report_analysis_rule_multi_company" model="ir.rule">
<field name="name">Helpdesk Ticket Report: multi-company</field>
<field name="model_id" ref="model_helpdesk_ticket_report_analysis"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="helpdesk_ticket_report_analysis_rule_user" model="ir.rule">
<field name="name">Helpdesk Ticket Report: Helpdesk Ticket User</field>
<field name="model_id" ref="model_helpdesk_ticket_report_analysis"/>
<field name="domain_force">[
'|',
('team_id.privacy_visibility', '!=', 'invited_internal'),
'|',
('team_id.message_partner_ids', 'in', [user.partner_id.id]),
('ticket_id.message_partner_ids', 'in', [user.partner_id.id]),
]</field>
<field name="groups" eval="[(4, ref('group_helpdesk_user'))]"/>
</record>
<record id="helpdesk_ticket_report_analysis_rule_manager" model="ir.rule">
<field name="name">Helpdesk Ticket Report: Helpdesk Ticket Administrator</field>
<field name="model_id" ref="model_helpdesk_ticket_report_analysis"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="groups" eval="[(4, ref('group_helpdesk_manager'))]"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,22 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_helpdesk_tag,helpdesk.tag,model_helpdesk_tag,helpdesk.group_helpdesk_user,1,1,1,1
access_helpdesk_tag_internal_user,helpdesk.tag,model_helpdesk_tag,base.group_user,1,0,0,0
access_helpdesk_sla_internal_user,helpdesk.sla,model_helpdesk_sla,base.group_user,1,0,0,0
access_helpdesk_sla_status_internal_user,helpdesk.sla.status,model_helpdesk_sla_status,base.group_user,1,0,0,0
access_helpdesk_sla_manager,helpdesk.sla.manager,model_helpdesk_sla,helpdesk.group_helpdesk_manager,1,1,1,1
access_helpdesk_stage_internal_user,helpdesk.stage,model_helpdesk_stage,base.group_user,1,0,0,0
access_helpdesk_stage_manager,helpdesk.stage.manager,model_helpdesk_stage,helpdesk.group_helpdesk_manager,1,1,1,1
access_helpdesk_stage_portal,helpdesk.stage.portal,helpdesk.model_helpdesk_stage,base.group_portal,1,0,0,0
access_helpdesk_ticket_portal,helpdesk.ticket.portal,helpdesk.model_helpdesk_ticket,base.group_portal,1,0,0,0
access_helpdesk_ticket,helpdesk.ticket,model_helpdesk_ticket,helpdesk.group_helpdesk_user,1,1,1,1
access_helpdesk_ticket_internal_user,helpdesk.ticket_on_internal_user,model_helpdesk_ticket,base.group_user,1,0,0,0
access_helpdesk_team_public_public,helpdesk.team,model_helpdesk_team,base.group_public,1,0,0,0
access_helpdesk_team_public_portal,helpdesk.team,model_helpdesk_team,base.group_portal,1,0,0,0
access_helpdesk_team_public_employee,helpdesk.team,model_helpdesk_team,base.group_user,1,0,0,0
access_helpdesk_team_manager,helpdesk.team.manager,model_helpdesk_team,helpdesk.group_helpdesk_manager,1,1,1,1
access_helpdesk_sla_report_analysis_manager,access_helpdesk_sla_report_analysis_manager,model_helpdesk_sla_report_analysis,helpdesk.group_helpdesk_manager,1,0,0,0
access_helpdesk_sla_report_analysis_user,access_helpdesk_sla_report_analysis_user,model_helpdesk_sla_report_analysis,helpdesk.group_helpdesk_user,1,0,0,0
access_mail_activity_type_helpdesk_manager,mail.activity.type.helpdesk.manager,mail.model_mail_activity_type,helpdesk.group_helpdesk_manager,1,1,1,1
access_helpdesk_ticket_report_analysis_manager,helpdesk.ticket.report.analysis.manager,model_helpdesk_ticket_report_analysis,helpdesk.group_helpdesk_manager,1,0,0,0
access_helpdesk_ticket_report_analysis_user,helpdesk.ticket.report.analysis.user,model_helpdesk_ticket_report_analysis,helpdesk.group_helpdesk_user,1,0,0,0
access_helpdesk_stage_delete_wizard,helpdesk.stage.delete.wizard,model_helpdesk_stage_delete_wizard,helpdesk.group_helpdesk_manager,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_helpdesk_tag helpdesk.tag model_helpdesk_tag helpdesk.group_helpdesk_user 1 1 1 1
3 access_helpdesk_tag_internal_user helpdesk.tag model_helpdesk_tag base.group_user 1 0 0 0
4 access_helpdesk_sla_internal_user helpdesk.sla model_helpdesk_sla base.group_user 1 0 0 0
5 access_helpdesk_sla_status_internal_user helpdesk.sla.status model_helpdesk_sla_status base.group_user 1 0 0 0
6 access_helpdesk_sla_manager helpdesk.sla.manager model_helpdesk_sla helpdesk.group_helpdesk_manager 1 1 1 1
7 access_helpdesk_stage_internal_user helpdesk.stage model_helpdesk_stage base.group_user 1 0 0 0
8 access_helpdesk_stage_manager helpdesk.stage.manager model_helpdesk_stage helpdesk.group_helpdesk_manager 1 1 1 1
9 access_helpdesk_stage_portal helpdesk.stage.portal helpdesk.model_helpdesk_stage base.group_portal 1 0 0 0
10 access_helpdesk_ticket_portal helpdesk.ticket.portal helpdesk.model_helpdesk_ticket base.group_portal 1 0 0 0
11 access_helpdesk_ticket helpdesk.ticket model_helpdesk_ticket helpdesk.group_helpdesk_user 1 1 1 1
12 access_helpdesk_ticket_internal_user helpdesk.ticket_on_internal_user model_helpdesk_ticket base.group_user 1 0 0 0
13 access_helpdesk_team_public_public helpdesk.team model_helpdesk_team base.group_public 1 0 0 0
14 access_helpdesk_team_public_portal helpdesk.team model_helpdesk_team base.group_portal 1 0 0 0
15 access_helpdesk_team_public_employee helpdesk.team model_helpdesk_team base.group_user 1 0 0 0
16 access_helpdesk_team_manager helpdesk.team.manager model_helpdesk_team helpdesk.group_helpdesk_manager 1 1 1 1
17 access_helpdesk_sla_report_analysis_manager access_helpdesk_sla_report_analysis_manager model_helpdesk_sla_report_analysis helpdesk.group_helpdesk_manager 1 0 0 0
18 access_helpdesk_sla_report_analysis_user access_helpdesk_sla_report_analysis_user model_helpdesk_sla_report_analysis helpdesk.group_helpdesk_user 1 0 0 0
19 access_mail_activity_type_helpdesk_manager mail.activity.type.helpdesk.manager mail.model_mail_activity_type helpdesk.group_helpdesk_manager 1 1 1 1
20 access_helpdesk_ticket_report_analysis_manager helpdesk.ticket.report.analysis.manager model_helpdesk_ticket_report_analysis helpdesk.group_helpdesk_manager 1 0 0 0
21 access_helpdesk_ticket_report_analysis_user helpdesk.ticket.report.analysis.user model_helpdesk_ticket_report_analysis helpdesk.group_helpdesk_user 1 0 0 0
22 access_helpdesk_stage_delete_wizard helpdesk.stage.delete.wizard model_helpdesk_stage_delete_wizard helpdesk.group_helpdesk_manager 1 1 1 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

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