You can manage todo lists on tasks by installing the Todo Lists application, supporting the Getting Things Done (GTD) methodology.
You can also manage issues/bugs in projects by installing the "Issue Tracker" application.
]]> - @@ -333,4 +331,4 @@ -+ Documents are attached to the tasks and issues of your project.
+ Send messages or log internal notes with attachments to link + documents to your project. +
'''), + 'limit': 80, + 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, self.id) + } + + @api.model + def activate_sample_project(self): + """ Unarchives the sample project 'project.project_project_data' and + reloads the project dashboard """ + # Unarchive sample project + project = self.env.ref('project.project_project_data', False) + if project: + project.write({'active': True}) + + # Change the help message on the action (no more activate project) + action = self.env.ref('project.open_view_project_all', False) + action_data = None + if action: + action.sudo().write({ + "help": _('''Click to create a new project.
''') + }) + action_data = action.read()[0] + # Reload the dashboard + return action_data + + def _compute_is_favorite(self): + for project in self: + project.is_favorite = self.env.user in project.favorite_user_ids + + def _get_default_favorite_user_ids(self): + return [(6, 0, [self.env.uid])] + + @api.model + def default_get(self, flds): + result = super(Project, self).default_get(flds) + result['use_tasks'] = True + return result + + # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field + _alias_models = lambda self: self._get_alias_models() + _visibility_selection = lambda self: self._get_visibility_selection() + + active = fields.Boolean(default=True, + help="If the active field is set to False, it will allow you to hide the project without removing it.") + sequence = fields.Integer(default=10, help="Gives the sequence order when displaying a list of Projects.") + analytic_account_id = fields.Many2one( + 'account.analytic.account', string='Contract/Analytic', + help="Link this project to an analytic account if you need financial management on projects. " + "It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.", + ondelete="cascade", required=True, auto_join=True) + favorite_user_ids = fields.Many2many( + 'res.users', 'project_favorite_user_rel', 'project_id', 'user_id', + default=_get_default_favorite_user_ids, + string='Members') + is_favorite = fields.Boolean(compute='_compute_is_favorite', string='Show Project on dashboard', + help="Whether this project should be displayed on the dashboard or not") + label_tasks = fields.Char(string='Use Tasks as', default='Tasks', help="Gives label to tasks on project's kanban view.") + tasks = fields.One2many('project.task', 'project_id', string="Task Activities") + resource_calendar_id = fields.Many2one('resource.calendar', string='Working Time', + help="Timetable working hours to adjust the gantt diagram report") + type_ids = fields.Many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', string='Tasks Stages') + task_count = fields.Integer(compute='_compute_task_count', string="Tasks") + task_needaction_count = fields.Integer(compute='_compute_task_needaction_count', string="Tasks") + task_ids = fields.One2many('project.task', 'project_id', string='Tasks', + domain=['|', ('stage_id.fold', '=', False), ('stage_id', '=', False)]) + color = fields.Integer(string='Color Index') + user_id = fields.Many2one('res.users', string='Project Manager', default=lambda self: self.env.user) + alias_id = fields.Many2one('mail.alias', string='Alias', ondelete="restrict", required=True, + help="Internal email associated with this project. Incoming emails are automatically synchronized " + "with Tasks (or optionally Issues if the Issue Tracker module is installed).") + alias_model = fields.Selection(_alias_models, string="Alias Model", index=True, required=True, default='project.task', + help="The kind of document created when an email is received on this project's email alias") + privacy_visibility = fields.Selection(_visibility_selection, string='Privacy', required=True, + default='employees', + help="Holds visibility of the tasks or issues that belong to the current project:\n" + "- Portal : employees see everything;\n" + " if portal is activated, portal users see the tasks or issues followed by\n" + " them or by someone of their company\n" + "- Employees Only: employees see all tasks or issues\n" + "- Followers Only: employees see only the followed tasks or issues; if portal\n" + " is activated, portal users see the followed tasks or issues.") + doc_count = fields.Integer(compute='_compute_attached_docs_count', string="Number of documents attached") + date_start = fields.Date(string='Start Date') + date = fields.Date(string='Expiration Date', index=True, track_visibility='onchange') + + _sql_constraints = [ + ('project_date_greater', 'check(date >= date_start)', 'Error! project start-date must be lower than project end-date.') + ] + + @api.multi + def map_tasks(self, new_project_id): + """ copy and map tasks from old to new project """ + tasks = self.env['project.task'] + for task in self.tasks: + # preserve task name and stage, normally altered during copy + defaults = {'stage_id': task.stage_id.id, + 'name': task.name} + tasks += task.copy(defaults) + return self.browse(new_project_id).write({'tasks': [(6, 0, tasks.ids)]}) + + @api.multi + def copy(self, default=None): + if default is None: + default = {} + self = self.with_context(active_test=False) + if not default.get('name'): + default['name'] = _("%s (copy)") % (self.name) + project = super(Project, self).copy(default) + for follower in self.message_follower_ids: + project.message_subscribe(partner_ids=follower.partner_id.ids, subtype_ids=follower.subtype_ids.ids) + self.map_tasks(project.id) + return project + + @api.model + def create(self, vals): + ir_values = self.env['ir.values'].get_default('project.config.settings', 'generate_project_alias') + if ir_values: + vals['alias_name'] = vals.get('alias_name') or vals.get('name') + # Prevent double project creation when 'use_tasks' is checked + self = self.with_context(project_creation_in_progress=True, mail_create_nosubscribe=True) + return super(Project, self).create(vals) + + @api.multi + def write(self, vals): + # if alias_model has been changed, update alias_model_id accordingly + if vals.get('alias_model'): + vals['alias_model_id'] = self.env['ir.model'].search([ + ('model', '=', vals.get('alias_model', 'project.task')) + ], limit=1).id + res = super(Project, self).write(vals) + if 'active' in vals: + # archiving/unarchiving a project does it on its tasks, too + self.with_context(active_test=False).mapped('tasks').write({'active': vals['active']}) + return res + + @api.multi + def toggle_favorite(self): + favorite_projects = not_fav_projects = self.env['project.project'].sudo() + for project in self: + if self.env.user in project.favorite_user_ids: + favorite_projects |= project + else: + not_fav_projects |= project + + # Project User has no write access for project. + not_fav_projects.write({'favorite_user_ids': [(4, self.env.uid)]}) + favorite_projects.write({'favorite_user_ids': [(3, self.env.uid)]}) + + @api.multi + def close_dialog(self): + return {'type': 'ir.actions.act_window_close'} + + +class Task(models.Model): + _name = "project.task" + _description = "Task" + _date_name = "date_start" + _inherit = ['mail.thread', 'ir.needaction_mixin'] + _mail_post_access = 'read' + _order = "priority desc, sequence, date_start, name, id" + + def _get_default_partner(self): + if 'default_project_id' in self.env.context: + default_project_id = self.env['project.project'].browse(self.env.context['default_project_id']) + return default_project_id.exists().partner_id + + def _get_default_stage_id(self): + """ Gives default stage_id """ + return self.stage_find(self.env.context.get('default_project_id'), [('fold', '=', False)]) + + @api.multi + def _read_group_stage_ids(self, domain, read_group_order=None, access_rights_uid=None): + TaskType = self.env['project.task.type'] + order = TaskType._order + access_rights_uid = access_rights_uid or self.env.uid + if read_group_order == 'stage_id desc': + order = '%s desc' % order + if 'default_project_id' in self.env.context: + search_domain = ['|', ('project_ids', '=', self.env.context['default_project_id']), ('id', 'in', self.ids)] + else: + search_domain = [('id', 'in', self.ids)] + stage_ids = TaskType._search(search_domain, order=order, access_rights_uid=access_rights_uid) + stages = TaskType.sudo(access_rights_uid).browse(stage_ids) + result = stages.name_get() + # restore order of the search + result.sort(lambda x, y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0]))) + + return result, {stage.id: stage.fold for stage in stages} + + _group_by_full = { + 'stage_id': _read_group_stage_ids, + } + + active = fields.Boolean(default=True) + name = fields.Char(string='Task Title', track_visibility='onchange', required=True, index=True) + description = fields.Html(string='Description') + priority = fields.Selection([ + ('0','Normal'), + ('1','High') + ], default='0', index=True) + sequence = fields.Integer(string='Sequence', index=True, default=10, + help="Gives the sequence order when displaying a list of tasks.") + stage_id = fields.Many2one('project.task.type', string='Stage', track_visibility='onchange', index=True, + default=_get_default_stage_id, + domain="[('project_ids', '=', project_id)]", copy=False) + tag_ids = fields.Many2many('project.tags', string='Tags', oldname='categ_ids') + kanban_state = fields.Selection([ + ('normal', 'In Progress'), + ('done', 'Ready for next stage'), + ('blocked', 'Blocked') + ], string='Kanban State', + default='normal', + track_visibility='onchange', + required=True, copy=False, + help="A task's kanban state indicates special situations affecting it:\n" + " * Normal is the default situation\n" + " * Blocked indicates something is preventing the progress of this task\n" + " * Ready for next stage indicates the task is ready to be pulled to the next stage") + create_date = fields.Datetime(index=True) + write_date = fields.Datetime(index=True) #not displayed in the view but it might be useful with base_action_rule module (and it needs to be defined first for that) + date_start = fields.Datetime(string='Starting Date', + default=fields.Datetime.now, + index=True, copy=False) + date_end = fields.Datetime(string='Ending Date', index=True, copy=False) + date_assign = fields.Datetime(string='Assigning Date', index=True, copy=False, readonly=True) + date_deadline = fields.Date(string='Deadline', index=True, copy=False) + date_last_stage_update = fields.Datetime(string='Last Stage Update', + default=fields.Datetime.now, + index=True, + copy=False, + readonly=True) + project_id = fields.Many2one('project.project', + string='Project', + default=lambda self: self.env.context.get('default_project_id'), + index=True, + track_visibility='onchange', + change_default=True) + notes = fields.Text(string='Notes') + planned_hours = fields.Float(string='Initially Planned Hours', help='Estimated time to do the task, usually set by the project manager when the task is in draft state.') + remaining_hours = fields.Float(string='Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task.") + user_id = fields.Many2one('res.users', + string='Assigned to', + default=lambda self: self.env.uid, + index=True, track_visibility='onchange') + partner_id = fields.Many2one('res.partner', + string='Customer', + default=_get_default_partner) + manager_id = fields.Many2one('res.users', string='Project Manager', related='project_id.user_id') + company_id = fields.Many2one('res.company', + string='Company', + default=lambda self: self.env['res.company']._company_default_get()) + color = fields.Integer(string='Color Index') + user_email = fields.Char(related='user_id.email', string='User Email', readonly=True) + attachment_ids = fields.One2many('ir.attachment', 'res_id', domain=lambda self: [('res_model', '=', self._name)], auto_join=True, string='Attachments') + # In the domain of displayed_image_id, we couln't use attachment_ids because a one2many is represented as a list of commands so we used res_model & res_id + displayed_image_id = fields.Many2one('ir.attachment', domain="[('res_model', '=', 'project.task'), ('res_id', '=', id), ('mimetype', 'ilike', 'image')]", string='Displayed Image') + legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation') + legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation') + legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation') + + @api.onchange('project_id') + def _onchange_project(self): + if self.project_id: + self.partner_id = self.project_id.partner_id + + @api.onchange('user_id') + def _onchange_user(self): + if self.user_id: + self.date_start = fields.Datetime.now() + + @api.multi + def copy(self, default=None): + if default is None: + default = {} + if not default.get('name'): + default['name'] = _("%s (copy)") % self.name + if 'remaining_hours' not in default: + default['remaining_hours'] = self.planned_hours + return super(Task, self).copy(default) + + @api.constrains('date_start', 'date_end') + def _check_dates(self): + if any(self.filtered(lambda task: task.date_start and task.date_end and task.date_start > task.date_end)): + return ValidationError(_('Error ! Task starting date must be lower than its ending date.')) + + # Override view according to the company definition + @api.model + def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + # read uom as admin to avoid access rights issues, e.g. for portal/share users, + # this should be safe (no context passed to avoid side-effects) + obj_tm = self.env.user.company_id.project_time_mode_id + tm = obj_tm and obj_tm.name or 'Hours' + + res = super(Task, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + + # read uom as admin to avoid access rights issues, e.g. for portal/share users, + # this should be safe (no context passed to avoid side-effects) + obj_tm = self.env.user.company_id.project_time_mode_id + # using get_object to get translation value + uom_hour = self.env.ref('product.product_uom_hour', False) + if not obj_tm or not uom_hour or obj_tm.id == uom_hour.id: + return res + + eview = etree.fromstring(res['arch']) + + # if the project_time_mode_id is not in hours (so in days), display it as a float field + def _check_rec(eview): + if eview.attrib.get('widget', '') == 'float_time': + eview.set('widget', 'float') + for child in eview: + _check_rec(child) + return True + + _check_rec(eview) + + res['arch'] = etree.tostring(eview) + + # replace reference of 'Hours' to 'Day(s)' + for f in res['fields']: + # TODO this NOT work in different language than english + # the field 'Initially Planned Hours' should be replaced by 'Initially Planned Days' + # but string 'Initially Planned Days' is not available in translation + if 'Hours' in res['fields'][f]['string']: + res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours', obj_tm.name) + return res + + @api.model + def get_empty_list_help(self, help): + self = self.with_context( + empty_list_help_id=self.env.context.get('default_project_id'), + empty_list_help_model='project.project', + empty_list_help_document_name=_("tasks") + ) + return super(Task, self).get_empty_list_help(help) + + # ---------------------------------------- + # Case management + # ---------------------------------------- + + def stage_find(self, section_id, domain=[], order='sequence'): + """ Override of the base.stage method + Parameter of the stage search taken from the lead: + - section_id: if set, stages must belong to this section or + be a default stage; if not set, stages must be default + stages + """ + # collect all section_ids + section_ids = [] + if section_id: + section_ids.append(section_id) + section_ids.extend(self.mapped('project_id').ids) + search_domain = [] + if section_ids: + search_domain = [('|')] * (len(section_ids) - 1) + for section_id in section_ids: + search_domain.append(('project_ids', '=', section_id)) + search_domain += list(domain) + # perform search, return the first found + return self.env['project.task.type'].search(search_domain, order=order, limit=1).id + + def _store_history(self): + for task in self: + self.env['project.task.history'].create({ + 'task_id': task.id, + 'remaining_hours': task.remaining_hours, + 'planned_hours': task.planned_hours, + 'kanban_state': task.kanban_state, + 'type_id': task.stage_id.id, + 'user_id': task.user_id.id + + }) + return True + + # ------------------------------------------------ + # CRUD overrides + # ------------------------------------------------ + + @api.model + def create(self, vals): + # context: no_log, because subtype already handle this + context = dict(self.env.context, mail_create_nolog=True) + + # for default stage + if vals.get('project_id') and not context.get('default_project_id'): + context['default_project_id'] = vals.get('project_id') + # user_id change: update date_assign + if vals.get('user_id'): + vals['date_assign'] = fields.Datetime.now() + task = super(Task, self.with_context(context)).create(vals) + task._store_history() + return task + + @api.multi + def write(self, vals): + now = fields.Datetime.now() + # stage change: update date_last_stage_update + if 'stage_id' in vals: + vals['date_last_stage_update'] = now + # reset kanban state when changing stage + if 'kanban_state' not in vals: + vals['kanban_state'] = 'normal' + # user_id change: update date_assign + if vals.get('user_id'): + vals['date_assign'] = now + + result = super(Task, self).write(vals) + + if any(item in vals for item in ['stage_id', 'remaining_hours', 'user_id', 'kanban_state']): + self._store_history() + return result + + # --------------------------------------------------- + # Mail gateway + # --------------------------------------------------- + + @api.multi + def _track_template(self, tracking): + res = super(Task, self)._track_template(tracking) + test_task = self[0] + changes, tracking_value_ids = tracking[test_task.id] + if 'stage_id' in changes and test_task.stage_id.mail_template_id: + res['stage_id'] = (test_task.stage_id.mail_template_id, {'composition_mode': 'mass_mail'}) + return res + + @api.multi + def _track_subtype(self, init_values): + self.ensure_one() + if 'kanban_state' in init_values and self.kanban_state == 'blocked': + return 'project.mt_task_blocked' + elif 'kanban_state' in init_values and self.kanban_state == 'done': + return 'project.mt_task_ready' + elif 'user_id' in init_values and self.user_id: # assigned -> new + return 'project.mt_task_new' + elif 'stage_id' in init_values and self.stage_id and self.stage_id.sequence <= 1: # start stage -> new + return 'project.mt_task_new' + elif 'stage_id' in init_values: + return 'project.mt_task_stage' + return super(Task, self)._track_subtype(init_values) + + @api.multi + def _notification_group_recipients(self, message, recipients, done_ids, group_data): + """ Override the mail.thread method to handle project users and officers + recipients. Indeed those will have specific action in their notification + emails: creating tasks, assigning it. """ + group_project_user = self.env.ref('project.group_project_user') + for recipient in recipients.filtered(lambda recipient: recipient.id not in done_ids): + if recipient.user_ids and group_project_user in recipient.user_ids[0].groups_id: + group_data['group_project_user'] |= recipient + done_ids.add(recipient.id) + return super(Task, self)._notification_group_recipients(message, recipients, done_ids, group_data) + + @api.multi + def _notification_get_recipient_groups(self, message, recipients): + self.ensure_one() + res = super(Task, self)._notification_get_recipient_groups(message, recipients) + + take_action = self._notification_link_helper('assign') + new_action_id = self.env.ref('project.action_view_task').id + new_action = self._notification_link_helper('new', action_id=new_action_id) + + actions = [] + if not self.user_id: + actions.append({'url': take_action, 'title': _('I take it')}) + else: + actions.append({'url': new_action, 'title': _('New Task')}) + + res['group_project_user'] = { + 'actions': actions + } + return res + + @api.model + def message_get_reply_to(self, res_ids, default=None): + """ Override to get the reply_to of the parent project. """ + tasks = self.sudo().browse(res_ids) + project_ids = tasks.mapped('project_id').ids + aliases = self.env['project.project'].message_get_reply_to(project_ids, default=default) + return {task.id: aliases.get(task.project_id.id, False) for task in tasks} + + @api.multi + def 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 + aliases = self.mapped('project_id.alias_name') + return filter(lambda x: x.split('@')[0] not in aliases, email_list) + + @api.model + def message_new(self, msg, custom_values=None): + """ Override to updates the document according to the email. """ + if custom_values is None: + custom_values = {} + defaults = { + 'name': msg.get('subject'), + 'planned_hours': 0.0, + 'partner_id': msg.get('author_id') + } + defaults.update(custom_values) + + res = super(Task, self).message_new(msg, custom_values=defaults) + task = self.browse(res) + email_list = task.email_split(msg) + partner_ids = filter(None, task._find_partner_from_emails(email_list, force_create=False)) + task.message_subscribe(partner_ids) + return res + + @api.multi + def message_update(self, msg, update_vals=None): + """ Override to update the task according to the email. """ + if update_vals is None: + update_vals = {} + maps = { + 'cost': 'planned_hours', + } + for line in msg['body'].split('\n'): + line = line.strip() + res = tools.command_re.match(line) + if res: + match = res.group(1).lower() + field = maps.get(match) + if field: + try: + update_vals[field] = float(res.group(2).lower()) + except (ValueError, TypeError): + pass + + email_list = self.email_split(msg) + partner_ids = filter(None, self._find_partner_from_emails(email_list, force_create=False)) + self.message_subscribe(partner_ids) + return super(Task, self).message_update(msg, update_vals=update_vals) + + @api.multi + def message_get_suggested_recipients(self): + recipients = super(Task, self).message_get_suggested_recipients() + for task in self.filtered('partner_id'): + reason = _('Customer Email') if task.partner_id.email else _('Customer') + task._message_add_suggested_recipient(recipients, partner=task.partner_id, reason=reason) + return recipients + + @api.multi + def message_get_email_values(self, notif_mail=None): + res = super(Task, self).message_get_email_values(notif_mail=notif_mail) + headers = {} + if res.get('headers'): + try: + headers.update(eval(res['headers'])) + except Exception: + pass + if self.project_id: + current_objects = filter(None, headers.get('X-Odoo-Objects', '').split(',')) + current_objects.insert(0, 'project.project-%s, ' % self.project_id.id) + headers['X-Odoo-Objects'] = ','.join(current_objects) + if self.tag_ids: + headers['X-Odoo-Tags'] = ','.join(self.tag_ids.mapped('name')) + res['headers'] = repr(headers) + return res + + +class AccountAnalyticAccount(models.Model): + _inherit = 'account.analytic.account' + _description = 'Analytic Account' + + use_tasks = fields.Boolean(string='Use Tasks', help="Check this box to manage internal activities through this project") + company_uom_id = fields.Many2one('product.uom', related='company_id.project_time_mode_id', string="Company UOM") + project_ids = fields.One2many('project.project', 'analytic_account_id', string='Projects') + project_count = fields.Integer(compute='_compute_project_count', string='Project Count') + + def _compute_project_count(self): + for account in self: + account.project_count = len(account.with_context(active_test=False).project_ids) + + @api.model + def _trigger_project_creation(self, vals): + ''' + This function is used to decide if a project needs to be automatically created or not when an analytic account is created. It returns True if it needs to be so, False otherwise. + ''' + return vals.get('use_tasks') and 'project_creation_in_progress' not in self.env.context + + @api.multi + def project_create(self, vals): + ''' + This function is called at the time of analytic account creation and is used to create a project automatically linked to it if the conditions are meet. + ''' + self.ensure_one() + Project = self.env['project.project'] + project = Project.with_context(active_test=False).search([('analytic_account_id', '=', self.id)]) + if not project and self._trigger_project_creation(vals): + project_values = { + 'name': vals.get('name'), + 'analytic_account_id': self.id, + 'use_tasks': True, + } + return Project.create(project_values).id + return False + + @api.model + def create(self, vals): + analytic_account = super(AccountAnalyticAccount, self).create(vals) + analytic_account.project_create(vals) + return analytic_account + + @api.multi + def write(self, vals): + vals_for_project = vals.copy() + for account in self: + if not vals.get('name'): + vals_for_project['name'] = account.name + account.project_create(vals_for_project) + return super(AccountAnalyticAccount, self).write(vals) + + @api.multi + def unlink(self): + projects = self.env['project.project'].search([('analytic_account_id', 'in', self.ids)]) + has_tasks = self.env['project.task'].search_count([('project_id', 'in', projects.ids)]) + if has_tasks: + raise UserError(_('Please remove existing tasks in the project linked to the accounts you want to delete.')) + return super(AccountAnalyticAccount, self).unlink() + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + if args is None: + args = [] + if self.env.context.get('current_model') == 'project.project': + return self.search(args + [('name', operator, name)], limit=limit).name_get() + + return super(AccountAnalyticAccount, self).name_search(name, args=args, operator=operator, limit=limit) + + @api.multi + def projects_action(self): + projects = self.with_context(active_test=False).mapped('project_ids') + result = { + "type": "ir.actions.act_window", + "res_model": "project.project", + "views": [[False, "tree"], [False, "form"]], + "domain": [["id", "in", projects.ids]], + "context": {"create": False}, + "name": "Projects", + } + if len(projects) == 1: + result['views'] = [(False, "form")] + result['res_id'] = projects.id + else: + result = {'type': 'ir.actions.act_window_close'} + return result + + +class ProjectTaskHistory(models.Model): + """ + Tasks History, used for cumulative flow charts (Lean/Agile) + """ + _name = 'project.task.history' + _description = 'History of Tasks' + _rec_name = 'task_id' + _log_access = False + + @api.depends('date', 'task_id', 'type_id', 'type_id.fold') + def _compute_end_date(self): + for history in self: + if history.type_id and history.type_id.fold: + history.end_date = history.date + continue + self.env.cr.execute('''select + date + from + project_task_history + where + task_id=%s and + id>%s + order by id limit 1''', (history.task_id.id, history.id)) + res = self.env.cr.fetchone() + history.end_date = res and res[0] or False + + task_id = fields.Many2one('project.task', string='Task', ondelete='cascade', required=True, index=True) + type_id = fields.Many2one('project.task.type', string='Stage') + kanban_state = fields.Selection([ + ('normal', 'Normal'), + ('blocked', 'Blocked'), + ('done', 'Ready for next stage') + ], string='Kanban State') + date = fields.Date(string='Date', index=True, default=fields.Date.context_today) + end_date = fields.Date(string='End Date', compute='_compute_end_date', store=True) + remaining_hours = fields.Float(string='Remaining Time', digits=(16, 2)) + planned_hours = fields.Float(string='Planned Time', digits=(16, 2)) + user_id = fields.Many2one('res.users', string='Responsible') + + +class ProjectTags(models.Model): + """ Tags of project's tasks (or issues) """ + _name = "project.tags" + _description = "Tags of project's tasks, issues..." + + name = fields.Char(required=True) + color = fields.Integer(string='Color Index') + + _sql_constraints = [ + ('name_uniq', 'unique (name)', "Tag name already exists !"), + ] diff --git a/addons/project/models/res_company.py b/addons/project/models/res_company.py new file mode 100644 index 00000000000..282b611ca75 --- /dev/null +++ b/addons/project/models/res_company.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + project_time_mode_id = fields.Many2one('product.uom', string='Project Time Unit', + help="This will set the unit of measure used in projects and tasks.\n" + "If you use the timesheet linked to projects, don't " + "forget to setup the right unit of measure in your employees.") diff --git a/addons/project/models/res_config.py b/addons/project/models/res_config.py new file mode 100644 index 00000000000..930ce8773d4 --- /dev/null +++ b/addons/project/models/res_config.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ProjectConfiguration(models.TransientModel): + _name = 'project.config.settings' + _inherit = 'res.config.settings' + + module_pad = fields.Selection([ + (0, "Task description is a plain text"), + (1, "Collaborative rich text on task description") + ], string="Pads", + help='Lets the company customize which Pad installation should be used to link to new pads ' + '(for example: http://ietherpad.com/).\n' + '-This installs the module pad.') + module_rating_project = fields.Selection([ + (0, "No customer rating"), + (1, 'Allow activating customer rating on projects, at issue completion') + ], string="Rating", + help="This allows customers to give rating on provided services") + generate_project_alias = fields.Selection([ + (0, "Do not create an email alias automatically"), + (1, "Automatically generate an email alias at the project creation") + ], string="Project Alias", + help="Odoo will generate an email alias at the project creation from project name.") + module_project_forecast = fields.Boolean(string="Forecasts, planning and Gantt charts") + + @api.multi + def set_default_generate_project_alias(self): + check = self.env.user.has_group('base.group_system') + Values = check and self.env['ir.values'].sudo() or self.env['ir.values'] + for config in self: + Values.set_default('project.config.settings', 'generate_project_alias', config.generate_project_alias) diff --git a/addons/project/models/res_partner.py b/addons/project/models/res_partner.py new file mode 100644 index 00000000000..2fd7f0f7e3a --- /dev/null +++ b/addons/project/models/res_partner.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResPartner(models.Model): + """ Inherits partner and adds Tasks information in the partner form """ + _inherit = 'res.partner' + + task_ids = fields.One2many('project.task', 'partner_id', string='Tasks') + task_count = fields.Integer(compute='_compute_task_count', string='# Tasks') + + def _compute_task_count(self): + for partner in self: + partner.task_count = len(partner.task_ids) diff --git a/addons/project/web_planner.py b/addons/project/models/web_planner.py similarity index 83% rename from addons/project/web_planner.py rename to addons/project/models/web_planner.py index 23710215b51..ae8b43efc56 100644 --- a/addons/project/web_planner.py +++ b/addons/project/models/web_planner.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- -from openerp import models +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models class PlannerProject(models.Model): diff --git a/addons/project/project.py b/addons/project/project.py deleted file mode 100644 index 5c46c6a4957..00000000000 --- a/addons/project/project.py +++ /dev/null @@ -1,1085 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from datetime import datetime, date -from lxml import etree -import time - -from openerp import api -from openerp import SUPERUSER_ID -from openerp import tools -from openerp.osv import fields, osv -from openerp.tools.safe_eval import safe_eval as eval -from openerp.tools.translate import _ -from openerp.exceptions import UserError - - -class project_task_type(osv.osv): - _name = 'project.task.type' - _description = 'Task Stage' - _order = 'sequence' - - def _get_mail_template_id_domain(self): - return [('model', '=', 'project.task')] - - _columns = { - 'name': fields.char('Stage Name', required=True, translate=True), - 'description': fields.text('Description', translate=True), - 'sequence': fields.integer('Sequence'), - 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'), - 'legend_priority': fields.char( - 'Priority Management Explanation', translate=True, - help='Explanation text to help users using the star and priority mechanism on stages or issues that are in this stage.'), - 'legend_blocked': fields.char( - 'Kanban Blocked Explanation', translate=True, - help='Override the default value displayed for the blocked state for kanban selection, when the task or issue is in that stage.'), - 'legend_done': fields.char( - 'Kanban Valid Explanation', translate=True, - help='Override the default value displayed for the done state for kanban selection, when the task or issue is in that stage.'), - 'legend_normal': fields.char( - 'Kanban Ongoing Explanation', translate=True, - help='Override the default value displayed for the normal state for kanban selection, when the task or issue is in that stage.'), - 'mail_template_id': fields.many2one( - 'mail.template', - string='Email Template', - domain=lambda self: self._get_mail_template_id_domain(), - help="If set an email will be sent to the customer when the task or issue reaches this step."), - 'fold': fields.boolean('Folded in Tasks Pipeline', - help='This stage is folded in the kanban view when ' - 'there are no records in that stage to display.'), - } - - def _get_default_project_ids(self, cr, uid, ctx=None): - if ctx is None: - ctx = {} - default_project_id = ctx.get('default_project_id') - return [default_project_id] if default_project_id else None - - _defaults = { - 'sequence': 1, - 'project_ids': _get_default_project_ids, - } - - -class project(osv.osv): - _name = "project.project" - _description = "Project" - _inherit = ['mail.alias.mixin', 'mail.thread', 'ir.needaction_mixin'] - _inherits = {'account.analytic.account': "analytic_account_id"} - _period_number = 5 - - def get_alias_model_name(self, vals): - return vals.get('alias_model', 'project.task') - - def get_alias_values(self): - values = super(project, self).get_alias_values() - values['alias_defaults'] = {'project_id': self.id} - return values - - def onchange_partner_id(self, cr, uid, ids, part=False, context=None): - partner_obj = self.pool.get('res.partner') - val = {} - if not part: - return {'value': val} - if 'pricelist_id' in self.fields_get(cr, uid, context=context): - pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context) - pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False - val['pricelist_id'] = pricelist_id - return {'value': val} - - def unlink(self, cr, uid, ids, context=None): - analytic_account_to_delete = set() - for proj in self.browse(cr, uid, ids, context=context): - if proj.tasks: - raise UserError(_('You cannot delete a project containing tasks. You can either delete all the project\'s tasks and then delete the project or simply deactivate the project.')) - if proj.analytic_account_id and not proj.analytic_account_id.line_ids: - analytic_account_to_delete.add(proj.analytic_account_id.id) - res = super(project, self).unlink(cr, uid, ids, context=context) - self.pool['account.analytic.account'].unlink(cr, uid, list(analytic_account_to_delete), context=context) - return res - - def _get_attached_docs(self, cr, uid, ids, field_name, arg, context): - res = {} - attachment = self.pool.get('ir.attachment') - task = self.pool.get('project.task') - for id in ids: - project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', id)], context=context, count=True) - task_ids = task.search(cr, uid, [('project_id', '=', id)], context=context) - task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context, count=True) - res[id] = (project_attachments or 0) + (task_attachments or 0) - return res - def _task_count(self, cr, uid, ids, field_name, arg, context=None): - if context is None: - context = {} - res={} - for project in self.browse(cr, uid, ids, context=context): - res[project.id] = len(project.task_ids) - return res - - def _task_needaction_count(self, cr, uid, ids, field_name, arg, context=None): - Task = self.pool['project.task'] - res = dict.fromkeys(ids, 0) - projects = Task.read_group(cr, uid, [('project_id', 'in', ids), ('message_needaction', '=', True)], ['project_id'], ['project_id'], context=context) - res.update({project['project_id'][0]: int(project['project_id_count']) for project in projects}) - return res - - def _get_alias_models(self, cr, uid, context=None): - """ Overriden in project_issue to offer more options """ - return [('project.task', "Tasks")] - - def _get_visibility_selection(self, cr, uid, context=None): - """ Overriden in portal_project to offer more options """ - return [ - ('employees', _('Visible by all employees')), - ('followers', _('On invitation only')), - ('portal', _('Shared with a customer')) - ] - - def attachment_tree_view(self, cr, uid, ids, context): - task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)]) - domain = [ - '|', - '&', ('res_model', '=', 'project.project'), ('res_id', 'in', ids), - '&', ('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)] - res_id = ids and ids[0] or False - return { - 'name': _('Attachments'), - 'domain': domain, - 'res_model': 'ir.attachment', - 'type': 'ir.actions.act_window', - 'view_id': False, - 'view_mode': 'kanban,tree,form', - 'view_type': 'form', - 'help': _('''- Documents are attached to the tasks and issues of your project.
- Send messages or log internal notes with attachments to link - documents to your project. -
'''), - 'limit': 80, - 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, res_id) - } - - def activate_sample_project(self, cr, uid, context=None): - """ Unarchives the sample project 'project.project_project_data' and - reloads the project dashboard """ - # Unarchive sample project - data_obj = self.pool.get('ir.model.data') - proj_id = data_obj.xmlid_to_res_id(cr, uid, 'project.project_project_data') - if proj_id: - self.browse(cr, uid, proj_id, context=context).write({'active': True}) - - # Change the help message on the action (no more activate project) - act_id = data_obj.xmlid_to_res_id(cr, uid, 'project.open_view_project_all') - action = None - if act_id: - act_window_obj = self.pool.get('ir.actions.act_window') - act_window_obj.write(cr, SUPERUSER_ID, [act_id], { - "help": _('''Click to create a new project.
''') - }, context=context) - action = act_window_obj.read(cr, uid, [act_id])[0] - # Reload the dashboard - return action - - def _get_favorite(self, cr, uid, ids, name, args, context=None): - return dict((project.id, uid in project.favorite_user_ids.ids) for project in self.browse(cr, uid, ids, context=context)) - - def _get_default_favorite_user_ids(self, cr, uid, context=None): - return [(6, 0, [uid])] - - # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field - _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs) - _visibility_selection = lambda self, *args, **kwargs: self._get_visibility_selection(*args, **kwargs) - - _columns = { - 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."), - 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."), - 'analytic_account_id': fields.many2one( - 'account.analytic.account', 'Contract/Analytic', - help="Link this project to an analytic account if you need financial management on projects. " - "It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.", - ondelete="cascade", required=True, auto_join=True), - 'favorite_user_ids': fields.many2many( - 'res.users', 'project_favorite_user_rel', 'project_id', 'user_id', - string='Members'), - 'is_favorite': fields.function(_get_favorite, type="boolean", string='Show Project on dashboard', - help="Whether this project should be displayed on the dashboard or not"), - 'label_tasks': fields.char('Use Tasks as', help="Gives label to tasks on project's kanban view."), - 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"), - 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report"), - 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages'), - 'task_count': fields.function(_task_count, type='integer', string="Tasks",), - 'task_needaction_count': fields.function(_task_needaction_count, type='integer', string="Tasks",), - 'task_ids': fields.one2many('project.task', 'project_id', string='Tasks', - domain=['|', ('stage_id.fold', '=', False), ('stage_id', '=', False)]), - 'color': fields.integer('Color Index'), - 'user_id': fields.many2one('res.users', 'Project Manager'), - 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="restrict", required=True, - help="Internal email associated with this project. Incoming emails are automatically synchronized " - "with Tasks (or optionally Issues if the Issue Tracker module is installed)."), - 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True, - help="The kind of document created when an email is received on this project's email alias"), - 'privacy_visibility': fields.selection(_visibility_selection, 'Privacy', required=True, - help="Holds visibility of the tasks or issues that belong to the current project:\n" - "- Portal : employees see everything;\n" - " if portal is activated, portal users see the tasks or issues followed by\n" - " them or by someone of their company\n" - "- Employees Only: employees see all tasks or issues\n" - "- Followers Only: employees see only the followed tasks or issues; if portal\n" - " is activated, portal users see the followed tasks or issues."), - 'doc_count': fields.function( - _get_attached_docs, string="Number of documents attached", type='integer' - ), - 'date_start': fields.date('Start Date'), - 'date': fields.date('Expiration Date', select=True, track_visibility='onchange'), - } - - _order = "sequence, name, id" - _defaults = { - 'active': True, - 'favorite_user_ids': _get_default_favorite_user_ids, - 'type': 'contract', - 'label_tasks': 'Tasks', - 'sequence': 10, - 'user_id': lambda self,cr,uid,ctx: uid, - 'alias_model': 'project.task', - 'privacy_visibility': 'employees', - } - - # TODO: Why not using a SQL contraints ? - def _check_dates(self, cr, uid, ids, context=None): - for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context): - if leave['date_start'] and leave['date']: - if leave['date_start'] > leave['date']: - return False - return True - - _constraints = [ - (_check_dates, 'Error! project start-date must be lower than project end-date.', ['date_start', 'date']) - ] - - def set_template(self, cr, uid, ids, context=None): - return self.setActive(cr, uid, ids, value=False, context=context) - - def reset_project(self, cr, uid, ids, context=None): - return self.setActive(cr, uid, ids, value=True, context=context) - - def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None): - """ copy and map tasks from old to new project """ - if context is None: - context = {} - map_task_id = {} - task_obj = self.pool.get('project.task') - proj = self.browse(cr, uid, old_project_id, context=context) - for task in proj.tasks: - # preserve task name and stage, normally altered during copy - defaults = {'stage_id': task.stage_id.id, - 'name': task.name} - map_task_id[task.id] = task_obj.copy(cr, uid, task.id, defaults, context=context) - self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]}) - task_obj.duplicate_task(cr, uid, map_task_id, context=context) - return True - - def copy(self, cr, uid, id, default=None, context=None): - if default is None: - default = {} - context = dict(context or {}) - context['active_test'] = False - proj = self.browse(cr, uid, id, context=context) - if not default.get('name'): - default.update(name=_("%s (copy)") % (proj.name)) - res = super(project, self).copy(cr, uid, id, default, context) - for follower in proj.message_follower_ids: - self.message_subscribe(cr, uid, res, partner_ids=[follower.partner_id.id], subtype_ids=[subtype.id for subtype in follower.subtype_ids]) - self.map_tasks(cr, uid, id, res, context=context) - return res - - def duplicate_template(self, cr, uid, ids, context=None): - context = dict(context or {}) - data_obj = self.pool.get('ir.model.data') - result = [] - for proj in self.browse(cr, uid, ids, context=context): - context.update({'analytic_project_copy': True}) - new_date_start = time.strftime('%Y-%m-%d') - new_date_end = False - if proj.date_start and proj.date: - start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3]) - end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3]) - new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d') - context.update({'copy':True}) - new_id = self.copy(cr, uid, proj.id, default = { - 'name':_("%s (copy)") % (proj.name), - 'date_start':new_date_start, - 'date':new_date_end}, context=context) - result.append(new_id) - - if result and len(result): - res_id = result[0] - form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project') - form_view = data_obj.read(cr, uid, form_view_id, ['res_id']) - tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project') - tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id']) - search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter') - search_view = data_obj.read(cr, uid, search_view_id, ['res_id']) - return { - 'name': _('Projects'), - 'view_type': 'form', - 'view_mode': 'form,tree', - 'res_model': 'project.project', - 'view_id': False, - 'res_id': res_id, - 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')], - 'type': 'ir.actions.act_window', - 'search_view_id': search_view['res_id'], - } - - @api.multi - def setActive(self, value=True): - """ Set a project as active/inactive, and its tasks as well. """ - self.write({'active': value}) - - def create(self, cr, uid, vals, context=None): - ir_values = self.pool.get('ir.values').get_default(cr, uid, 'project.config.settings', 'generate_project_alias') - if ir_values: - vals['alias_name'] = vals.get('alias_name') or vals.get('name') - # Prevent double project creation when 'use_tasks' is checked - create_context = dict(context or {}, - project_creation_in_progress=True, - mail_create_nosubscribe=True) - return super(project, self).create(cr, uid, vals, context=create_context) - - def write(self, cr, uid, ids, vals, context=None): - # if alias_model has been changed, update alias_model_id accordingly - if vals.get('alias_model'): - model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))]) - vals.update(alias_model_id=model_ids[0]) - res = super(project, self).write(cr, uid, ids, vals, context=context) - if 'active' in vals: - # archiving/unarchiving a project does it on its tasks, too - projects = self.browse(cr, uid, ids, context) - tasks = projects.with_context(active_test=False).mapped('tasks') - tasks.write({'active': vals['active']}) - return res - - def toggle_favorite(self, cr, uid, ids, context=None): - favorite_project_ids = [] - not_fav_project_ids = [] - for project in self.browse(cr, uid, ids, context=context): - if uid in project.favorite_user_ids.ids: - favorite_project_ids.append(project.id) - else: - not_fav_project_ids.append(project.id) - - # Project User has no write access for project. - self.write(cr, SUPERUSER_ID, not_fav_project_ids, {'favorite_user_ids': [(4, uid)]}, context=context) - self.write(cr, SUPERUSER_ID, favorite_project_ids, {'favorite_user_ids': [(3, uid)]}, context=context) - - @api.multi - def close_dialog(self): - return {'type': 'ir.actions.act_window_close'} - -class task(osv.osv): - _name = "project.task" - _description = "Task" - _date_name = "date_start" - _inherit = ['mail.thread', 'ir.needaction_mixin'] - _mail_post_access = 'read' - - def _get_default_partner(self, cr, uid, context=None): - if context is None: - context = {} - if 'default_project_id' in context: - project = self.pool.get('project.project').browse(cr, uid, context['default_project_id'], context=context) - if project and project.partner_id: - return project.partner_id.id - return False - - def _get_default_stage_id(self, cr, uid, context=None): - """ Gives default stage_id """ - if context is None: - context = {} - return self.stage_find(cr, uid, [], context.get('default_project_id'), [('fold', '=', False)], context=context) - - def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None): - if context is None: - context = {} - stage_obj = self.pool.get('project.task.type') - order = stage_obj._order - access_rights_uid = access_rights_uid or uid - if read_group_order == 'stage_id desc': - order = '%s desc' % order - if 'default_project_id' in context: - search_domain = ['|', ('project_ids', '=', context['default_project_id']), ('id', 'in', ids)] - else: - search_domain = [('id', 'in', ids)] - stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context) - result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context) - # restore order of the search - result.sort(lambda x, y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0]))) - - fold = {} - for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context): - fold[stage.id] = stage.fold or False - return result, fold - - _group_by_full = { - 'stage_id': _read_group_stage_ids, - } - - def onchange_project(self, cr, uid, id, project_id, context=None): - if project_id: - project = self.pool.get('project.project').browse(cr, uid, project_id, context=context) - if project and project.partner_id: - return {'value': {'partner_id': project.partner_id.id}} - return {} - - def onchange_user_id(self, cr, uid, ids, user_id, context=None): - vals = {} - if user_id: - vals['date_start'] = fields.datetime.now() - return {'value': vals} - - def duplicate_task(self, cr, uid, map_ids, context=None): - mapper = lambda t: map_ids.get(t.id, t.id) - for task in self.browse(cr, uid, map_ids.values(), context): - new_child_ids = set(map(mapper, task.child_ids)) - new_parent_ids = set(map(mapper, task.parent_ids)) - if new_child_ids or new_parent_ids: - task.write({'parent_ids': [(6,0,list(new_parent_ids))], - 'child_ids': [(6,0,list(new_child_ids))]}) - - def copy_data(self, cr, uid, id, default=None, context=None): - if default is None: - default = {} - current = self.browse(cr, uid, id, context=context) - if not default.get('name'): - default['name'] = _("%s (copy)") % current.name - if 'remaining_hours' not in default: - default['remaining_hours'] = current.planned_hours - - return super(task, self).copy_data(cr, uid, id, default, context) - - _columns = { - 'active': fields.boolean('Active'), - 'name': fields.char('Task Title', track_visibility='onchange', size=128, required=True, select=True), - 'description': fields.html('Description'), - 'priority': fields.selection([('0','Normal'), ('1','High')], 'Priority', select=True), - 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."), - 'stage_id': fields.many2one('project.task.type', 'Stage', track_visibility='onchange', select=True, - domain="[('project_ids', '=', project_id)]", copy=False), - 'tag_ids': fields.many2many('project.tags', string='Tags', oldname='categ_ids'), - 'kanban_state': fields.selection([('normal', 'In Progress'),('done', 'Ready for next stage'),('blocked', 'Blocked')], 'Kanban State', - track_visibility='onchange', - help="A task's kanban state indicates special situations affecting it:\n" - " * Normal is the default situation\n" - " * Blocked indicates something is preventing the progress of this task\n" - " * Ready for next stage indicates the task is ready to be pulled to the next stage", - required=True, copy=False), - 'create_date': fields.datetime('Create Date', readonly=True, select=True), - 'write_date': fields.datetime('Last Modification Date', readonly=True, select=True), #not displayed in the view but it might be useful with base_action_rule module (and it needs to be defined first for that) - 'date_start': fields.datetime('Starting Date', select=True, copy=False), - 'date_end': fields.datetime('Ending Date', select=True, copy=False), - 'date_assign': fields.datetime('Assigning Date', select=True, copy=False, readonly=True), - 'date_deadline': fields.date('Deadline', select=True, copy=False), - 'date_last_stage_update': fields.datetime('Last Stage Update', select=True, copy=False, readonly=True), - 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select=True, track_visibility='onchange', change_default=True), - 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'), - 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'), - 'notes': fields.text('Notes'), - 'planned_hours': fields.float('Initially Planned Hours', help='Estimated time to do the task, usually set by the project manager when the task is in draft state.'), - 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."), - 'user_id': fields.many2one('res.users', 'Assigned to', select=True, track_visibility='onchange'), - 'partner_id': fields.many2one('res.partner', 'Customer'), - 'manager_id': fields.related('project_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'), - 'company_id': fields.many2one('res.company', 'Company'), - 'id': fields.integer('ID', readonly=True), - 'color': fields.integer('Color Index'), - 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True), - 'attachment_ids': fields.one2many('ir.attachment', 'res_id', domain=lambda self: [('res_model', '=', self._name)], auto_join=True, string='Attachments'), - # In the domain of displayed_image_id, we couln't use attachment_ids because a one2many is represented as a list of commands so we used res_model & res_id - 'displayed_image_id': fields.many2one('ir.attachment', domain="[('res_model', '=', 'project.task'), ('res_id', '=', id), ('mimetype', 'ilike', 'image')]", string='Displayed Image'), - 'legend_blocked': fields.related("stage_id", "legend_blocked", type="char", string='Kanban Blocked Explanation'), - 'legend_done': fields.related("stage_id", "legend_done", type="char", string='Kanban Valid Explanation'), - 'legend_normal': fields.related("stage_id", "legend_normal", type="char", string='Kanban Ongoing Explanation'), - } - _defaults = { - 'stage_id': _get_default_stage_id, - 'project_id': lambda self, cr, uid, ctx=None: ctx.get('default_project_id') if ctx is not None else False, - 'date_last_stage_update': fields.datetime.now, - 'kanban_state': 'normal', - 'priority': '0', - 'sequence': 10, - 'active': True, - 'user_id': lambda obj, cr, uid, ctx=None: uid, - 'company_id': lambda self, cr, uid, ctx=None: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=ctx), - 'partner_id': lambda self, cr, uid, ctx=None: self._get_default_partner(cr, uid, context=ctx), - 'date_start': fields.datetime.now, - } - _order = "priority desc, sequence, date_start, name, id" - - def _check_recursion(self, cr, uid, ids, context=None): - for id in ids: - visited_branch = set() - visited_node = set() - res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context) - if not res: - return False - - return True - - def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None): - if id in visited_branch: #Cycle - return False - - if id in visited_node: #Already tested don't work one more time for nothing - return True - - visited_branch.add(id) - visited_node.add(id) - - #visit child using DFS - task = self.browse(cr, uid, id, context=context) - for child in task.child_ids: - res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context) - if not res: - return False - - visited_branch.remove(id) - return True - - def _check_dates(self, cr, uid, ids, context=None): - if context == None: - context = {} - obj_task = self.browse(cr, uid, ids[0], context=context) - start = obj_task.date_start or False - end = obj_task.date_end or False - if start and end : - if start > end: - return False - return True - - _constraints = [ - (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']), - (_check_dates, 'Error ! Task starting date must be lower than its ending date.', ['date_start','date_end']) - ] - - # Override view according to the company definition - def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False): - users_obj = self.pool.get('res.users') - if context is None: context = {} - # read uom as admin to avoid access rights issues, e.g. for portal/share users, - # this should be safe (no context passed to avoid side-effects) - obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id - tm = obj_tm and obj_tm.name or 'Hours' - - res = super(task, self).fields_view_get(cr, uid, view_id=view_id, view_type=view_type, context=context, toolbar=toolbar, submenu=submenu) - - # read uom as admin to avoid access rights issues, e.g. for portal/share users, - # this should be safe (no context passed to avoid side-effects) - obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id - try: - # using get_object to get translation value - uom_hour = self.pool['ir.model.data'].get_object(cr, uid, 'product', 'product_uom_hour', context=context) - except ValueError: - uom_hour = False - if not obj_tm or not uom_hour or obj_tm.id == uom_hour.id: - return res - - eview = etree.fromstring(res['arch']) - - # if the project_time_mode_id is not in hours (so in days), display it as a float field - def _check_rec(eview): - if eview.attrib.get('widget','') == 'float_time': - eview.set('widget','float') - for child in eview: - _check_rec(child) - return True - - _check_rec(eview) - - res['arch'] = etree.tostring(eview) - - # replace reference of 'Hours' to 'Day(s)' - for f in res['fields']: - # TODO this NOT work in different language than english - # the field 'Initially Planned Hours' should be replaced by 'Initially Planned Days' - # but string 'Initially Planned Days' is not available in translation - if 'Hours' in res['fields'][f]['string']: - res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours', obj_tm.name) - return res - - def get_empty_list_help(self, cr, uid, help, context=None): - context = dict(context or {}) - context['empty_list_help_id'] = context.get('default_project_id') - context['empty_list_help_model'] = 'project.project' - context['empty_list_help_document_name'] = _("tasks") - return super(task, self).get_empty_list_help(cr, uid, help, context=context) - - # ---------------------------------------- - # Case management - # ---------------------------------------- - - def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None): - """ Override of the base.stage method - Parameter of the stage search taken from the lead: - - section_id: if set, stages must belong to this section or - be a default stage; if not set, stages must be default - stages - """ - if isinstance(cases, (int, long)): - cases = self.browse(cr, uid, cases, context=context) - # collect all section_ids - section_ids = [] - if section_id: - section_ids.append(section_id) - for task in cases: - if task.project_id: - section_ids.append(task.project_id.id) - search_domain = [] - if section_ids: - search_domain = [('|')] * (len(section_ids) - 1) - for section_id in section_ids: - search_domain.append(('project_ids', '=', section_id)) - search_domain += list(domain) - # perform search, return the first found - stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context) - if stage_ids: - return stage_ids[0] - return False - - def _check_child_task(self, cr, uid, ids, context=None): - if context == None: - context = {} - tasks = self.browse(cr, uid, ids, context=context) - for task in tasks: - if task.child_ids: - for child in task.child_ids: - if child.stage_id and not child.stage_id.fold: - raise UserError(_("Child task still open.\nPlease cancel or complete child task first.")) - return True - - - def _store_history(self, cr, uid, ids, context=None): - for task in self.browse(cr, uid, ids, context=context): - self.pool.get('project.task.history').create(cr, uid, { - 'task_id': task.id, - 'remaining_hours': task.remaining_hours, - 'planned_hours': task.planned_hours, - 'kanban_state': task.kanban_state, - 'type_id': task.stage_id.id, - 'user_id': task.user_id.id - - }, context=context) - return True - - # ------------------------------------------------ - # CRUD overrides - # ------------------------------------------------ - - def create(self, cr, uid, vals, context=None): - context = dict(context or {}) - - # for default stage - if vals.get('project_id') and not context.get('default_project_id'): - context['default_project_id'] = vals.get('project_id') - # user_id change: update date_assign - if vals.get('user_id'): - vals['date_assign'] = fields.datetime.now() - # context: no_log, because subtype already handle this - create_context = dict(context, mail_create_nolog=True) - task_id = super(task, self).create(cr, uid, vals, context=create_context) - self._store_history(cr, uid, [task_id], context=context) - return task_id - - def write(self, cr, uid, ids, vals, context=None): - if isinstance(ids, (int, long)): - ids = [ids] - - # stage change: update date_last_stage_update - if 'stage_id' in vals: - vals['date_last_stage_update'] = fields.datetime.now() - # user_id change: update date_assign - if vals.get('user_id'): - vals['date_assign'] = fields.datetime.now() - # reset kanban state when changing stage - if 'stage_id' in vals: - if 'kanban_state' not in vals: - vals['kanban_state'] = 'normal' - - result = super(task, self).write(cr, uid, ids, vals, context=context) - - if any(item in vals for item in ['stage_id', 'remaining_hours', 'user_id', 'kanban_state']): - self._store_history(cr, uid, ids, context=context) - return result - - def unlink(self, cr, uid, ids, context=None): - if context is None: - context = {} - self._check_child_task(cr, uid, ids, context=context) - res = super(task, self).unlink(cr, uid, ids, context) - return res - - def _get_total_hours(self): - return self.remaining_hours - - # --------------------------------------------------- - # Mail gateway - # --------------------------------------------------- - - @api.multi - def _track_template(self, tracking): - res = super(task, self)._track_template(tracking) - test_task = self[0] - changes, tracking_value_ids = tracking[test_task.id] - if 'stage_id' in changes and test_task.stage_id.mail_template_id: - res['stage_id'] = (test_task.stage_id.mail_template_id, {'composition_mode': 'mass_mail'}) - return res - - def _track_subtype(self, cr, uid, ids, init_values, context=None): - record = self.browse(cr, uid, ids[0], context=context) - if 'kanban_state' in init_values and record.kanban_state == 'blocked': - return 'project.mt_task_blocked' - elif 'kanban_state' in init_values and record.kanban_state == 'done': - return 'project.mt_task_ready' - elif 'user_id' in init_values and record.user_id: # assigned -> new - return 'project.mt_task_new' - elif 'stage_id' in init_values and record.stage_id and record.stage_id.sequence <= 1: # start stage -> new - return 'project.mt_task_new' - elif 'stage_id' in init_values: - return 'project.mt_task_stage' - return super(task, self)._track_subtype(cr, uid, ids, init_values, context=context) - - def _notification_group_recipients(self, cr, uid, ids, message, recipients, done_ids, group_data, context=None): - """ Override the mail.thread method to handle project users and officers - recipients. Indeed those will have specific action in their notification - emails: creating tasks, assigning it. """ - group_project_user = self.pool['ir.model.data'].xmlid_to_res_id(cr, uid, 'project.group_project_user') - for recipient in recipients: - if recipient.id in done_ids: - continue - if recipient.user_ids and group_project_user in recipient.user_ids[0].groups_id.ids: - group_data['group_project_user'] |= recipient - done_ids.add(recipient.id) - return super(task, self)._notification_group_recipients(cr, uid, ids, message, recipients, done_ids, group_data, context=context) - - def _notification_get_recipient_groups(self, cr, uid, ids, message, recipients, context=None): - res = super(task, self)._notification_get_recipient_groups(cr, uid, ids, message, recipients, context=context) - - take_action = self._notification_link_helper(cr, uid, ids, 'assign', context=context) - new_action_id = self.pool['ir.model.data'].xmlid_to_res_id(cr, uid, 'project.action_view_task') - new_action = self._notification_link_helper(cr, uid, ids, 'new', context=context, action_id=new_action_id) - - task_record = self.browse(cr, uid, ids[0], context=context) - actions = [] - if not task_record.user_id: - actions.append({'url': take_action, 'title': _('I take it')}) - else: - actions.append({'url': new_action, 'title': _('New Task')}) - - res['group_project_user'] = { - 'actions': actions - } - return res - - @api.cr_uid_context - def message_get_reply_to(self, cr, uid, ids, default=None, context=None): - """ Override to get the reply_to of the parent project. """ - tasks = self.browse(cr, SUPERUSER_ID, ids, context=context) - project_ids = set([task.project_id.id for task in tasks if task.project_id]) - aliases = self.pool['project.project'].message_get_reply_to(cr, uid, list(project_ids), default=default, context=context) - return dict((task.id, aliases.get(task.project_id and task.project_id.id or 0, False)) for task in tasks) - - def email_split(self, cr, uid, ids, msg, context=None): - email_list = tools.email_split((msg.get('to') or '') + ',' + (msg.get('cc') or '')) - # check left-part is not already an alias - task_ids = self.browse(cr, uid, ids, context=context) - aliases = [task.project_id.alias_name for task in task_ids if task.project_id] - return filter(lambda x: x.split('@')[0] not in aliases, email_list) - - def message_new(self, cr, uid, msg, custom_values=None, context=None): - """ Override to updates the document according to the email. """ - if custom_values is None: - custom_values = {} - defaults = { - 'name': msg.get('subject'), - 'planned_hours': 0.0, - 'partner_id': msg.get('author_id', False) - } - defaults.update(custom_values) - - res = super(task, self).message_new(cr, uid, msg, custom_values=defaults, context=context) - email_list = self.email_split(cr, uid, [res], msg, context=context) - partner_ids = filter(None, self._find_partner_from_emails(cr, uid, [res], email_list, force_create=False, context=context)) - self.message_subscribe(cr, uid, [res], partner_ids, context=context) - return res - - def message_update(self, cr, uid, ids, msg, update_vals=None, context=None): - """ Override to update the task according to the email. """ - if update_vals is None: - update_vals = {} - maps = { - 'cost': 'planned_hours', - } - for line in msg['body'].split('\n'): - line = line.strip() - res = tools.command_re.match(line) - if res: - match = res.group(1).lower() - field = maps.get(match) - if field: - try: - update_vals[field] = float(res.group(2).lower()) - except (ValueError, TypeError): - pass - - email_list = self.email_split(cr, uid, ids, msg, context=context) - partner_ids = filter(None, self._find_partner_from_emails(cr, uid, ids, email_list, force_create=False, context=context)) - self.message_subscribe(cr, uid, ids, partner_ids, context=context) - return super(task, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context) - - def message_get_suggested_recipients(self, cr, uid, ids, context=None): - recipients = super(task, self).message_get_suggested_recipients(cr, uid, ids, context=context) - for data in self.browse(cr, uid, ids, context=context): - if data.partner_id: - reason = _('Customer Email') if data.partner_id.email else _('Customer') - data._message_add_suggested_recipient(recipients, partner=data.partner_id, reason=reason) - return recipients - - def message_get_email_values(self, cr, uid, ids, notif_mail=None, context=None): - res = super(task, self).message_get_email_values(cr, uid, ids, notif_mail=notif_mail, context=context) - current_task = self.browse(cr, uid, ids[0], context=context) - headers = {} - if res.get('headers'): - try: - headers.update(eval(res['headers'])) - except Exception: - pass - if current_task.project_id: - current_objects = filter(None, headers.get('X-Odoo-Objects', '').split(',')) - current_objects.insert(0, 'project.project-%s, ' % current_task.project_id.id) - headers['X-Odoo-Objects'] = ','.join(current_objects) - if current_task.tag_ids: - headers['X-Odoo-Tags'] = ','.join([tag.name for tag in current_task.tag_ids]) - res['headers'] = repr(headers) - return res - - -class account_analytic_account(osv.osv): - _inherit = 'account.analytic.account' - _description = 'Analytic Account' - - def _compute_project_count(self, cr, uid, ids, fieldnames, args, context=None): - result = dict.fromkeys(ids, 0) - for account in self.browse(cr, uid, ids, context=context): - result[account.id] = len(account.project_ids) - return result - - _columns = { - 'use_tasks': fields.boolean(string='Use Tasks', help="Check this box to manage internal activities through this project"), - 'company_uom_id': fields.related('company_id', 'project_time_mode_id', string="Company UOM", type='many2one', relation='product.uom'), - 'project_ids': fields.one2many('project.project', 'analytic_account_id', 'Projects'), - 'project_count': fields.function(_compute_project_count, string='Project Count', type='integer') - } - - def on_change_template(self, cr, uid, ids, template_id, date_start=False, context=None): - res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, date_start=date_start, context=context) - if template_id and 'value' in res: - template = self.browse(cr, uid, template_id, context=context) - res['value']['use_tasks'] = template.use_tasks - return res - - def _trigger_project_creation(self, cr, uid, vals, context=None): - ''' - This function is used to decide if a project needs to be automatically created or not when an analytic account is created. It returns True if it needs to be so, False otherwise. - ''' - if context is None: context = {} - return vals.get('use_tasks') and not 'project_creation_in_progress' in context - - @api.cr_uid_id_context - def project_create(self, cr, uid, analytic_account_id, vals, context=None): - ''' - This function is called at the time of analytic account creation and is used to create a project automatically linked to it if the conditions are meet. - ''' - project_pool = self.pool.get('project.project') - project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)]) - if not project_id and self._trigger_project_creation(cr, uid, vals, context=context): - project_values = { - 'name': vals.get('name'), - 'analytic_account_id': analytic_account_id, - 'use_tasks': True, - } - return project_pool.create(cr, uid, project_values, context=context) - return False - - def create(self, cr, uid, vals, context=None): - if context is None: - context = {} - if vals.get('child_ids', False) and context.get('analytic_project_copy', False): - vals['child_ids'] = [] - analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context) - self.project_create(cr, uid, analytic_account_id, vals, context=context) - return analytic_account_id - - def write(self, cr, uid, ids, vals, context=None): - if isinstance(ids, (int, long)): - ids = [ids] - vals_for_project = vals.copy() - for account in self.browse(cr, uid, ids, context=context): - if not vals.get('name'): - vals_for_project['name'] = account.name - self.project_create(cr, uid, account.id, vals_for_project, context=context) - return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context) - - def unlink(self, cr, uid, ids, context=None): - proj_ids = self.pool['project.project'].search(cr, uid, [('analytic_account_id', 'in', ids)]) - has_tasks = self.pool['project.task'].search(cr, uid, [('project_id', 'in', proj_ids)], count=True, context=context) - if has_tasks: - raise UserError(_('Please remove existing tasks in the project linked to the accounts you want to delete.')) - return super(account_analytic_account, self).unlink(cr, uid, ids, context=context) - - def name_search(self, cr, uid, name, args=None, operator='ilike', context=None, limit=100): - if args is None: - args = [] - if context is None: - context={} - if context.get('current_model') == 'project.project': - project_ids = self.search(cr, uid, args + [('name', operator, name)], limit=limit, context=context) - return self.name_get(cr, uid, project_ids, context=context) - - return super(account_analytic_account, self).name_search(cr, uid, name, args=args, operator=operator, context=context, limit=limit) - - def projects_action(self, cr, uid, ids, context=None): - accounts = self.browse(cr, uid, ids, context=context) - project_ids = sum([account.project_ids.ids for account in accounts], []) - result = { - "type": "ir.actions.act_window", - "res_model": "project.project", - "views": [[False, "tree"], [False, "form"]], - "domain": [["id", "in", project_ids]], - "context": {"create": False}, - "name": "Projects", - } - if len(project_ids) == 1: - result['views'] = [(False, "form")] - result['res_id'] = project_ids[0] - else: - result = {'type': 'ir.actions.act_window_close'} - return result - - - -class project_project(osv.osv): - _inherit = 'project.project' - _defaults = { - 'use_tasks': True - } - -class project_task_history(osv.osv): - """ - Tasks History, used for cumulative flow charts (Lean/Agile) - """ - _name = 'project.task.history' - _description = 'History of Tasks' - _rec_name = 'task_id' - _log_access = False - - def _get_date(self, cr, uid, ids, name, arg, context=None): - result = {} - for history in self.browse(cr, uid, ids, context=context): - if history.type_id and history.type_id.fold: - result[history.id] = history.date - continue - cr.execute('''select - date - from - project_task_history - where - task_id=%s and - id>%s - order by id limit 1''', (history.task_id.id, history.id)) - res = cr.fetchone() - result[history.id] = res and res[0] or False - return result - - def _get_related_date(self, cr, uid, ids, context=None): - result = [] - for history in self.browse(cr, uid, ids, context=context): - cr.execute('''select - id - from - project_task_history - where - task_id=%s and - id<%s - order by id desc limit 1''', (history.task_id.id, history.id)) - res = cr.fetchone() - if res: - result.append(res[0]) - return result - - _columns = { - 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True), - 'type_id': fields.many2one('project.task.type', 'Stage'), - 'kanban_state': fields.selection([('normal', 'Normal'), ('blocked', 'Blocked'), ('done', 'Ready for next stage')], 'Kanban State', required=False), - 'date': fields.date('Date', select=True), - 'end_date': fields.function(_get_date, string='End Date', type="date", store={ - 'project.task.history': (_get_related_date, None, 20) - }), - 'remaining_hours': fields.float('Remaining Time', digits=(16, 2)), - 'planned_hours': fields.float('Planned Time', digits=(16, 2)), - 'user_id': fields.many2one('res.users', 'Responsible'), - } - _defaults = { - 'date': fields.date.context_today, - } - -class project_task_history_cumulative(osv.osv): - _name = 'project.task.history.cumulative' - _table = 'project_task_history_cumulative' - _inherit = 'project.task.history' - _auto = False - - _columns = { - 'end_date': fields.date('End Date'), - 'nbr_tasks': fields.integer('# of Tasks', readonly=True), - 'project_id': fields.many2one('project.project', 'Project'), - } - - def init(self, cr): - tools.drop_view_if_exists(cr, 'project_task_history_cumulative') - - cr.execute(""" CREATE VIEW project_task_history_cumulative AS ( - SELECT - history.date::varchar||'-'||history.history_id::varchar AS id, - history.date AS end_date, - * - FROM ( - SELECT - h.id AS history_id, - h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date, - h.task_id, h.type_id, h.user_id, h.kanban_state, - count(h.task_id) as nbr_tasks, - greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours, - t.project_id - FROM - project_task_history AS h - JOIN project_task AS t ON (h.task_id = t.id) - GROUP BY - h.id, - h.task_id, - t.project_id - - ) AS history - ) - """) - -class project_tags(osv.Model): - """ Tags of project's tasks (or issues) """ - _name = "project.tags" - _description = "Tags of project's tasks, issues..." - _columns = { - 'name': fields.char('Name', required=True), - 'color': fields.integer('Color Index'), - } - _sql_constraints = [ - ('name_uniq', 'unique (name)', "Tag name already exists !"), - ] diff --git a/addons/project/project_dashboard.xml b/addons/project/project_dashboard.xml deleted file mode 100644 index d6f7355d144..00000000000 --- a/addons/project/project_dashboard.xml +++ /dev/null @@ -1,111 +0,0 @@ -+ Hello Mr. Adersen, +
++ We need more details about your issue in the HR module. Could you please + send us complete details about the error eg. error message, traceback + or what operations you were doing when you the error occured ? +
++ Thank You. +
++-- +YourCompany +info@yourcompany.example.com ++1 555 123 8069 ++ """} + crm_bug_id = self.ref('project_issue.crm_case_buginaccountsmodule0') + mail = self.env["mail.mail"].with_context(active_model='project.issue', + active_id=crm_bug_id, + active_ids=[crm_bug_id]).create(vals) + mail.send() diff --git a/addons/project_issue/tests/test_subscribe_issue.py b/addons/project_issue/tests/test_subscribe_issue.py new file mode 100644 index 00000000000..2577033bbf0 --- /dev/null +++ b/addons/project_issue/tests/test_subscribe_issue.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +from odoo.addons.project_issue.tests.common import TestIssueUsers +from odoo.modules import module + + +class TestSubscribeIssue(TestIssueUsers): + + def test_subscribe_issues(self): + # In Order to test process of Issue in Odoo, Customer send the issue by email. + request_file = open(module.get_module_resource('project_issue', 'tests', 'issue.eml'), 'rb') + request_message = request_file.read() + self.env["mail.thread"].message_process('project.issue', request_message) + + # After getting the mail, Check details of new issue of that customer. + issue = self.ProjectIssue.search([('email_from', '=', 'Robert Adersen
- The Odoo issues tacker allows you to efficiently manage things + The Odoo issues tracker allows you to efficiently manage things like internal requests, software development bugs, customer complaints, project troubles, material breakdowns, etc.
- The Odoo issues tacker allows you to efficiently manage things + The Odoo issues tracker allows you to efficiently manage things like internal requests, software development bugs, customer complaints, project troubles, material breakdowns, etc.
- The Odoo issues tacker allows you to efficiently manage things + The Odoo issues tracker allows you to efficiently manage things like internal requests, software development bugs, customer complaints, project troubles, material breakdowns, etc.
+ Click to create an issue. +
+ You can track your issues from this menu and the action we will take. +
- Click to create an issue. -
- You can track your issues from this menu and the action we - will take. -
-