[IMP] project,*: improve user experience

This commit (and its enterprise equivalent) purpose is to smooth the
user experience and remove unnecessary steps.

The commits:
- Remove all stat buttons except the status and collaborators one from
  the project edit form.
- Add multiple small changes to string/name fo fields in views
- Change the custom O2M widget for a standard M2M for the management of
  child tasks
- Add the milestone field to the portal page of shared project.
- Remove the wizard 'marked_as_reached_milestone'
- Add the automatic generation of milestone when a SO is confirmed if
  some SOL need it

task-2941835

closes odoo/odoo#98546

Related: odoo/enterprise#30628
Related: odoo/upgrade#3798
Signed-off-by: Laurent Stukkens (ltu) <ltu@odoo.com>
This commit is contained in:
damr
2022-09-07 23:10:37 +02:00
committed by Xavier BOL (xbo)
parent 0a8c3e7451
commit f3974bea4b
36 changed files with 215 additions and 642 deletions
+3 -3
View File
@@ -33,9 +33,9 @@ class TimesheetCustomerPortal(CustomerPortal):
'name': {'input': 'name', 'label': _('Search in Description')},
}
def _task_get_searchbar_sortings(self):
values = super()._task_get_searchbar_sortings()
values['progress'] = {'label': _('Progress'), 'order': 'progress asc', 'sequence': 9}
def _task_get_searchbar_sortings(self, milestones_allowed):
values = super()._task_get_searchbar_sortings(milestones_allowed)
values['progress'] = {'label': _('Progress'), 'order': 'progress asc', 'sequence': 10}
return values
def _get_searchbar_groupby(self):
-19
View File
@@ -220,25 +220,6 @@ class Project(models.Model):
warning_msg, self.env.ref('hr_timesheet.timesheet_action_project').id,
_('See timesheet entries'), {'active_ids': projects_with_timesheets.ids})
def action_show_timesheets_by_employee_invoice_type(self):
action = self.env["ir.actions.actions"]._for_xml_id("hr_timesheet.timesheet_action_all")
#Let's put the chart view first
new_views = []
for view in action['views']:
new_views.insert(0, view) if view[1] == 'graph' else new_views.append(view)
action.update({
'display_name': _("Timesheets"),
'domain': [('project_id', '=', self.id)],
'context': {
'default_project_id': self.id,
'search_default_groupby_employee': True,
'search_default_groupby_timesheet_invoice_type': True
},
'views': new_views
})
return action
def _convert_project_uom_to_timesheet_encode_uom(self, time):
uom_from = self.company_id.project_time_mode_id
uom_to = self.env.company.timesheet_encode_uom_id
@@ -49,13 +49,13 @@
<t t-set="is_uom_day" t-value="timesheet_ids._is_timesheet_encode_uom_day()"/>
</xpath>
<xpath expr="//thead/tr/t[@t-set='number_of_header']" position="attributes">
<attribute name="t-value">8</attribute>
<attribute name="t-value">9</attribute>
</xpath>
<xpath expr="//thead/tr/th[@name='project_portal_assignees']" position="after">
<xpath expr="//thead/tr/th[@name='project_portal_milestones']" position="after">
<th t-if="is_uom_day" class="text-end">Days Spent</th>
<th t-else="" class="text-end">Hours Spent</th>
</xpath>
<xpath expr="//tbody/t/tr/td[@name='project_portal_assignees']" position="after">
<xpath expr="//tbody/t/tr/td[@name='project_portal_milestones']" position="after">
<td class="text-end">
<t t-if="is_uom_day">
<t t-out="timesheet_ids._convert_hours_to_days(task.effective_hours)"/>
@@ -81,21 +81,6 @@
<field name="inherit_id" ref="project.edit_project"/>
<field name="priority">24</field>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_get_list_view']" position="after">
<button class="oe_stat_button" name="action_show_timesheets_by_employee_invoice_type" type="object" icon="fa-clock-o" attrs="{'invisible': [('allow_timesheets', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user">
<div class="o_field_widget o_stat_info">
<div class="oe_inline">
<span class="o_stat_value me-1">
<field name="total_timesheet_time" widget="statinfo" nolabel="1"/>
</span>
<span class="o_stat_value">
<field name="timesheet_encode_uom_id" class="o_stat_text" options="{'no_open' : True}"/>
</span>
</div>
<span class="o_stat_text">Recorded</span>
</div>
</button>
</xpath>
<xpath expr="//div[@name='dates']" position="after">
<field name="allocated_hours" widget="timesheet_uom_no_toggle" attrs="{'invisible': [('allow_timesheets', '=', False)]}"/>
</xpath>
-2
View File
@@ -48,7 +48,6 @@
'data/mail_template_data.xml',
'data/project_data.xml',
'wizard/project_task_type_delete_views.xml',
'wizard/project_milestone_reach_wizard.xml',
],
'demo': [
'data/mail_template_demo.xml',
@@ -73,7 +72,6 @@
'project/static/src/js/project_rating_graph_view.js',
'project/static/src/js/project_rating_pivot_view.js',
'project/static/src/js/project_task_kanban_examples.js',
'project/static/src/js/subtasks_list_renderer.js',
'project/static/src/js/tours/project.js',
'project/static/src/js/project_calendar.js',
'project/static/src/js/widgets/*',
+55 -18
View File
@@ -45,6 +45,10 @@ class ProjectCustomerPortal(CustomerPortal):
project=project,
task_url=f'projects/{project.id}/task',
)
# default value is set to 'project' in _prepare_tasks_values, so we have to set it to 'none' here.
if not groupby:
values['groupby'] = 'none'
return self._get_page_view_values(project, access_token, values, 'my_projects_history', False, **kwargs)
def _prepare_project_domain(self):
@@ -246,28 +250,33 @@ class ProjectCustomerPortal(CustomerPortal):
}
return self._get_page_view_values(task, access_token, values, history, False, **kwargs)
def _task_get_searchbar_sortings(self):
return {
def _task_get_searchbar_sortings(self, milestones_allowed):
values = {
'date': {'label': _('Newest'), 'order': 'create_date desc', 'sequence': 1},
'name': {'label': _('Title'), 'order': 'name', 'sequence': 2},
'project': {'label': _('Project'), 'order': 'project_id, stage_id', 'sequence': 3},
'users': {'label': _('Assignees'), 'order': 'user_ids', 'sequence': 4},
'stage': {'label': _('Stage'), 'order': 'stage_id, project_id', 'sequence': 5},
'status': {'label': _('Status'), 'order': 'kanban_state', 'sequence': 6},
'priority': {'label': _('Priority'), 'order': 'priority desc', 'sequence': 7},
'date_deadline': {'label': _('Deadline'), 'order': 'date_deadline asc', 'sequence': 8},
'update': {'label': _('Last Stage Update'), 'order': 'date_last_stage_update desc', 'sequence': 10},
'priority': {'label': _('Priority'), 'order': 'priority desc', 'sequence': 8},
'date_deadline': {'label': _('Deadline'), 'order': 'date_deadline asc', 'sequence': 9},
'update': {'label': _('Last Stage Update'), 'order': 'date_last_stage_update desc', 'sequence': 11},
}
if milestones_allowed:
values['milestone'] = {'label': _('Milestone'), 'order': 'milestone_id', 'sequence': 7}
return values
def _task_get_searchbar_groupby(self):
def _task_get_searchbar_groupby(self, milestones_allowed):
values = {
'none': {'input': 'none', 'label': _('None'), 'order': 1},
'project': {'input': 'project', 'label': _('Project'), 'order': 2},
'stage': {'input': 'stage', 'label': _('Stage'), 'order': 4},
'status': {'input': 'status', 'label': _('Status'), 'order': 5},
'priority': {'input': 'priority', 'label': _('Priority'), 'order': 6},
'customer': {'input': 'customer', 'label': _('Customer'), 'order': 9},
'priority': {'input': 'priority', 'label': _('Priority'), 'order': 7},
'customer': {'input': 'customer', 'label': _('Customer'), 'order': 10},
}
if milestones_allowed:
values['milestone'] = {'input': 'milestone', 'label': _('Milestone'), 'order': 6}
return dict(sorted(values.items(), key=lambda item: item[1]["order"]))
def _task_get_groupby_mapping(self):
@@ -275,6 +284,7 @@ class ProjectCustomerPortal(CustomerPortal):
'project': 'project_id',
'stage': 'stage_id',
'customer': 'partner_id',
'milestone': 'milestone_id',
'priority': 'priority',
'status': 'kanban_state',
}
@@ -286,7 +296,7 @@ class ProjectCustomerPortal(CustomerPortal):
return order
return '%s, %s' % (field_name, order)
def _task_get_searchbar_inputs(self):
def _task_get_searchbar_inputs(self, milestones_allowed):
values = {
'all': {'input': 'all', 'label': _('Search in All'), 'order': 1},
'content': {'input': 'content', 'label': Markup(_('Search <span class="nolabel"> (in Content)</span>')), 'order': 1},
@@ -295,9 +305,12 @@ class ProjectCustomerPortal(CustomerPortal):
'users': {'input': 'users', 'label': _('Search in Assignees'), 'order': 3},
'stage': {'input': 'stage', 'label': _('Search in Stages'), 'order': 4},
'status': {'input': 'status', 'label': _('Search in Status'), 'order': 5},
'priority': {'input': 'priority', 'label': _('Search in Priority'), 'order': 6},
'message': {'input': 'message', 'label': _('Search in Messages'), 'order': 10},
'priority': {'input': 'priority', 'label': _('Search in Priority'), 'order': 7},
'message': {'input': 'message', 'label': _('Search in Messages'), 'order': 11},
}
if milestones_allowed:
values['milestone'] = {'input': 'milestone', 'label': _('Search in Milestone'), 'order': 6}
return dict(sorted(values.items(), key=lambda item: item[1]["order"]))
def _task_get_search_domain(self, search_in, search):
@@ -315,6 +328,8 @@ class ProjectCustomerPortal(CustomerPortal):
search_domain.append([('project_id', 'ilike', search)])
if search_in in ('ref', 'all'):
search_domain.append([('id', 'ilike', search)])
if search_in in ('milestone', 'all'):
search_domain.append([('milestone_id', 'ilike', search)])
if search_in in ('users', 'all'):
user_ids = request.env['res.users'].sudo().search([('name', 'ilike', search)])
search_domain.append([('user_ids', 'in', user_ids.ids)])
@@ -328,12 +343,15 @@ class ProjectCustomerPortal(CustomerPortal):
def _prepare_tasks_values(self, page, date_begin, date_end, sortby, search, search_in, groupby, url="/my/tasks", domain=None, su=False):
values = self._prepare_portal_layout_values()
searchbar_sortings = dict(sorted(self._task_get_searchbar_sortings().items(),
key=lambda item: item[1]["sequence"]))
searchbar_inputs = self._task_get_searchbar_inputs()
searchbar_groupby = self._task_get_searchbar_groupby()
Task = request.env['project.task']
milestone_domain = AND([domain, [('allow_milestones', '=', 'True')]])
milestones_allowed = Task.sudo().search_count(milestone_domain, limit=1) == 1
searchbar_sortings = dict(sorted(self._task_get_searchbar_sortings(milestones_allowed).items(),
key=lambda item: item[1]["sequence"]))
searchbar_inputs = self._task_get_searchbar_inputs(milestones_allowed)
searchbar_groupby = self._task_get_searchbar_groupby(milestones_allowed)
if not domain:
domain = []
if not su and Task.check_access_rights('read'):
@@ -341,17 +359,20 @@ class ProjectCustomerPortal(CustomerPortal):
Task = Task.sudo()
# default sort by value
if not sortby:
if not sortby or (sortby == 'milestone' and not milestones_allowed):
sortby = 'date'
order = searchbar_sortings[sortby]['order']
# default group by value
if not groupby:
if not groupby or (groupby == 'milestone' and not milestones_allowed):
groupby = 'project'
if date_begin and date_end:
domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)]
# search reset if needed
if not milestones_allowed and search_in == 'milestone':
search_in = 'all'
# search
if search and search_in:
domain += self._task_get_search_domain(search_in, search)
@@ -363,10 +384,25 @@ class ProjectCustomerPortal(CustomerPortal):
tasks = Task.search(domain, order=order, limit=self._items_per_page, offset=pager_offset)
request.session['my_project_tasks_history' if url.startswith('/my/projects') else 'my_tasks_history'] = tasks.ids[:100]
tasks_project_allow_milestone = tasks.filtered(lambda t: t.allow_milestones)
tasks_no_milestone = tasks - tasks_project_allow_milestone
groupby_mapping = self._task_get_groupby_mapping()
group = groupby_mapping.get(groupby)
if group:
grouped_tasks = [Task.concat(*g) for k, g in groupbyelem(tasks, itemgetter(group))]
if group == 'milestone_id':
grouped_tasks = [Task.concat(*g) for k, g in groupbyelem(tasks_project_allow_milestone, itemgetter(group))]
if not grouped_tasks:
grouped_tasks = [tasks_no_milestone]
else:
if grouped_tasks[len(grouped_tasks) - 1][0].milestone_id and tasks_no_milestone:
grouped_tasks.append(tasks_no_milestone)
else:
grouped_tasks[len(grouped_tasks) - 1] |= tasks_no_milestone
else:
grouped_tasks = [Task.concat(*g) for k, g in groupbyelem(tasks, itemgetter(group))]
else:
grouped_tasks = [tasks]
@@ -382,6 +418,7 @@ class ProjectCustomerPortal(CustomerPortal):
'date': date_begin,
'date_end': date_end,
'grouped_tasks': get_grouped_tasks,
'allow_milestone': milestones_allowed,
'page_name': 'task',
'default_url': url,
'task_url': 'tasks',
+1 -32
View File
@@ -778,21 +778,6 @@ class Project(models.Model):
action_context['search_default_project_id'] = self.id
return dict(action, context=action_context)
def action_view_analytic_account_entries(self):
self.ensure_one()
return {
'res_model': 'account.analytic.line',
'type': 'ir.actions.act_window',
'name': _("Gross Margin"),
'domain': [('account_id', '=', self.analytic_account_id.id)],
'views': [(self.env.ref('analytic.view_account_analytic_line_tree').id, 'list'),
(self.env.ref('analytic.view_account_analytic_line_form').id, 'form'),
(self.env.ref('analytic.view_account_analytic_line_graph').id, 'graph'),
(self.env.ref('analytic.view_account_analytic_line_pivot').id, 'pivot')],
'view_mode': 'tree,form,graph,pivot',
'context': {'search_default_group_date': 1, 'default_account_id': self.analytic_account_id.id}
}
def action_get_list_view(self):
self.ensure_one()
return {
@@ -899,7 +884,7 @@ class Project(models.Model):
'show': True,
'sequence': 3,
}]
if self.user_has_groups('project.group_project_rating'):
if self.rating_count != 0 and self.user_has_groups('project.group_project_rating'):
if self.rating_avg >= rating_data.RATING_AVG_TOP:
icon = 'smile-o text-success'
elif self.rating_avg >= rating_data.RATING_AVG_OK:
@@ -2422,22 +2407,6 @@ class Task(models.Model):
return children
return children + children._get_all_subtasks(depth - 1)
def get_milestone_to_mark_as_reached_action(self):
""" Return an action if the milestone can be marked as reached otherwise return False """
milestones = self.milestone_id.filtered('can_be_marked_as_done')
if milestones:
wizard = self.env['project.milestone.reach.wizard'].create({'line_ids': [Command.create({'milestone_id': m.id}) for m in milestones]})
return {
'name': _('Mark milestone as reached'),
'view_mode': 'form',
'res_model': 'project.milestone.reach.wizard',
'views': [(self.env.ref('project.project_milestone_reach_wizard_view_form').id, 'form')],
'type': 'ir.actions.act_window',
'res_id': wizard.id,
'target': 'new',
}
return False
def action_open_parent_task(self):
return {
'name': _('Parent Task'),
@@ -42,5 +42,3 @@ access_project_collaborator_user,project.collaborator.user,model_project_collabo
access_project_collaborator_portal,project.collaborator.portal,model_project_collaborator,base.group_portal,1,0,0,0
access_project_share_manager,project.share.wizard.manager,model_project_share_wizard,project.group_project_manager,1,1,1,0
access_project_personal_stage,project.personal.stage.user,model_project_task_stage_personal,base.group_user,1,1,1,1
access_project_milestone_reach_wizard_project_user,project.milestone.reach.wizard.project.user,model_project_milestone_reach_wizard,project.group_project_user,1,1,1,0
access_project_milestone_reach_line_wizard_project_user,project.milestone.reach.line.wizard.project.user,model_project_milestone_reach_line_wizard,project.group_project_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
42 access_project_collaborator_portal project.collaborator.portal model_project_collaborator base.group_portal 1 0 0 0
43 access_project_share_manager project.share.wizard.manager model_project_share_wizard project.group_project_manager 1 1 1 0
44 access_project_personal_stage project.personal.stage.user model_project_task_stage_personal base.group_user 1 1 1 1
access_project_milestone_reach_wizard_project_user project.milestone.reach.wizard.project.user model_project_milestone_reach_wizard project.group_project_user 1 1 1 0
access_project_milestone_reach_line_wizard_project_user project.milestone.reach.line.wizard.project.user model_project_milestone_reach_line_wizard project.group_project_user 1 1 1 0
@@ -1,7 +1,7 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { formatFloat, formatMonetary } from '@web/views/fields/formatters';
import { formatFloat } from '@web/views/fields/formatters';
import { session } from '@web/session';
import { ViewButton } from '@web/views/view_button/view_button';
import { FormViewDialog } from '@web/views/view_dialogs/form_view_dialog';
@@ -70,9 +70,9 @@ export class ProjectRightSidePanel extends Component {
}
formatMonetary(value, options = {}) {
const valueFormatted = formatMonetary(value, {
currencyId: this.currencyId,
const valueFormatted = formatFloat(value, {
...options,
'digits': [false, 0],
'noSymbol': true,
});
const currency = session.currencies[this.currencyId];
@@ -1,20 +0,0 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { ListRenderer } from '@web/views/list/list_renderer';
export class SubtaskListRenderer extends ListRenderer {
setup() {
super.setup();
this.dialog = useService("dialog");
}
async onDeleteRecord(record) {
this.dialog.add(ConfirmationDialog, {
body: this.env._t("Are you sure you want to delete this record?"),
confirm: () => super.onDeleteRecord(record),
cancel: () => {},
});
}
}
@@ -1,19 +0,0 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { X2ManyField } from '@web/views/fields/x2many/x2many_field';
import { SubtaskListRenderer } from './subtask_list_renderer';
export class SubtaskOne2ManyField extends X2ManyField {
get Renderer() {
return this.viewMode === 'list' ? SubtaskListRenderer : super.Renderer;
}
}
SubtaskOne2ManyField.components = {
...X2ManyField,
ListRenderer: SubtaskListRenderer,
}
registry.category("fields").add("subtasks_one2many", SubtaskOne2ManyField);
@@ -111,25 +111,6 @@ const ProjectFormController = FormController.extend({
dialog.open();
},
async _applyChanges(dataPointID, changes, event) {
const result = await this._super(...arguments);
if (event.data.force_save && 'stage_id' in changes) {
this._getMilestoneReachWizardAction([parseInt(event.target.res_id)]);
}
return result;
},
async _getMilestoneReachWizardAction(recordIds) {
const action = await this._rpc({
model: 'project.task',
method: 'get_milestone_to_mark_as_reached_action',
args: [recordIds],
});
if (action) {
this.trigger_up('do-action', {
action,
});
}
},
async _saveRecord(recordID, options) {
const task = this.model.get(recordID || this.handle);
const result = await this._super(...arguments);
@@ -1,34 +0,0 @@
/** @odoo-module **/
import ListRenderer from 'web.ListRenderer';
import Dialog from 'web.Dialog';
import { _t } from 'web.core';
export const SubTasksListRenderer = ListRenderer.extend({
events: Object.assign({}, ListRenderer.prototype.events, {
'click tr .o_list_record_remove': '_onClickOpenDialog',
}),
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Confirmation dialog is opened when deleted any record from list.
*
* @private
* @param {MouseEvent} ev
*/
_onClickOpenDialog(ev) {
ev.stopPropagation();
Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), {
confirm_callback: () => {
this._onRemoveIconClick(ev);
},
cancel_callback: () => {
return false;
},
});
},
});
@@ -1,21 +0,0 @@
/** @odoo-module **/
import { FieldOne2Many } from 'web.relational_fields';
import fieldRegistry from 'web.field_registry';
import { SubTasksListRenderer } from '../subtasks_list_renderer';
const SubTasksFieldOne2Many = FieldOne2Many.extend({
/**
* We want to use our custom renderer for the list.
*
* @override
*/
_getRenderer() {
if (this.view.arch.tag === 'tree') {
return SubTasksListRenderer;
}
return this._super.apply(...arguments);
},
});
fieldRegistry.add('subtasks_one2many', SubTasksFieldOne2Many);
+28 -20
View File
@@ -472,12 +472,13 @@ class TestProjectrecurrence(TransactionCase):
'name': 'Parent Task',
'project_id': self.project_recurring.id
})
child_task = self.env['project.task'].create({
'name': 'Child Task',
'parent_id': parent_task.id,
})
domain = [('project_id', '=', self.project_recurring.id)]
with Form(parent_task.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.new() as subtask_form:
subtask_form.name = 'Test Subtask 1'
with freeze_time("2020-01-01"):
with Form(parent_task.child_ids.with_context({'tracking_disable': True})) as form:
with Form(child_task.with_context({'tracking_disable': True})) as form:
form.description = 'my super recurring task bla bla bla'
form.date_deadline = datetime(2020, 2, 1)
form.display_project_id = parent_task.project_id
@@ -569,12 +570,20 @@ class TestProjectrecurrence(TransactionCase):
'project_id': self.project_recurring.id
})
domain = [('project_id', '=', self.project_recurring.id)]
child_task_1, child_task_2_recurrence = self.env['project.task'].create([
{'name': 'Child task 1'},
{'name': 'Child task 2 that have recurrence'},
])
with Form(parent_task.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.new() as subtask_form:
subtask_form.name = 'Child task 1'
with Form(parent_task.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.new() as subtask_form:
subtask_form.name = 'Child task 2 that have recurrence'
task_form.child_ids.add(child_task_1)
task_form.child_ids.add(child_task_2_recurrence)
grand_child_task_1 = self.env['project.task'].create({
'name': 'Grandchild task 1 (recurrent)',
})
grand_child_task_2_recurrence = self.env['project.task'].create({
'name': 'Grandchild task 2',
})
with freeze_time("2020-01-01"):
recurrent_subtask = parent_task.child_ids[0]
with Form(recurrent_subtask.with_context(tracking_disable=True)) as task_form:
@@ -586,11 +595,8 @@ class TestProjectrecurrence(TransactionCase):
task_form.repeat_on_month = 'date'
task_form.repeat_day = '15'
task_form.date_deadline = datetime(2020, 2, 1)
with task_form.child_ids.new() as subtask_form:
subtask_form.name = 'Grandchild task 1 (recurrent)'
with task_form.child_ids.new() as subtask_form:
subtask_form.name = 'Grandchild task 2'
task_form.child_ids.add(grand_child_task_1)
task_form.child_ids.add(grand_child_task_2_recurrence)
# configure recurring subtask
recurrent_subsubtask = recurrent_subtask.child_ids.filtered(lambda t: t.name == 'Grandchild task 1 (recurrent)')
non_recurrent_subsubtask = recurrent_subtask.child_ids.filtered(lambda t: t.name == 'Grandchild task 2')
@@ -602,18 +608,20 @@ class TestProjectrecurrence(TransactionCase):
subtask_form.repeat_number = 4
subtask_form.date_deadline = datetime(2020, 2, 3)
grand_child_task_3, grand_child_task_4, grand_child_task_5 = self.env['project.task'].create([
{'name': 'Grandchild task 3'},
{'name': 'Grandchild task 4'},
{'name': 'Grandchild task 5'},
])
# create non-recurring grandchild subtasks
with Form(non_recurrent_subsubtask.with_context(tracking_disable=True)) as subtask_form:
with subtask_form.child_ids.new() as subsubtask_form:
subsubtask_form.name = 'Grandchild task 3'
subtask_form.child_ids.add(grand_child_task_3)
non_recurrent_subsubtask = non_recurrent_subsubtask.child_ids
with Form(non_recurrent_subsubtask.with_context(tracking_disable=True)) as subtask_form:
with subtask_form.child_ids.new() as subsubtask_form:
subsubtask_form.name = 'Grandchild task 4'
subtask_form.child_ids.add(grand_child_task_4)
non_recurrent_subsubtask = non_recurrent_subsubtask.child_ids
with Form(non_recurrent_subsubtask.with_context(tracking_disable=True)) as subtask_form:
with subtask_form.child_ids.new() as subsubtask_form:
subsubtask_form.name = 'Grandchild task 5'
subtask_form.child_ids.add(grand_child_task_5)
self.assertTrue(recurrent_subtask.recurrence_id)
self.assertEqual(recurrent_subtask.recurrence_id.next_recurrence_date, date(2020, 1, 15))
+39 -27
View File
@@ -108,26 +108,25 @@ class TestProjectSubtasks(TestProjectCommon):
- The display project id should follow the project id
"""
# 1)
test_subtask_1 = self.env['project.task'].create({
'name': 'Test Subtask 1',
})
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.new() as subtask_form:
subtask_form.name = 'Test Subtask 1'
task_form.child_ids.add(test_subtask_1)
self.assertEqual(self.task_1.child_ids.project_id, self.project_pigs, "The project should be assigned from the default project.")
self.assertFalse(self.task_1.child_ids.display_project_id, "The display project of a sub task should be false to project_id.")
# 2)
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.edit(0) as subtask_form:
subtask_form.display_project_id = self.project_goats
task_form.child_ids[0].display_project_id = self.project_goats
self.assertEqual(self.task_1.project_id, self.project_pigs, "Changing the project of a subtask should not change parent project")
self.assertEqual(self.task_1.child_ids.display_project_id, self.project_goats, "Display Project of the task should be well assigned")
self.assertEqual(self.task_1.child_ids.project_id, self.project_goats, "Changing display project id on a subtask should change project id")
# 3)
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.edit(0) as subtask_form:
subtask_form.display_project_id = self.env['project.project']
task_form.child_ids[0].display_project_id = self.env['project.project']
self.assertFalse(self.task_1.child_ids.display_project_id, "Display Project of the task should be well assigned, to False")
self.assertEqual(self.task_1.child_ids.project_id, self.project_pigs, "Resetting display project to False on a subtask should change project id to parent project id")
@@ -142,8 +141,7 @@ class TestProjectSubtasks(TestProjectCommon):
# 5)
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.edit(0) as subtask_form:
subtask_form.display_project_id = self.project_goats
task_form.child_ids[0].display_project_id = self.project_goats
task_form.project_id = self.project_pigs
self.assertEqual(self.task_1.project_id, self.project_pigs, "Parent project should change back.")
@@ -162,10 +160,12 @@ class TestProjectSubtasks(TestProjectCommon):
self.assertFalse(orphan_subtask.parent_id, "Parent should be false")
# 7)
test_subtask_1 = self.env['project.task'].create({
'name': 'Test Subtask 1',
})
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.new() as subtask_form:
subtask_form.name = 'Test Subtask 1'
subtask_form.display_project_id = self.project_goats
task_form.child_ids.add(test_subtask_1)
task_form.child_ids[0].display_project_id = self.project_goats
with Form(self.task_1.child_ids.with_context({'tracking_disable': True})) as subtask_form:
subtask_form.display_project_id = self.env['project.project']
subtask_form.parent_id = self.env['project.task']
@@ -183,9 +183,11 @@ class TestProjectSubtasks(TestProjectCommon):
self.project_pigs.type_ids |= stage_a
self.project_pigs.type_ids |= stage_b
test_subtask_1 = self.env['project.task'].create({
'name': 'Test Subtask 1',
})
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.new() as subtask_form:
subtask_form.name = 'Test Subtask 1'
task_form.child_ids.add(test_subtask_1)
self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "The stage of the child task should be the default one of the project.")
@@ -194,16 +196,17 @@ class TestProjectSubtasks(TestProjectCommon):
self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "The stage of the child task should remain the same while changing parent task stage.")
test_subtask_2 = self.env['project.task'].create({
'name': 'Test Subtask 2',
})
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
task_form.child_ids.remove(0)
with task_form.child_ids.new() as subtask_form:
subtask_form.name = 'Test Subtask 2'
task_form.child_ids.remove(test_subtask_1.id)
task_form.child_ids.add(test_subtask_2)
self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "The stage of the child task should be the default one of the project even if parent stage id is different.")
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.edit(0) as subtask_form:
subtask_form.display_project_id = self.project_goats
task_form.child_ids[0].display_project_id = self.project_goats
self.assertEqual(self.task_1.child_ids.stage_id.name, "New", "The stage of the child task should be the default one of the display project id, once set.")
@@ -242,15 +245,24 @@ class TestProjectSubtasks(TestProjectCommon):
4) check the correct nb of sub-tasks is displayed in the 'sub-tasks' stat button and on the parent task kanban card
5) sub-tasks should be copied when the parent task is duplicated
"""
with Form(self.task_1.with_context({'tracking_disable': True})) as task_form:
with task_form.child_ids.new() as subtask_form:
subtask_form.name = 'Test Subtask 1'
subtask_form.display_project_id = self.env['project.project']
child_subtask = self.task_1.child_ids[0]
with Form(child_subtask.with_context(tracking_disable=True)) as subtask_form:
with subtask_form.child_ids.new() as subsubtask_form:
subsubtask_form.name = 'Test Subtask/Subtask'
test_subtask_1 = self.env['project.task'].create({
'name': 'Test Subtask 1',
})
task_form = Form(self.task_1.with_context({'tracking_disable': True}))
task_form.child_ids.add(test_subtask_1)
task_form.child_ids[0].display_project_id = self.env['project.project']
task = task_form.save()
child_subtask = self.task_1.child_ids[0]
test_subtask_2 = self.env['project.task'].create({
'name': 'Test Subtask 2',
})
with Form(child_subtask.with_context(tracking_disable=True)) as subtask_form:
subtask_form.child_ids.add(test_subtask_2)
subtask_form.child_ids[0].display_project_id = self.env['project.project']
self.assertEqual(task.subtask_count, 2, "Parent task should have 2 children")
task_2 = task.copy()
self.assertEqual(task_2.subtask_count, 2, "If the parent task is duplicated then the sub task should be copied")
@@ -1,21 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_collaborator_view_form" model="ir.ui.view">
<field name="name">project.collaborator.view.form</field>
<field name="model">project.collaborator</field>
<field name="arch" type="xml">
<form string="Project Collaborator" create="0" edit="0">
<sheet>
<group>
<field name="project_id" options="{'no_create': True}" />
<field name="partner_id" options="{'no_create': True}" />
</group>
</sheet>
</form>
</field>
</record>
<record id="project_sharing_access_view_tree" model="ir.ui.view">
<field name="name">project.collaborator.view.tree</field>
<field name="model">project.collaborator</field>
@@ -45,7 +30,7 @@
<record id="project_collaborator_action" model="ir.actions.act_window">
<field name="name">Project Collaborators</field>
<field name="res_model">project.collaborator</field>
<field name="view_mode">tree,form</field>
<field name="view_mode">tree</field>
<field name="domain">[('project_id', '=', active_id)]</field>
<field name="search_view_id" ref="project_collaborator_view_search"/>
<field name="help" type="html">
@@ -110,13 +110,15 @@
<thead>
<tr>
<!-- Allows overrides in modules -->
<t t-set="group_by_in_header_list" t-value="['priority', 'status', 'project', 'stage']"></t>
<t t-set="number_of_header" t-value="7"></t>
<t t-set="group_by_in_header_list" t-value="['priority', 'status', 'project', 'stage', 'milestone']"></t>
<t t-set="number_of_header" t-value="8"></t>
<!-- Computes the right colspan once and use it everywhere -->
<t t-set="grouped_tasks_colspan" t-value="number_of_header - 1 if groupby in group_by_in_header_list else number_of_header"></t>
<t t-set="grouped_tasks_colspan" t-value="grouped_tasks_colspan if allow_milestone else grouped_tasks_colspan - 1"></t>
<th t-attf-colspan="{{2 if groupby != 'priority' else 1}}"/>
<th>Name</th>
<th name="project_portal_assignees">Assignees</th>
<th>Assignees</th>
<th t-if="groupby != 'milestone' and allow_milestone" name="project_portal_milestones">Milestone</th>
<th t-if="groupby != 'status'"/>
<th t-if="groupby != 'project'">Project</th>
<th t-if="groupby != 'stage'" class="text-end">Stage</th>
@@ -127,6 +129,12 @@
<tr t-if="not groupby == 'none'" class="table-light">
<th t-if="groupby == 'project'" t-attf-colspan="{{grouped_tasks_colspan}}">
<span t-field="tasks[0].sudo().project_id.name"/></th>
<th t-if="groupby == 'milestone'" t-attf-colspan="{{grouped_tasks_colspan}}">
<span t-if="tasks[0].sudo().milestone_id and tasks[0].sudo().allow_milestones"
class="text-truncate"
t-field="tasks[0].sudo().milestone_id.name"/>
<span t-else="">No Milestone</span>
</th>
<th t-if="groupby == 'stage'" t-attf-colspan="{{grouped_tasks_colspan}}">
<span class="text-truncate" t-field="tasks[0].sudo().stage_id.name"/></th>
<th t-if="groupby == 'priority'" t-attf-colspan="{{grouped_tasks_colspan}}">
@@ -149,13 +157,18 @@
<td>
<a t-attf-href="/my/#{task_url}/#{task.id}?{{ keep_query() }}"><span t-field="task.name"/></a>
</td>
<td name="project_portal_assignees">
<td>
<t t-set="assignees" t-value="task.sudo().user_ids"/>
<div t-if="assignees" class="row flex-nowrap ps-3">
<img class="rounded-circle o_portal_contact_img me-2" t-attf-src="#{image_data_uri(assignees[:1].avatar_1024)}" alt="User" style="width: 20px; height: 20px;"/>
<span t-out="'%s%s' % (assignees[:1].name, ' + %s others' % len(assignees[1:]) if len(assignees.user_ids) > 1 else '')" t-att-title="'\n'.join(assignees[1:].mapped('name'))"/>
</div>
</td>
<td t-if="groupby != 'milestone' and allow_milestone" name="project_portal_milestones">
<t t-if="task.milestone_id and task.allow_milestones">
<span t-esc="task.milestone_id.name" />
</t>
</td>
<td t-if="groupby != 'status'" align="right">
<t t-call="project.portal_my_tasks_state_widget_template">
<t t-set="path" t-value="'tasks'"/>
@@ -294,6 +307,7 @@
<div t-if="project_accessible"><strong>Project:</strong> <a t-attf-href="/my/projects/#{task.project_id.id}" t-field="task.project_id"/></div>
<div t-else=""><strong>Project:</strong> <a t-field="task.project_id"/></div>
<div t-if="task.date_deadline"><strong>Deadline:</strong> <span t-field="task.date_deadline" t-options='{"widget": "date"}'/></div>
<div t-if="task.milestone_id and task.allow_milestones"><strong>Milestone:</strong> <span t-field="task.milestone_id"/></div>
<div name="portal_my_task_planned_hours">
<t t-call="project.portal_my_task_planned_hours_template"/>
</div>
+10 -48
View File
@@ -457,45 +457,6 @@
</header>
<sheet string="Project">
<div class="oe_button_box" name="button_box" groups="base.group_user">
<field name="currency_id" invisible="1"/>
<field name="allow_milestones" invisible="1"/>
<button class="oe_stat_button" type="object" name="action_view_analytic_account_entries" icon="fa-usd" attrs="{'invisible': [('analytic_account_id', '=', False)]}" groups="analytic.group_analytic_accounting">
<div class="o_form_field o_stat_info">
<span class="o_stat_value">
<field name="analytic_account_balance" widget='monetary'/>
</span>
<span class="o_stat_text">Gross Margin</span>
</div>
</button>
<button class="oe_stat_button" type="action"
name="%(act_project_project_2_project_task_all)d" icon="fa-tasks">
<field string="Tasks In Progress" name="task_count" widget="statinfo" options="{'label_field': 'label_tasks'}"/>
</button>
<button class="oe_stat_button" name="action_get_list_view" type="object" icon="fa-check-square-o" attrs="{'invisible': [('allow_milestones', '=', False)]}">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="milestone_count" nolabel="1"/>
</span>
<span class="o_stat_text">
Milestones
</span>
</div>
</button>
<field name="rating_avg" invisible="1"/>
<field name="rating_count" invisible="1"/>
<button name="action_view_all_rating" type="object" attrs="{'invisible': ['|', ('rating_active', '=', False), ('rating_count', '=', 0)]}" class="oe_stat_button" groups="project.group_project_rating">
<i class="fa fa-fw o_button_icon fa-smile-o text-success" attrs="{'invisible': [('rating_avg', '&lt;', 3.66)]}" title="Satisfied"/>
<i class="fa fa-fw o_button_icon fa-meh-o text-warning" attrs="{'invisible': ['|', ('rating_avg', '&lt;', 2.33), ('rating_avg', '&gt;=', 3.66)]}" title="Okay"/>
<i class="fa fa-fw o_button_icon fa-frown-o text-danger" attrs="{'invisible': [('rating_avg', '&gt;=', 2.33)]}" title="Dissatisfied"/>
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="rating_avg_percentage" nolabel="1" widget="percentage"/>
</span>
<span class="o_stat_text">
Satisfaction
</span>
</div>
</button>
<button class="oe_stat_button" name="project_update_all_action" type="object" groups="project.group_project_manager">
<div class="ps-4">
<field name="last_update_color" invisible="1"/>
@@ -508,11 +469,6 @@
<field name="last_update_status" readonly="1" widget="status_with_color" options="{'color_field': 'last_update_color'}"/>
</div>
</button>
<button name="%(action_project_task_burndown_chart_report)d" type="action" class="oe_stat_button" icon="fa-area-chart" groups="project.group_project_user">
<span class="o_stat_text">
Burndown Chart
</span>
</button>
<button class="oe_stat_button" name="%(project.project_collaborator_action)d" type="action" icon="fa-users" groups="project.group_project_manager" attrs="{'invisible':[('privacy_visibility', '!=', 'portal')]}">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
@@ -908,7 +864,7 @@
</div>
<div role="menuitem" class="col-6" groups="project.group_project_manager">
<a t-if="record.privacy_visibility.raw_value == 'portal'" class="dropdown-item" role="menuitem" name="%(project.project_share_wizard_action)d" type="action">Share</a>
<a class="dropdown-item" role="menuitem" type="edit">Edit</a>
<a class="dropdown-item" role="menuitem" type="edit">Settings</a>
</div>
<div class="o_kanban_card_manage_section o_kanban_manage_view col-12 row ps-0" groups="!project.group_project_manager">
<div role="menuitem" class="w-100">
@@ -1214,7 +1170,11 @@
<field name="description" type="html" options="{'collaborative': true, 'resizable': false}"/>
</page>
<page name="sub_tasks_page" string="Sub-tasks" attrs="{'invisible': [('allow_subtasks', '=', False)]}">
<field name="child_ids" context="{'default_project_id': project_id if not parent_id or not display_project_id else display_project_id, 'default_user_ids': user_ids, 'default_parent_id': id, 'default_partner_id': partner_id, 'default_milestone_id': allow_milestones and milestone_id}" widget="subtasks_one2many">
<field name="child_ids"
context="{'default_project_id': project_id if not parent_id or not display_project_id else display_project_id, 'default_user_ids': user_ids, 'default_parent_id': id,
'default_partner_id': partner_id, 'default_milestone_id': allow_milestones and milestone_id, 'search_default_display_project_id': project_id }"
widget="many2many"
domain="['!', ('id', 'parent_of', id)]">
<tree editable="bottom">
<field name="legend_normal" invisible="1"/>
<field name="legend_done" invisible="1"/>
@@ -1290,8 +1250,10 @@
</tree>
</field>
</page>
<page name="recurrence" string="Recurrence" attrs="{'invisible': [('recurring_task', '=', False)]}" groups="project.group_project_recurring_tasks">
<group>
<page name="recurrence" string="Recurrent" groups="project.group_project_recurring_tasks">
<span class="o_form_label"> Recurrent </span>
<field name="recurring_task" attrs="{'invisible': ['|', ('allow_recurring_tasks', '=', False), ('active', '=', False)]}"/>
<group attrs="{'invisible': [('recurring_task', '=', False)]}">
<group>
<label for="repeat_interval" />
<div class="o_col">
-1
View File
@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import project_milestone_reach_wizard
from . import project_task_type_delete
from . import project_share_wizard
@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
class ProjectMilestoneReachWizard(models.TransientModel):
_name = 'project.milestone.reach.wizard'
_description = 'Mark one or more milestones as reached'
milestone_id = fields.Many2one(related='line_ids.milestone_id')
line_ids = fields.One2many('project.milestone.reach.line.wizard', 'wizard_id')
line_count = fields.Integer(compute='_compute_line_count')
@api.depends('line_ids')
def _compute_line_count(self):
for wizard in self:
wizard.line_count = len(wizard.line_ids)
def action_mark_milestone_as_reach(self):
milestones = self.line_ids.filtered('mark_as_reached').milestone_id if self.line_count > 1 else self.milestone_id
if not milestones:
return {'type': 'ir.actions.act_window_close'}
milestones.is_reached = True
if len(milestones) == 1:
message = _('The %s milestone has successfully been marked as reached.', milestones.display_name)
else:
message = _('The selected milestones have successfully been marked as reached.')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'message': message,
'next': {'type': 'ir.actions.act_window_close'},
}
}
class ProjectMilestoneReachLineWizard(models.TransientModel):
_name = 'project.milestone.reach.line.wizard'
_description = 'Mark a milestone as reached'
wizard_id = fields.Many2one('project.milestone.reach.wizard', readonly=True)
milestone_id = fields.Many2one('project.milestone', readonly=True)
mark_as_reached = fields.Boolean(default=True)
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_milestone_reach_wizard_view_form" model="ir.ui.view">
<field name="name">project.milestone.reach.wizard.view.form</field>
<field name="model">project.milestone.reach.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<field name="line_count" invisible="1" />
<span attrs="{'invisible': [('line_count', '!=', 1)]}">Congratulations, all of the tasks linked to the <field name="milestone_id" nolabel="1" /> milestone are now done. Would you like to mark this milestone as reached?</span>
<span attrs="{'invisible': [('line_count', '=', 1)]}">Congratulations, all of the tasks linked to those milestones are now done. Would you like to mark those milestones as reached?</span>
<group attrs="{'invisible': [('line_count', '=', 1)]}">
<field name="line_ids" nolabel="1">
<tree create="0" editable="1">
<field name="milestone_id" />
<field name="mark_as_reached" />
</tree>
</field>
</group>
</sheet>
<footer>
<button string="Mark As Reached" type="object" name="action_mark_milestone_as_reach" class="btn btn-primary" data-hotkey="q"/>
<button string="Discard" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
</odoo>
@@ -9,9 +9,6 @@
'summary': 'Project expenses',
'description': 'Bridge created to add the number of expenses linked to an AA to a project form',
'depends': ['project', 'hr_expense'],
'data': [
'views/project_project_views.xml',
],
'demo': [
'data/project_hr_expense_demo.xml',
],
@@ -44,14 +44,6 @@ class Project(models.Model):
action["res_id"] = expense_ids[0]
return action
def action_open_project_expenses(self):
if not self.analytic_account_id:
return {'type': 'ir.actions.act_window_close'}
expense_ids = self.env['hr.expense']._search([
('analytic_account_id', 'in', self.analytic_account_id.ids)
])
return self._get_expense_action(expense_ids=expense_ids)
def action_profitability_items(self, section_name, domain=None, res_id=False):
if section_name == 'expenses':
return self._get_expense_action(domain, [res_id] if res_id else [])
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_project_view_form" model="ir.ui.view">
<field name="name">project.project.form.inherit</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="project.edit_project"/>
<field name="priority">40</field>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_view_analytic_account_entries']" position="after">
<button name="action_open_project_expenses" type="object" class="oe_stat_button" icon="fa-money" groups="hr_expense.group_hr_expense_team_approver">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="expenses_count" nolabel="1"/>
</span>
<span class="o_stat_text">
Expenses
</span>
</div>
</button>
</xpath>
</field>
</record>
</odoo>
-3
View File
@@ -7,9 +7,6 @@
'summary': "Monitor MRP using project",
'category': 'Services/Project',
'depends': ['mrp_account', 'project'],
'data': [
'views/project_views.xml',
],
'demo': [
'data/project_mrp_demo.xml',
],
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_project_form_view_inherited" model="ir.ui.view">
<field name="name">project.project.view.inherited</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="project.edit_project" />
<field eval="20" name="priority"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_view_analytic_account_entries']" position="after">
<button class="oe_stat_button" type="object" name="action_view_mrp_production"
icon="fa-wrench"
groups="mrp.group_mrp_user">
<field string="Manufacturing Orders" name="production_count" widget="statinfo"/>
</button>
<button class="oe_stat_button" type="object" name="action_view_workorder"
icon="fa-cog" attrs="{'invisible': [('workorder_count', '=', 0)]}"
groups="mrp.group_mrp_user">
<field string="Work Orders" name="workorder_count" widget="statinfo"/>
</button>
<button class="oe_stat_button" type="object" name="action_view_mrp_bom"
icon="fa-flask"
groups="mrp.group_mrp_user">
<field string="Bills of Materials" name="bom_count" widget="statinfo"/>
</button>
</xpath>
</field>
</record>
</odoo>
-3
View File
@@ -7,9 +7,6 @@
'summary': "Monitor purchase in project",
'category': 'Services/Project',
'depends': ['purchase', 'project'],
'data': [
'views/project_views.xml',
],
'demo': [
'data/project_purchase_demo.xml',
],
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_project_form_view_inherited" model="ir.ui.view">
<field name="name">project.project.view.inherited</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="project.edit_project"/>
<field name="priority">30</field>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_view_analytic_account_entries']" position="after">
<button name="action_open_project_purchase_orders" type="object" class="oe_stat_button" icon="fa-credit-card" attrs="{'invisible': [('purchase_orders_count', '=', 0)]}" groups="purchase.group_purchase_user">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="purchase_orders_count" nolabel="1"/>
</span>
<span class="o_stat_text">
Purchase Orders
</span>
</div>
</button>
</xpath>
</field>
</record>
</odoo>
+10 -10
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _
from odoo import _lt
from odoo.osv.expression import OR
from odoo.addons.project.controllers.portal import ProjectCustomerPortal
@@ -9,10 +9,10 @@ from odoo.addons.project.controllers.portal import ProjectCustomerPortal
class SaleProjectCustomerPortal(ProjectCustomerPortal):
def _task_get_searchbar_groupby(self):
values = super()._task_get_searchbar_groupby()
values['sale_order'] = {'input': 'sale_order', 'label': _('Sales Order'), 'order': 7}
values['sale_line'] = {'input': 'sale_line', 'label': _('Sales Order Item'), 'order': 8}
def _task_get_searchbar_groupby(self, milestones_allowed):
values = super()._task_get_searchbar_groupby(milestones_allowed)
values['sale_order'] = {'input': 'sale_order', 'label': _lt('Sales Order'), 'order': 8}
values['sale_line'] = {'input': 'sale_line', 'label': _lt('Sales Order Item'), 'order': 9}
return dict(sorted(values.items(), key=lambda item: item[1]["order"]))
def _task_get_groupby_mapping(self):
@@ -20,11 +20,11 @@ class SaleProjectCustomerPortal(ProjectCustomerPortal):
groupby_mapping.update(sale_order='sale_order_id', sale_line='sale_line_id')
return groupby_mapping
def _task_get_searchbar_inputs(self):
values = super()._task_get_searchbar_inputs()
values['sale_order'] = {'input': 'sale_order', 'label': _('Search in Sales Order'), 'order': 7}
values['sale_line'] = {'input': 'sale_line', 'label': _('Search in Sales Order Item'), 'order': 8}
values['invoice'] = {'input': 'invoice', 'label': _('Search in Invoice'), 'order': 9}
def _task_get_searchbar_inputs(self, milestones_allowed):
values = super()._task_get_searchbar_inputs(milestones_allowed)
values['sale_order'] = {'input': 'sale_order', 'label': _lt('Search in Sales Order'), 'order': 8}
values['sale_line'] = {'input': 'sale_line', 'label': _lt('Search in Sales Order Item'), 'order': 9}
values['invoice'] = {'input': 'invoice', 'label': _lt('Search in Invoice'), 'order': 10}
return dict(sorted(values.items(), key=lambda item: item[1]["order"]))
def _task_get_search_domain(self, search_in, search):
@@ -12,7 +12,7 @@ class ProjectMilestone(models.Model):
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', help='Sales Order Item that will be updated once the milestone is reached.',
domain="[('order_partner_id', '=?', project_partner_id), ('qty_delivered_method', '=', 'milestones')]")
quantity_percentage = fields.Float('Quantity (%)', help='Percentage of the ordered quantity that will automatically be delivered once the milestone is reached.')
quantity_percentage = fields.Float('Quantity', help='Percentage of the ordered quantity that will automatically be delivered once the milestone is reached.')
sale_line_name = fields.Text(related='sale_line_id.name')
@@ -301,6 +301,18 @@ class SaleOrderLine(models.Model):
project = map_so_project[so_line.order_id.id]
if not so_line.task_id:
so_line._timesheet_create_task(project=project)
so_line._generate_milestone()
def _generate_milestone(self):
if self.product_id.service_policy == 'delivered_milestones':
milestone = self.env['project.milestone'].create({
'name': self.name,
'project_id': self.project_id.id,
'sale_line_id': self.id,
'quantity_percentage': 1,
})
if self.product_id.service_tracking == 'task_in_project':
self.task_id.milestone_id = milestone.id
def _prepare_invoice_line(self, **optional_values):
"""
@@ -35,14 +35,6 @@
<button name="action_create_invoice" string="Create Invoice" type="object" class="btn-primary" groups="sales_team.group_sale_salesman_all_leads" attrs="{'invisible': [('has_any_so_to_invoice', '=', False)]}" data-hotkey="w"/>
<button name="action_create_invoice" string="Create Invoice" type="object" class="btn-secondary" groups="sales_team.group_sale_salesman_all_leads" attrs="{'invisible': ['|', ('has_any_so_with_nothing_to_invoice', '=', False), ('has_any_so_to_invoice', '=', True)]}" data-hotkey="w"/>
</xpath>
<xpath expr="//button[@name='project_update_all_action']" position="after">
<button class="oe_stat_button"
type="object" name="action_view_sos" icon="fa-dollar"
attrs="{'invisible': ['|', ('allow_billable', '=', False), ('sale_order_count', '=', 0)]}"
groups="sales_team.group_sale_salesman_all_leads">
<field name="sale_order_count" widget="statinfo" string="Sales Orders"/>
</button>
</xpath>
<xpath expr="//field[@name='partner_id']" position="attributes">
<attribute name="options">{'always_reload': True}</attribute>
<attribute name="context">{'res_partner_search_mode': 'customer'}</attribute>
@@ -60,32 +52,6 @@
</div>
</div>
</xpath>
<xpath expr="//button[@name='action_view_analytic_account_entries']" position="after">
<button name="action_open_project_invoices" type="object" class="oe_stat_button" icon="fa-pencil-square-o"
attrs="{'invisible': ['|', ('invoice_count', '=', 0), ('analytic_account_id', '=', False)]}"
groups="account.group_account_readonly">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="invoice_count" nolabel="1"/>
</span>
<span class="o_stat_text">
Invoices
</span>
</div>
</button>
</xpath>
<xpath expr="//button[@name='action_view_analytic_account_entries']" position="after">
<button name="action_open_project_vendor_bills" type="object" class="oe_stat_button" icon="fa-pencil-square-o" attrs="{'invisible': [('vendor_bill_count', '=', 0)]}" groups="account.group_account_readonly">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="vendor_bill_count" nolabel="1"/>
</span>
<span class="o_stat_text">
Vendor Bills
</span>
</div>
</button>
</xpath>
</field>
</record>
<record id="view_sale_project_inherit_form" model="ir.ui.view">
@@ -193,7 +193,7 @@ tour.register('sale_timesheet_tour', {
trigger: '.oe_kanban_global_click :contains("Project for Freeman") button.o_dropdown_kanban',
content: 'Open the project dropdown',
}, {
trigger: '.o_kanban_record:contains("Project for Freeman") .dropdown-menu a:contains("Edit")',
trigger: '.o_kanban_record:contains("Project for Freeman") .dropdown-menu a:contains("Settings")',
content: 'Start editing the project',
// timer: 300,
}, {
@@ -6,23 +6,6 @@
<field name="model">project.project</field>
<field name="inherit_id" ref="hr_timesheet.project_invoice_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='%(project.action_project_task_burndown_chart_report)d']" position="after">
<button string="Costs / Revenues" class="oe_stat_button d-none" type="object" name="action_view_timesheet" icon="fa-puzzle-piece" attrs="{'invisible': [('allow_billable', '=', False)]}" groups="project.group_project_manager"/>
</xpath>
<xpath expr="//button[@name='action_show_timesheets_by_employee_invoice_type']" position="after">
<button name="action_billable_time_button" type="object" class="oe_stat_button" icon="fa-clock-o" attrs="{'invisible': ['|', ('allow_timesheets', '=', False), ('analytic_account_id', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_approver">
<div class="o_field_widget o_stat_info">
<div class="oe_inline">
<span class="o_stat_value me-1">
<field name="billable_percentage" widget="statinfo" nolabel="1"/>%
</span>
</div>
<span class="o_stat_text">
Billable Time
</span>
</div>
</button>
</xpath>
<xpath expr="//page[@name='settings']" position="after">
<page name="billing_employee_rate" string="Invoicing" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('partner_id', '=', False)]}">
<group>
@@ -31,8 +14,8 @@
<field name="pricing_type" invisible="1" widget="radio"/>
<field name="timesheet_product_id" string="Default Service" invisible="1" context="{'default_detailed_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/>
<field name="sale_order_id" invisible="1" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
<field name="sale_line_id" groups="!sales_team.group_sale_salesman" string="Default Sales Order Item" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
<field name="sale_line_id" groups="sales_team.group_sale_salesman" string="Default Sales Order Item" options="{'no_create': True, 'no_edit': True, 'delete': False}"/>
<field name="sale_line_id" groups="!sales_team.group_sale_salesman" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
<field name="sale_line_id" groups="sales_team.group_sale_salesman" options="{'no_create': True, 'no_edit': True, 'delete': False}"/>
</group>
</group>
<field name="sale_line_employee_ids">
@@ -820,109 +820,55 @@ tour.stepUtils.mobileModifier(tour.stepUtils.autoExpandMoreButtons('.o_control_p
content: _t('See Tasks'),
position: 'bottom',
}, {
trigger: '.o_field_widget[name=project_id] a',
content: _t('See Project'),
position: 'right',
},
tour.stepUtils.autoExpandMoreButtons('.o_control_panel .breadcrumb:contains("the_flow.project")'),
{
trigger: '.oe_button_box button.oe_stat_button:has(span:contains("Recorded"))',
extra_trigger: '.o_form_readonly .oe_stat_button.dropdown > .dropdown-menu',
content: _t('See Timesheets'),
position: 'bottom',
trigger: 'a.nav-link:contains(Timesheets)',
extra_trigger: 'div.o_notebook_headers',
content: 'Click on Timesheets page to log a timesheet',
}, {
mobile: true,
trigger: '.o_enable_searchview',
content: _t('Open the search field'),
position: 'bottom',
}, {
mobile: true,
trigger: '.o_toggle_searchview_full',
content: _t('Open the full search field'),
position: 'bottom',
}, {
trigger: '.o_group_by_menu .dropdown-toggle',
content: _t('Click on the group by button'),
position: 'bottom',
}, {
trigger: '.dropdown-item.selected:contains(Employee)',
extra_trigger: '.dropdown.show',
content: _t('Remove group by employee'),
position: 'bottom',
}, {
trigger: '.dropdown-item.selected:contains(Billing Type)',
extra_trigger: '.dropdown.show',
content: _t('Remove group by Billing type'),
position: 'bottom',
}, {
mobile: true,
trigger: '.o_mobile_search_footer',
content: _t('Close the search dropdown on mobile'),
position: 'bottom',
}, {
mobile: true,
trigger: '.o_cp_bottom_right .o_cp_switch_buttons .btn-link',
content: _t('Prepare to change the view on mobile'),
position: 'bottom',
}, {
trigger: '.o_switch_view.o_list',
content: _t('Activate the list view'),
position: 'bottom',
}, {
trigger: '.o_list_button_add',
content: _t('Add a Timesheet'),
position: 'bottom',
}, {
// FIXME replace list by kanban + form
// FIXME WOWL: remove first part of selector when legacy view is dropped
// (currently, it's anew view in community and a legacy one in enterprise)
trigger: '.o_selected_row input[name=name], .o_selected_row .o_field_widget[name=name] input',
content: _t('Set description'),
position: 'bottom',
run: 'text 10 hours',
}, {
trigger: '.o_selected_row .o_field_widget[name=task_id] input',
content: _t('Choose a task'),
position: 'bottom',
run: 'click',
trigger: '.o_form_button_edit',
content: _t('Click on Edit button to enter to the form view of the task.'),
}, {
mobile: false,
trigger: ".ui-menu-item > a",
auto: true,
trigger: 'div[name="timesheet_ids"] td.o_field_x2many_list_row_add a[role="button"]',
content: 'Click on Add a line to create a new timesheet into the task.',
}, {
mobile: true,
trigger: ".o_kanban_record:has(.o_kanban_record_title :contains('the_flow.service'))",
extra_trigger: ".modal:not(.o_inactive_modal) .modal-title:contains('Task')",
content: _t("Select the the_flow.vendor"),
position: "bottom",
}, {
edition: 'community',
trigger: '.o_selected_row .o_field_widget[name=unit_amount] input',
content: _t('Set time'),
trigger: '.o-kanban-button-new',
content: _t("Click on Add in order to add a timesheet."),
position: 'bottom',
}, {
mobile: false,
trigger: 'div[name="timesheet_ids"] div[name="name"] input',
content: 'Enter a description this timesheet',
run: 'text 10 hours',
}, {
mobile: true,
trigger: '.modal-body .o_form_view div[name="name"] input',
content: 'Enter a description this timesheet',
run: 'text 10 hours',
}, {
mobile: false,
trigger: 'div[name="timesheet_ids"] div[name="unit_amount"] input',
content: 'Enter one hour for this timesheet',
run: 'text 10',
}, {
edition: 'enterprise',
trigger: '.o_selected_row .o_field_widget[name=unit_amount]input',
content: _t('Set time'),
position: 'bottom',
mobile: true,
trigger: '.modal-body .o_form_view div[name="unit_amount"] input',
content: 'Enter one hour for this timesheet',
run: 'text 10',
}, {
trigger: '.o_list_button_save',
trigger: '.o_form_button_save',
content: _t('Save'),
position: 'bottom',
},
...tour.stepUtils.goBackBreadcrumbsMobile(
_t('Back to the sale order'),
undefined,
".breadcrumb-item.active:contains('Timesheets')",
".breadcrumb-item.active:contains('the_flow.project')",
".breadcrumb-item.active:contains('the_flow.service')"
),
{
mobile: false,
trigger: '.breadcrumb-item:nth-child(2) a',
extra_trigger: '.o_list_button_add', // Waiting save
extra_trigger: 'div:not(".o_form_editable")', // Waiting save
content: _t('Back to the sale order'),
position: 'bottom',
},