Skip to content

Commit 05a9e08

Browse files
committed
[IMP] project,*: added project templates mechanism
* = hr_timesheet, sale_project, project_purchase, project_mrp, project_stock, sale_timesheet, website_project, analytic, project_timesheet_holidays In this PR we add Project templates which will contribute towards template system in the Project app. Reusable project templates to work. task-4700744
1 parent c54cd1a commit 05a9e08

File tree

49 files changed

+772
-93
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+772
-93
lines changed

addons/hr_timesheet/models/hr_timesheet.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def default_get(self, field_list):
4545
return result
4646

4747
def _domain_project_id(self):
48-
domain = [('allow_timesheets', '=', True)]
48+
domain = [('allow_timesheets', '=', True), ('is_template', '=', False)]
4949
if not self.env.user.has_group('hr_timesheet.group_timesheet_manager'):
5050
return expression.AND([domain,
5151
['|', ('privacy_visibility', '!=', 'followers'), ('message_partner_ids', 'in', [self.env.user.partner_id.id])]

addons/hr_timesheet/models/res_company.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ def _default_timesheet_encode_uom_id(self):
2424
timesheet_encode_uom_id = fields.Many2one('uom.uom', string="Timesheet Encoding Unit",
2525
default=_default_timesheet_encode_uom_id)
2626
internal_project_id = fields.Many2one(
27-
'project.project', string="Internal Project",
28-
help="Default project value for timesheet generated from time off type.")
27+
"project.project", string="Internal Project",
28+
domain=[("is_template", "=", False)],
29+
help="Default project value for timesheet generated from time off type.",
30+
)
2931

3032
@api.constrains('internal_project_id')
3133
def _check_internal_project_id_company(self):

addons/hr_timesheet/views/project_project_views.xml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<field name="arch" type="xml">
2424
<xpath expr="//header" position="after">
2525
<field name="analytic_account_active" invisible="1"/>
26-
<t name="timesheet_error" invisible="not allow_timesheets">
26+
<t name="timesheet_error" invisible="not allow_timesheets or is_template">
2727
<div class="alert alert-warning mb-1 text-center" role="alert" colspan="2" invisible="not account_id or analytic_account_active">
2828
You cannot log timesheets on this project since it is linked to an inactive analytic account.<br/>
2929
Please switch to another account, or reactivate the current one to timesheet on the project.
@@ -77,7 +77,7 @@
7777
<field name="allocated_hours"/>
7878
</xpath>
7979
<xpath expr="//div[@name='card_menu_view']" position="inside">
80-
<div role="menuitem" t-if="record.allow_timesheets.raw_value" groups="hr_timesheet.group_hr_timesheet_user">
80+
<div role="menuitem" t-if="record.allow_timesheets.raw_value and !record.is_template.raw_value" groups="hr_timesheet.group_hr_timesheet_user">
8181
<a name="action_project_timesheets" type="object">Timesheets</a>
8282
</div>
8383
</xpath>
@@ -100,17 +100,17 @@
100100
<field name="inherit_id" ref="project.view_project_project_filter"/>
101101
<field name="arch" type="xml">
102102
<filter name="late_milestones" position="before">
103-
<filter string="Timesheets &gt;100%" name="projects_in_overtime" domain="[('is_project_overtime', '=', True)]" groups="project.group_project_manager"/>
103+
<filter string="Timesheets &gt;100%" name="projects_in_overtime" domain="[('is_project_overtime', '=', True)]" groups="project.group_project_manager" invisible="context.get('default_is_template')"/>
104104
</filter>
105105
</field>
106106
</record>
107107

108108
<record id="project.open_view_project_all" model="ir.actions.act_window">
109-
<field name="domain">[('is_internal_project', '=', False)]</field>
109+
<field name="domain">[('is_internal_project', '=', False), ("is_template", "=", False)]</field>
110110
</record>
111111

112112
<record id="project.open_view_project_all_group_stage" model="ir.actions.act_window">
113-
<field name="domain">[('is_internal_project', '=', False)]</field>
113+
<field name="domain">[('is_internal_project', '=', False), ("is_template", "=", False)]</field>
114114
</record>
115115
</data>
116116
</odoo>

addons/hr_timesheet/views/project_task_views.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
</div>
2929
</xpath>
3030
<xpath expr="//t[@name='warning_section']" position="inside">
31-
<div class="alert alert-warning text-center mb-2" role="alert" invisible="analytic_account_active or not allow_timesheets" groups="hr_timesheet.group_hr_timesheet_user">
31+
<div class="alert alert-warning text-center mb-2" role="alert" invisible="analytic_account_active or not allow_timesheets or is_template" groups="hr_timesheet.group_hr_timesheet_user">
3232
You cannot log timesheets on this project since it is linked to an inactive analytic account.<br/> Please change this account, or reactivate the current one to timesheet on the project.
3333
</div>
3434
</xpath>

addons/project/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
'data/project_tour.xml',
5555
'wizard/project_task_type_delete_views.xml',
5656
'wizard/project_project_stage_delete_views.xml',
57+
'wizard/project_template_create_wizard.xml',
5758
'views/project_menus.xml',
5859
],
5960
'demo': [

addons/project/controllers/portal.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def _project_get_page_view_values(self, project, access_token, page=1, date_begi
5959
return self._get_page_view_values(project, access_token, values, 'my_projects_history', False, **kwargs)
6060

6161
def _prepare_project_domain(self):
62-
return []
62+
return [('is_template', '=', False)]
6363

6464
def _prepare_searchbar_sortings(self):
6565
return {
@@ -112,6 +112,8 @@ def portal_my_projects(self, page=1, date_begin=None, date_end=None, sortby=None
112112
def portal_my_project(self, project_id=None, access_token=None, page=1, date_begin=None, date_end=None, sortby=None, search=None, search_in='content', groupby=None, task_id=None, **kw):
113113
try:
114114
project_sudo = self._document_check_access('project.project', project_id, access_token)
115+
if project_sudo.is_template:
116+
return request.redirect('/my')
115117
except (AccessError, MissingError):
116118
return request.redirect('/my')
117119
if project_sudo.collaborator_count and project_sudo.with_user(request.env.user)._check_project_sharing_access():

addons/project/models/project_collaborator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class ProjectCollaborator(models.Model):
77
_name = 'project.collaborator'
88
_description = 'Collaborators in project shared'
99

10-
project_id = fields.Many2one('project.project', 'Project Shared', domain=[('privacy_visibility', '=', 'portal')], required=True, readonly=True, export_string_translation=False, index=True)
10+
project_id = fields.Many2one('project.project', 'Project Shared', domain=[('privacy_visibility', '=', 'portal'), ('is_template', '=', False)], required=True, readonly=True, export_string_translation=False, index=True)
1111
partner_id = fields.Many2one('res.partner', 'Collaborator', required=True, readonly=True, export_string_translation=False)
1212
partner_email = fields.Char(related='partner_id.email', export_string_translation=False)
1313
limited_access = fields.Boolean('Limited Access', default=False, export_string_translation=False)

addons/project/models/project_milestone.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def _get_default_project_id(self):
2020

2121
name = fields.Char(required=True)
2222
sequence = fields.Integer('Sequence', default=10)
23-
project_id = fields.Many2one('project.project', required=True, default=_get_default_project_id, index=True, ondelete='cascade')
23+
project_id = fields.Many2one('project.project', required=True, default=_get_default_project_id, domain=[('is_template', '=', False)], index=True, ondelete='cascade')
2424
deadline = fields.Date(tracking=True, copy=False)
2525
is_reached = fields.Boolean(string="Reached", default=False, copy=False)
2626
reached_date = fields.Date(compute='_compute_reached_date', store=True, export_string_translation=False)

addons/project/models/project_project.py

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def _set_favorite_user_ids(self, is_favorite):
194194
next_milestone_id = fields.Many2one('project.milestone', compute='_compute_next_milestone_id', groups="project.group_project_milestone", export_string_translation=False)
195195
can_mark_milestone_as_done = fields.Boolean(compute='_compute_next_milestone_id', groups="project.group_project_milestone", export_string_translation=False)
196196
is_milestone_deadline_exceeded = fields.Boolean(compute='_compute_next_milestone_id', groups="project.group_project_milestone", export_string_translation=False)
197+
is_template = fields.Boolean(copy=False, export_string_translation=False)
197198

198199
_project_date_greater = models.Constraint(
199200
'check(date >= date_start)',
@@ -472,7 +473,17 @@ def copy_data(self, default=None):
472473
vals_list = super().copy_data(default=default)
473474
if default and 'name' in default:
474475
return vals_list
475-
return [dict(vals, name=self.env._("%s (copy)", project.name)) for project, vals in zip(self, vals_list)]
476+
copy_from_template = self.env.context.get('copy_from_template')
477+
for project, vals in zip(self, vals_list):
478+
if project.is_template and not copy_from_template:
479+
vals['is_template'] = True
480+
if copy_from_template:
481+
# We can make last_update_status as None because it is a required field
482+
vals.pop("last_update_status", None)
483+
for field in set(self._get_template_field_blacklist()) & set(vals.keys()):
484+
del vals[field]
485+
vals["name"] = project.name if copy_from_template or vals.get("is_template") else self.env._("%s (copy)", project.name)
486+
return vals_list
476487

477488
def copy(self, default=None):
478489
default = dict(default or {})
@@ -807,9 +818,15 @@ def action_view_tasks(self):
807818
context = ast.literal_eval(context)
808819
context.update({
809820
'create': self.active,
810-
'active_test': self.active
821+
'active_test': self.active,
822+
'active_id': self.id,
811823
})
812824
action['context'] = context
825+
if self.is_template:
826+
action['context'].update({'default_is_template': True})
827+
domain = ast.literal_eval(action['domain'].replace('active_id', str(self.id)))
828+
domain.remove(('has_template_ancestor', '=', False))
829+
action['domain'] = domain
813830
return action
814831

815832
def action_view_all_rating(self):
@@ -1140,3 +1157,130 @@ def _thread_to_store(self, store: Store, fields, *, request_list=None):
11401157
def _compute_task_completion_percentage(self):
11411158
for task in self:
11421159
task.task_completion_percentage = task.task_count and 1 - task.open_task_count / task.task_count
1160+
1161+
# ---------------------------------------------------
1162+
# Project Template Methods
1163+
# ---------------------------------------------------
1164+
1165+
def _get_project_to_template_warnings(self):
1166+
self.ensure_one()
1167+
warnings = []
1168+
if self.collaborator_ids:
1169+
warnings.append(self.env._("This project has collaborators linked to it."))
1170+
if self.account_id:
1171+
warnings.append(self.env._("This project is linked to an analytic plan."))
1172+
return warnings
1173+
1174+
def _get_template_to_project_warnings(self):
1175+
self.ensure_one()
1176+
return []
1177+
1178+
def _get_template_notification_action(self, tag, params):
1179+
return {
1180+
'type': 'ir.actions.client',
1181+
'tag': tag,
1182+
'params': params,
1183+
}
1184+
1185+
def action_convert_project_to_template(self):
1186+
self.ensure_one()
1187+
if self.is_template:
1188+
return self._get_template_notification_action(
1189+
"project_template_show_undo_confirmation_dialog",
1190+
{
1191+
"project_id": self.id,
1192+
"message": self._get_template_to_project_warnings(),
1193+
},
1194+
)
1195+
linked_warning = self._get_project_to_template_warnings()
1196+
if linked_warning:
1197+
return self._get_template_notification_action(
1198+
"project_template_create_show_confirmation",
1199+
{
1200+
"project_id": self.id,
1201+
"next": {
1202+
"type": "ir.actions.client",
1203+
"tag": "soft_reload",
1204+
},
1205+
"message": linked_warning,
1206+
},
1207+
)
1208+
return self._convert_project_to_template()
1209+
1210+
def create_template_from_project(self):
1211+
self.ensure_one()
1212+
template = self.copy(default={"is_template": True})
1213+
template._toggle_template_mode(True)
1214+
template.message_post(body=self.env._("Template created from %s.", self.name))
1215+
return template._get_template_notification_action(
1216+
"project_template_show_notification",
1217+
{
1218+
"project_id": template.id,
1219+
"undo_method": "unlink",
1220+
},
1221+
)
1222+
1223+
def _convert_project_to_template(self):
1224+
self.ensure_one()
1225+
self.collaborator_ids.unlink()
1226+
self._toggle_template_mode(True)
1227+
self.message_post(body=self.env._("Project converted to template."))
1228+
return self._get_template_notification_action(
1229+
"project_template_show_notification",
1230+
{
1231+
"project_id": self.id,
1232+
"next": {
1233+
"type": "ir.actions.client",
1234+
"tag": "soft_reload",
1235+
},
1236+
},
1237+
)
1238+
1239+
def action_undo_convert_to_template(self):
1240+
self.ensure_one()
1241+
self._toggle_template_mode(False)
1242+
self.message_post(body=self.env._("Template converted back to regular project."))
1243+
return self._get_template_notification_action(
1244+
"display_notification",
1245+
{
1246+
"message": self.env._("Template converted back to regular project."),
1247+
"next": {
1248+
"type": "ir.actions.client",
1249+
"tag": "soft_reload",
1250+
},
1251+
},
1252+
)
1253+
1254+
def _toggle_template_mode(self, is_template):
1255+
self.ensure_one()
1256+
self.is_template = is_template
1257+
self.task_ids.write({"is_template": is_template})
1258+
1259+
@api.model
1260+
def _get_template_default_context_whitelist(self):
1261+
"""
1262+
Whitelist of fields that can be set through the `default_` context keys when creating a project from a template.
1263+
"""
1264+
return []
1265+
1266+
@api.model
1267+
def _get_template_field_blacklist(self):
1268+
"""
1269+
Blacklist of fields to not copy when creating a project from a template.
1270+
"""
1271+
return [
1272+
"partner_id",
1273+
]
1274+
1275+
def action_create_from_template(self, values=None):
1276+
self.ensure_one()
1277+
values = values or {}
1278+
default = {
1279+
key.removeprefix('default_'): value
1280+
for key, value in self.env.context.items()
1281+
if key.startswith('default_') and key.removeprefix('default_') in self._get_template_default_context_whitelist()
1282+
} | values | {
1283+
field: False
1284+
for field in self._get_template_field_blacklist()
1285+
}
1286+
return self.with_context(copy_from_template=True).copy(default=default)

addons/project/models/project_task.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def _read_group_personal_stage_type_ids(self, stages, domain):
179179
help="Date on which the state of your task has last been modified.\n"
180180
"Based on this information you can identify tasks that are stalling and get statistics on the time it usually takes to move tasks from one stage/state to another.")
181181

182-
project_id = fields.Many2one('project.project', string='Project', domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id)]",
182+
project_id = fields.Many2one('project.project', string='Project', domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id), ('is_template', '=', False)]",
183183
compute="_compute_project_id", store=True, precompute=True, recursive=True, readonly=False, index=True, tracking=True, change_default=True, falsy_value_label=_lt("🔒 Private"))
184184
display_in_project = fields.Boolean(compute='_compute_display_in_project', store=True, export_string_translation=False)
185185
task_properties = fields.Properties('Properties', definition='project_id.task_properties_definition', copy=True)

addons/project/models/project_update.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def default_get(self, fields):
5757
user_id = fields.Many2one('res.users', string='Author', required=True, default=lambda self: self.env.user)
5858
description = fields.Html()
5959
date = fields.Date(default=fields.Date.context_today, tracking=True)
60-
project_id = fields.Many2one('project.project', required=True, index=True, export_string_translation=False)
60+
project_id = fields.Many2one('project.project', required=True, domain=[('is_template', '=', False)], index=True, export_string_translation=False)
6161
name_cropped = fields.Char(compute="_compute_name_cropped", export_string_translation=False)
6262
task_count = fields.Integer("Task Count", readonly=True, export_string_translation=False)
6363
closed_task_count = fields.Integer("Closed Task Count", readonly=True, export_string_translation=False)

addons/project/report/project_report.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,14 @@ def _from(self):
139139
AND pm.is_reached = False
140140
AND pm.deadline <= CAST(now() AS DATE)
141141
LEFT JOIN task_dependencies_rel td ON td.depends_on_id = t.id
142+
LEFT JOIN project_project p ON p.id = t.project_id
142143
"""
143144

144145
def _where(self):
145146
return """
146147
t.project_id IS NOT NULL
147148
AND t.is_template IS NOT TRUE
149+
AND p.is_template IS NOT TRUE
148150
"""
149151

150152
def init(self):

addons/project/security/ir.model.access.csv

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,5 @@ access_project_share_collaborator_manager,project.share.collaborator.wizard.mana
4646
access_project_personal_stage,project.personal.stage.user,model_project_task_stage_personal,base.group_user,1,1,1,1
4747
access_mail_activity_plan_project_manager,mail.activity.plan.project.manager,mail.model_mail_activity_plan,project.group_project_manager,1,1,1,1
4848
access_mail_activity_plan_template_project_manager,mail.activity.plan.template.project.manager,mail.model_mail_activity_plan_template,project.group_project_manager,1,1,1,1
49+
access_project_template_create_wizard_user,project.template.create.wizard.user,project.model_project_template_create_wizard,project.group_project_user,1,1,0,0
50+
access_project_template_create_wizard_manager,project.template.create.wizard.manager,project.model_project_template_create_wizard,project.group_project_manager,1,1,1,1

0 commit comments

Comments
 (0)