This commit is contained in:
Сергей Крылов 2024-04-12 12:07:51 +03:00
parent 16aaea4afe
commit 39c358a61c
317 changed files with 379412 additions and 72 deletions

120
README.md
View File

@ -1,93 +1,69 @@
# project Project Management
------------------
### Infinitely flexible. Incredibly easy to use.
Odoo's collaborative and realtime <a href="https://www.odoo.com/app/project">open source project management</a>
helps your team get work done. Keep track of everything, from the big picture
to the minute details, from the customer contract to the billing.
## Getting started Designed to Fit Your Own Process
--------------------------------
To make it easy for you to get started with GitLab, here's a list of recommended next steps. Organize projects around your own processes. Work on tasks and issues using the
kanban view, schedule tasks using the gantt chart and control deadlines in the
calendar view. Every project may have its own stages, allowing teams to
optimize their job.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! Easy to Use
-----------
## Add your files Get organized as fast as you can think. The easy-to-use interface takes no time
to learn, and every action is instantaneous, so theres nothing standing
between you and your sweet productive flow.
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files Work Together
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: -------------
``` ### Real-time chats, document sharing, email integration
cd existing_repo
git remote add origin https://gitlab.ispras.ru/productteam/odoo17/addons/project.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools Use the chatter to communicate with your team or customers and share comments
and documents on tasks and issues. Integrate discussion fast with the email
integration.
- [ ] [Set up project integrations](https://gitlab.ispras.ru/productteam/odoo17/addons/project/-/settings/integrations) Talk to other users or customers with the website live chat feature.
## Collaborate with your team Collaborative Writing
---------------------
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) ### The power of etherpad, inside your tasks
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
## Test and Deploy Collaboratively edit the same specifications or meeting minutes right inside
the application. The integrated etherpad feature allows several people to
work on the same tasks, at the same time.
Use the built-in continuous integration in GitLab. This is very efficient for scrum meetings, meeting minutes or complex
specifications. Every user has their own color and you can replay the whole
creation of the content.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) Get Work Done
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -------------
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
*** Get alerts on followed events to stay up to date with what interests you. Use
instant green/red visual indicators to scan through what has been done and what
requires your attention.
# Editing this README Timesheets, Contracts & Invoicing
---------------------------------
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. Projects are automatically integrated with customer contracts, allowing you to
invoice based on time & materials and record timesheets easily.
## Suggestions for a good README Track Issues
------------
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. Single out the issues that arise in a project in order to have a better focus
on resolving them. Integrate customer interaction on every issue and get
accurate reports on your team's performance.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

35
__init__.py Normal file
View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import report
from . import wizard
from . import populate
from odoo.tools.sql import create_index
def _check_exists_collaborators_for_project_sharing(env):
""" Check if it exists at least a collaborator in a shared project
If it is the case we need to active the portal rules added only for this feature.
"""
collaborator = env['project.collaborator'].search([], limit=1)
if collaborator:
# Then we need to enable the access rights linked to project sharing for the portal user
env['project.collaborator']._toggle_project_sharing_portal_rules(True)
def _project_post_init(env):
_check_exists_collaborators_for_project_sharing(env)
# Index to improve the performance of burndown chart.
project_task_stage_field_id = env['ir.model.fields']._get_ids('project.task').get('stage_id')
create_index(
env.cr,
'mail_tracking_value_mail_message_id_old_value_integer_task_stage',
env['mail.tracking.value']._table,
['mail_message_id', 'old_value_integer'],
where=f'field_id={project_task_stage_field_id}'
)

209
__manifest__.py Normal file
View File

@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Project',
'version': '1.3',
'website': 'https://www.odoo.com/app/project',
'category': 'Services/Project',
'sequence': 45,
'summary': 'Organize and plan your projects',
'depends': [
'analytic',
'base_setup',
'mail',
'portal',
'rating',
'resource',
'web',
'web_tour',
'digest',
],
'data': [
'security/project_security.xml',
'security/ir.model.access.csv',
'security/ir.model.access.xml',
'data/digest_data.xml',
'report/project_task_burndown_chart_report_views.xml',
'views/account_analytic_account_views.xml',
'views/digest_digest_views.xml',
'views/rating_rating_views.xml',
'views/project_update_views.xml',
'views/project_update_templates.xml',
'views/project_project_stage_views.xml',
'wizard/project_share_wizard_views.xml',
'views/project_collaborator_views.xml',
'views/project_task_type_views.xml',
'views/project_project_views.xml',
'views/project_task_views.xml',
'views/project_tags_views.xml',
'views/project_milestone_views.xml',
'views/res_partner_views.xml',
'views/res_config_settings_views.xml',
'views/mail_activity_plan_views.xml',
'views/mail_activity_type_views.xml',
'views/project_sharing_project_task_views.xml',
'views/project_portal_project_project_templates.xml',
'views/project_portal_project_task_templates.xml',
'views/project_task_templates.xml',
'views/project_sharing_project_task_templates.xml',
'report/project_report_views.xml',
'data/ir_cron_data.xml',
'data/mail_message_subtype_data.xml',
'data/mail_template_data.xml',
'data/project_data.xml',
'wizard/project_task_type_delete_views.xml',
'wizard/project_project_stage_delete_views.xml',
'views/project_menus.xml',
],
'demo': [
'data/mail_template_demo.xml',
'data/project_demo.xml',
],
'installable': True,
'application': True,
'post_init_hook': '_project_post_init',
'assets': {
'web.assets_backend': [
'project/static/src/css/project.css',
'project/static/src/utils/**/*',
'project/static/src/components/**/*',
'project/static/src/views/**/*',
'project/static/src/js/tours/project.js',
'project/static/src/scss/project_dashboard.scss',
'project/static/src/scss/project_form.scss',
'project/static/src/scss/project_widgets.scss',
'project/static/src/xml/**/*',
],
'web.assets_frontend': [
'project/static/src/scss/portal_rating.scss',
'project/static/src/scss/project_sharing_frontend.scss',
'project/static/src/js/portal_rating.js',
],
'web.qunit_suite_tests': [
'project/static/src/project_sharing/components/portal_file_input/portal_file_input.js',
'project/static/tests/**/*.js',
],
'web.assets_tests': [
'project/static/tests/tours/**/*',
],
'project.webclient': [
('include', 'web._assets_helpers'),
('include', 'web._assets_backend_helpers'),
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
'web/static/src/libs/fontawesome/css/font-awesome.css',
'web/static/lib/odoo_ui_icons/*',
'web/static/lib/select2/select2.css',
'web/static/lib/select2-bootstrap-css/select2-bootstrap.css',
'web/static/src/webclient/navbar/navbar.scss',
'web/static/src/scss/animation.scss',
'web/static/src/core/colorpicker/colorpicker.scss',
'web/static/src/scss/mimetypes.scss',
'web/static/src/scss/ui.scss',
'web/static/src/legacy/scss/ui.scss',
'web/static/src/views/fields/translation_dialog.scss',
'web/static/src/scss/fontawesome_overridden.scss',
'web/static/src/module_loader.js',
'web/static/src/session.js',
'web/static/lib/luxon/luxon.js',
'web/static/lib/owl/owl.js',
'web/static/lib/owl/odoo_module.js',
'web/static/lib/jquery/jquery.js',
'web/static/lib/popper/popper.js',
'web/static/lib/bootstrap/js/dist/dom/data.js',
'web/static/lib/bootstrap/js/dist/dom/event-handler.js',
'web/static/lib/bootstrap/js/dist/dom/manipulator.js',
'web/static/lib/bootstrap/js/dist/dom/selector-engine.js',
'web/static/lib/bootstrap/js/dist/base-component.js',
'web/static/lib/bootstrap/js/dist/alert.js',
'web/static/lib/bootstrap/js/dist/button.js',
'web/static/lib/bootstrap/js/dist/carousel.js',
'web/static/lib/bootstrap/js/dist/collapse.js',
'web/static/lib/bootstrap/js/dist/dropdown.js',
'web/static/lib/bootstrap/js/dist/modal.js',
'web/static/lib/bootstrap/js/dist/offcanvas.js',
'web/static/lib/bootstrap/js/dist/tooltip.js',
'web/static/lib/bootstrap/js/dist/popover.js',
'web/static/lib/bootstrap/js/dist/scrollspy.js',
'web/static/lib/bootstrap/js/dist/tab.js',
'web/static/lib/bootstrap/js/dist/toast.js',
'web/static/lib/select2/select2.js',
'web/static/src/legacy/js/libs/bootstrap.js',
'web/static/src/legacy/js/libs/jquery.js',
('include', 'web._assets_bootstrap_backend'),
'base/static/src/css/modules.css',
'web/static/src/core/utils/transitions.scss',
'web/static/src/core/**/*',
'web/static/src/model/**/*',
'web/static/src/search/**/*',
'web/static/src/webclient/icons.scss', # variables required in list_controller.scss
'web/static/src/views/**/*.js',
'web/static/src/views/*.xml',
'web/static/src/views/*.scss',
'web/static/src/views/fields/**/*',
'web/static/src/views/form/**/*',
'web/static/src/views/kanban/**/*',
'web/static/src/views/list/**/*',
'web/static/src/views/view_button/**/*',
'web/static/src/views/view_components/**/*',
'web/static/src/views/view_dialogs/**/*',
'web/static/src/views/widgets/**/*',
'web/static/src/webclient/**/*',
('remove', 'web/static/src/webclient/clickbot/clickbot.js'), # lazy loaded
('remove', 'web/static/src/views/form/button_box/*.scss'),
('remove', 'web/static/src/core/emoji_picker/emoji_data.js'),
# remove the report code and whitelist only what's needed
('remove', 'web/static/src/webclient/actions/reports/**/*'),
'web/static/src/webclient/actions/reports/*.js',
'web/static/src/webclient/actions/reports/*.xml',
'web/static/src/env.js',
('include', 'web_editor.assets_wysiwyg'),
'web/static/src/legacy/scss/fields.scss',
'base/static/src/scss/res_partner.scss',
# Form style should be computed before
'web/static/src/views/form/button_box/*.scss',
'web_editor/static/src/js/editor/odoo-editor/src/base_style.scss',
'web_editor/static/lib/vkbeautify/**/*',
'web_editor/static/src/js/common/**/*',
'web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js',
'web_editor/static/src/js/wysiwyg/fonts.js',
'web_editor/static/src/components/**/*',
'web_editor/static/src/scss/web_editor.common.scss',
'web_editor/static/src/scss/web_editor.backend.scss',
'web_editor/static/src/js/backend/**/*',
'web_editor/static/src/xml/backend.xml',
'mail/static/src/scss/variables/*.scss',
'mail/static/src/views/web/form/form_renderer.scss',
'project/static/src/components/project_task_name_with_subtask_count_char_field/*',
'project/static/src/components/project_task_state_selection/*',
'project/static/src/components/project_many2one_field/*',
'partner_autocomplete/static/src/js/partner_autocomplete_core.js',
'partner_autocomplete/static/src/js/partner_autocomplete_many2one.js',
'partner_autocomplete/static/src/xml/partner_autocomplete.xml',
'project/static/src/views/project_task_form/*.scss',
'project/static/src/project_sharing/search/favorite_menu/custom_favorite_item.xml',
'project/static/src/project_sharing/**/*',
'web/static/src/start.js',
],
},
'license': 'LGPL-3',
}

5
controllers/__init__.py Normal file
View File

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

546
controllers/portal.py Normal file
View File

@ -0,0 +1,546 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import OrderedDict
from operator import itemgetter
from markupsafe import Markup
from odoo import conf, http, _
from odoo.exceptions import AccessError, MissingError
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
from odoo.tools import groupby as groupbyelem
from odoo.osv.expression import OR, AND
class ProjectCustomerPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'project_count' in counters:
values['project_count'] = request.env['project.project'].search_count([]) \
if request.env['project.project'].check_access_rights('read', raise_exception=False) else 0
if 'task_count' in counters:
values['task_count'] = request.env['project.task'].search_count([('project_id', '!=', False)]) \
if request.env['project.task'].check_access_rights('read', raise_exception=False) else 0
return values
# ------------------------------------------------------------
# My Project
# ------------------------------------------------------------
def _project_get_page_view_values(self, project, access_token, page=1, date_begin=None, date_end=None, sortby=None, search=None, search_in='content', groupby=None, **kwargs):
# default filter by value
domain = [('project_id', '=', project.id)]
# pager
url = "/my/projects/%s" % project.id
values = self._prepare_tasks_values(page, date_begin, date_end, sortby, search, search_in, groupby, url, domain, su=bool(access_token), project=project)
# adding the access_token to the pager's url args,
# so we are not prompted for loging when switching pages
# if access_token is None, the arg is not present in the URL
values['pager']['url_args']['access_token'] = access_token
pager = portal_pager(**values['pager'])
values.update(
grouped_tasks=values['grouped_tasks'](pager['offset']),
page_name='project',
pager=pager,
project=project,
task_url=f'projects/{project.id}/task',
preview_object=project,
)
if not groupby:
values['groupby'] = 'project' if self._display_project_groupby(project) else 'none'
return self._get_page_view_values(project, access_token, values, 'my_projects_history', False, **kwargs)
def _prepare_project_domain(self):
return []
def _prepare_searchbar_sortings(self):
return {
'date': {'label': _('Newest'), 'order': 'create_date desc'},
'name': {'label': _('Name'), 'order': 'name'},
}
@http.route(['/my/projects', '/my/projects/page/<int:page>'], type='http', auth="user", website=True)
def portal_my_projects(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
values = self._prepare_portal_layout_values()
Project = request.env['project.project']
domain = self._prepare_project_domain()
searchbar_sortings = self._prepare_searchbar_sortings()
if not sortby:
sortby = 'date'
order = searchbar_sortings[sortby]['order']
if date_begin and date_end:
domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)]
# projects count
project_count = Project.search_count(domain)
# pager
pager = portal_pager(
url="/my/projects",
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby},
total=project_count,
page=page,
step=self._items_per_page
)
# content according to pager and archive selected
projects = Project.search(domain, order=order, limit=self._items_per_page, offset=pager['offset'])
request.session['my_projects_history'] = projects.ids[:100]
values.update({
'date': date_begin,
'date_end': date_end,
'projects': projects,
'page_name': 'project',
'default_url': '/my/projects',
'pager': pager,
'searchbar_sortings': searchbar_sortings,
'sortby': sortby
})
return request.render("project.portal_my_projects", values)
@http.route(['/my/project/<int:project_id>',
'/my/project/<int:project_id>/page/<int:page>',
'/my/project/<int:project_id>/task/<int:task_id>',
'/my/project/<int:project_id>/project_sharing'], type='http', auth="public")
def portal_project_routes_outdated(self, **kwargs):
""" Redirect the outdated routes to the new routes. """
return request.redirect(request.httprequest.full_path.replace('/my/project/', '/my/projects/'))
@http.route(['/my/task',
'/my/task/page/<int:page>',
'/my/task/<int:task_id>'], type='http', auth='public')
def portal_my_task_routes_outdated(self, **kwargs):
""" Redirect the outdated routes to the new routes. """
return request.redirect(request.httprequest.full_path.replace('/my/task', '/my/tasks'))
@http.route(['/my/projects/<int:project_id>', '/my/projects/<int:project_id>/page/<int:page>'], type='http', auth="public", website=True)
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):
try:
project_sudo = self._document_check_access('project.project', project_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
if project_sudo.collaborator_count and project_sudo.with_user(request.env.user)._check_project_sharing_access():
values = {'project_id': project_id}
if task_id:
values['task_id'] = task_id
return request.render("project.project_sharing_portal", values)
project_sudo = project_sudo if access_token else project_sudo.with_user(request.env.user)
if not groupby:
groupby = 'stage'
values = self._project_get_page_view_values(project_sudo, access_token, page, date_begin, date_end, sortby, search, search_in, groupby, **kw)
return request.render("project.portal_my_project", values)
def _get_project_sharing_company(self, project):
return project.company_id or request.env.user.company_id
def _prepare_project_sharing_session_info(self, project, task=None):
session_info = request.env['ir.http'].session_info()
user_context = dict(request.env.context) if request.session.uid else {}
mods = conf.server_wide_modules or []
if request.env.lang:
lang = request.env.lang
session_info['user_context']['lang'] = lang
# Update Cache
user_context['lang'] = lang
lang = user_context.get("lang")
translation_hash = request.env['ir.http'].get_web_translations_hash(mods, lang)
cache_hashes = {
"translations": translation_hash,
}
project_company = self._get_project_sharing_company(project)
session_info.update(
cache_hashes=cache_hashes,
action_name=project.action_project_sharing(),
project_id=project.id,
user_companies={
'current_company': project_company.id,
'allowed_companies': {
project_company.id: {
'id': project_company.id,
'name': project_company.name,
},
},
},
# FIXME: See if we prefer to give only the currency that the portal user just need to see the correct information in project sharing
currencies=request.env['ir.http'].get_currencies(),
)
if task:
session_info['open_task_action'] = task.action_project_sharing_open_task()
return session_info
@http.route("/my/projects/<int:project_id>/project_sharing", type="http", auth="user", methods=['GET'])
def render_project_backend_view(self, project_id, task_id=None):
project = request.env['project.project'].sudo().browse(project_id)
if not project.exists() or not project.with_user(request.env.user)._check_project_sharing_access():
return request.not_found()
task = task_id and request.env['project.task'].browse(int(task_id))
return request.render(
'project.project_sharing_embed',
{'session_info': self._prepare_project_sharing_session_info(project, task)},
)
@http.route('/my/projects/<int:project_id>/task/<int:task_id>', type='http', auth='public', website=True)
def portal_my_project_task(self, project_id=None, task_id=None, access_token=None, **kw):
try:
project_sudo = self._document_check_access('project.project', project_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
Task = request.env['project.task']
if access_token:
Task = Task.sudo()
task_sudo = Task.search([('project_id', '=', project_id), ('id', '=', task_id)], limit=1).sudo()
task_sudo.attachment_ids.generate_access_token()
values = self._task_get_page_view_values(task_sudo, access_token, project=project_sudo, **kw)
values['project'] = project_sudo
return request.render("project.portal_my_task", values)
@http.route('/my/projects/<int:project_id>/task/<int:task_id>/subtasks', type='http', auth='user', methods=['GET'], website=True)
def portal_my_project_subtasks(self, project_id, task_id, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', groupby=None, **kw):
try:
project_sudo = self._document_check_access('project.project', project_id)
task_sudo = request.env['project.task'].search([('project_id', '=', project_id), ('id', '=', task_id)]).sudo()
task_domain = [('id', 'child_of', task_id), ('id', '!=', task_id)]
searchbar_filters = self._get_my_tasks_searchbar_filters([('id', '=', task_sudo.project_id.id)], task_domain)
if not filterby:
filterby = 'all'
domain = searchbar_filters.get(filterby, searchbar_filters.get('all'))['domain']
values = self._prepare_tasks_values(page, date_begin, date_end, sortby, search, search_in, groupby, url=f'/my/projects/{project_id}/task/{task_id}/subtasks', domain=AND([task_domain, domain]))
values['page_name'] = 'project_subtasks'
# pager
pager_vals = values['pager']
pager_vals['url_args'].update(filterby=filterby)
pager = portal_pager(**pager_vals)
values.update({
'project': project_sudo,
'task': task_sudo,
'grouped_tasks': values['grouped_tasks'](pager['offset']),
'pager': pager,
'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())),
'filterby': filterby,
})
return request.render("project.portal_my_tasks", values)
except (AccessError, MissingError):
return request.not_found()
# ------------------------------------------------------------
# My Task
# ------------------------------------------------------------
def _task_get_page_view_values(self, task, access_token, **kwargs):
project = kwargs.get('project')
if project:
project_accessible = True
page_name = 'project_task'
history = 'my_project_tasks_history'
else:
page_name = 'task'
history = 'my_tasks_history'
try:
project_accessible = bool(task.project_id.id and self._document_check_access('project.project', task.project_id.id))
except (AccessError, MissingError):
project_accessible = False
values = {
'page_name': page_name,
'task': task,
'user': request.env.user,
'project_accessible': project_accessible,
'task_link_section': [],
'preview_object': task,
}
values = self._get_page_view_values(task, access_token, values, history, False, **kwargs)
if project:
values['project_id'] = project.id
history = request.session.get('my_project_tasks_history', [])
try:
current_task_index = history.index(task.id)
except ValueError:
return values
total_task = len(history)
task_url = f"{task.project_id.access_url}/task/%s?model=project.project&res_id={values['user'].id}&access_token={access_token}"
values['prev_record'] = current_task_index != 0 and task_url % history[current_task_index - 1]
values['next_record'] = current_task_index < total_task - 1 and task_url % history[current_task_index + 1]
return values
def _task_get_searchbar_sortings(self, milestones_allowed, project=False):
values = {
'date': {'label': _('Newest'), 'order': 'create_date desc', 'sequence': 1},
'name': {'label': _('Title'), 'order': 'name', 'sequence': 2},
'users': {'label': _('Assignees'), 'order': 'user_ids', 'sequence': 4},
'stage': {'label': _('Stage'), 'order': 'stage_id, project_id', 'sequence': 5},
'status': {'label': _('Status'), 'order': 'state', 'sequence': 6},
'priority': {'label': _('Priority'), 'order': 'priority desc', 'sequence': 8},
'date_deadline': {'label': _('Deadline'), 'order': 'date_deadline asc', 'sequence': 9},
'update': {'label': _('Last Stage Update'), 'order': 'date_last_stage_update desc', 'sequence': 11},
}
if not project:
values['project'] = {'label': _('Project'), 'order': 'project_id, stage_id', 'sequence': 3}
if milestones_allowed:
values['milestone'] = {'label': _('Milestone'), 'order': 'milestone_id', 'sequence': 7}
return values
# Meant to be overridden in documents_project
def _display_project_groupby(self, project):
return not project
def _task_get_searchbar_groupby(self, milestones_allowed, project=False):
values = {
'none': {'input': 'none', 'label': _('None'), 'order': 1},
'stage': {'input': 'stage', 'label': _('Stage'), 'order': 4},
'status': {'input': 'status', 'label': _('Status'), 'order': 5},
'priority': {'input': 'priority', 'label': _('Priority'), 'order': 7},
'customer': {'input': 'customer', 'label': _('Customer'), 'order': 10},
}
if self._display_project_groupby(project):
values['project'] = {'input': 'project', 'label': _('Project'), 'order': 2}
if milestones_allowed:
values['milestone'] = {'input': 'milestone', 'label': _('Milestone'), 'order': 6}
return dict(sorted(values.items(), key=lambda item: item[1]["order"]))
def _task_get_groupby_mapping(self):
return {
'project': 'project_id',
'stage': 'stage_id',
'customer': 'partner_id',
'milestone': 'milestone_id',
'priority': 'priority',
'status': 'state',
}
def _task_get_order(self, order, groupby):
groupby_mapping = self._task_get_groupby_mapping()
field_name = groupby_mapping.get(groupby, '')
if not field_name:
return order
return '%s, %s' % (field_name, order)
def _task_get_searchbar_inputs(self, milestones_allowed, project=False):
values = {
'all': {'input': 'all', 'label': _('Search in All'), 'order': 1},
'content': {'input': 'content', 'label': Markup(_('Search <span class="nolabel"> (in Content)</span>')), 'order': 1},
'ref': {'input': 'ref', 'label': _('Search in Ref'), 'order': 1},
'users': {'input': 'users', 'label': _('Search in Assignees'), 'order': 3},
'stage': {'input': 'stage', 'label': _('Search in Stages'), 'order': 4},
'status': {'input': 'status', 'label': _('Search in Status'), 'order': 5},
'priority': {'input': 'priority', 'label': _('Search in Priority'), 'order': 7},
'customer': {'input': 'customer', 'label': _('Search in Customer'), 'order': 10},
'message': {'input': 'message', 'label': _('Search in Messages'), 'order': 11},
}
if not project:
values['project'] = {'input': 'project', 'label': _('Search in Project'), 'order': 2}
if milestones_allowed:
values['milestone'] = {'input': 'milestone', 'label': _('Search in Milestone'), 'order': 6}
return dict(sorted(values.items(), key=lambda item: item[1]["order"]))
def _task_get_search_domain(self, search_in, search):
search_domain = []
if search_in in ('content', 'all'):
search_domain.append([('name', 'ilike', search)])
search_domain.append([('description', 'ilike', search)])
if search_in in ('customer', 'all'):
search_domain.append([('partner_id', 'ilike', search)])
if search_in in ('message', 'all'):
search_domain.append([('message_ids.body', 'ilike', search)])
if search_in in ('stage', 'all'):
search_domain.append([('stage_id', 'ilike', search)])
if search_in in ('project', 'all'):
search_domain.append([('project_id', 'ilike', search)])
if search_in in ('ref', 'all'):
search_domain.append([('id', 'ilike', search)])
if search_in in ('milestone', 'all'):
search_domain.append([('milestone_id', 'ilike', search)])
if search_in in ('users', 'all'):
user_ids = request.env['res.users'].sudo().search([('name', 'ilike', search)])
search_domain.append([('user_ids', 'in', user_ids.ids)])
if search_in in ('priority', 'all'):
search_domain.append([('priority', 'ilike', search == 'normal' and '0' or '1')])
if search_in in ('status', 'all'):
state_dict = dict(map(reversed, request.env['project.task']._fields['state']._description_selection(request.env)))
search_domain.append([('state', 'ilike', state_dict.get(search, search))])
return OR(search_domain)
def _prepare_tasks_values(self, page, date_begin, date_end, sortby, search, search_in, groupby, url="/my/tasks", domain=None, su=False, project=False):
values = self._prepare_portal_layout_values()
Task = request.env['project.task']
milestone_domain = AND([domain, [('allow_milestones', '=', 'True')]])
milestones_allowed = Task.sudo().search_count(milestone_domain, limit=1) == 1
searchbar_sortings = dict(sorted(self._task_get_searchbar_sortings(milestones_allowed, project).items(),
key=lambda item: item[1]["sequence"]))
searchbar_inputs = self._task_get_searchbar_inputs(milestones_allowed, project)
searchbar_groupby = self._task_get_searchbar_groupby(milestones_allowed, project)
if not domain:
domain = []
if not su and Task.check_access_rights('read'):
domain = AND([domain, request.env['ir.rule']._compute_domain(Task._name, 'read')])
Task_sudo = Task.sudo()
# default sort by value
if not sortby or (sortby == 'milestone' and not milestones_allowed):
sortby = 'date'
order = searchbar_sortings[sortby]['order']
# default group by value
if not groupby or (groupby == 'milestone' and not milestones_allowed):
groupby = 'project'
if date_begin and date_end:
domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)]
# search reset if needed
if not milestones_allowed and search_in == 'milestone':
search_in = 'all'
# search
if search and search_in:
domain += self._task_get_search_domain(search_in, search)
# content according to pager and archive selected
order = self._task_get_order(order, groupby)
def get_grouped_tasks(pager_offset):
tasks = Task_sudo.search(domain, order=order, limit=self._items_per_page, offset=pager_offset)
request.session['my_project_tasks_history' if url.startswith('/my/projects') else 'my_tasks_history'] = tasks.ids[:100]
tasks_project_allow_milestone = tasks.filtered(lambda t: t.allow_milestones)
tasks_no_milestone = tasks - tasks_project_allow_milestone
groupby_mapping = self._task_get_groupby_mapping()
group = groupby_mapping.get(groupby)
if group:
if group == 'milestone_id':
grouped_tasks = [Task_sudo.concat(*g) for k, g in groupbyelem(tasks_project_allow_milestone, itemgetter(group))]
if not grouped_tasks:
if tasks_no_milestone:
grouped_tasks = [tasks_no_milestone]
else:
if grouped_tasks[len(grouped_tasks) - 1][0].milestone_id and tasks_no_milestone:
grouped_tasks.append(tasks_no_milestone)
else:
grouped_tasks[len(grouped_tasks) - 1] |= tasks_no_milestone
else:
grouped_tasks = [Task_sudo.concat(*g) for k, g in groupbyelem(tasks, itemgetter(group))]
else:
grouped_tasks = [tasks] if tasks else []
task_states = dict(Task_sudo._fields['state']._description_selection(request.env))
if sortby == 'status':
if groupby == 'none' and grouped_tasks:
grouped_tasks[0] = grouped_tasks[0].sorted(lambda tasks: task_states.get(tasks.state))
else:
grouped_tasks.sort(key=lambda tasks: task_states.get(tasks[0].state))
return grouped_tasks
values.update({
'date': date_begin,
'date_end': date_end,
'grouped_tasks': get_grouped_tasks,
'allow_milestone': milestones_allowed,
'page_name': 'task',
'default_url': url,
'task_url': 'tasks',
'pager': {
"url": url,
"url_args": {'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'groupby': groupby, 'search_in': search_in, 'search': search},
"total": Task_sudo.search_count(domain),
"page": page,
"step": self._items_per_page
},
'searchbar_sortings': searchbar_sortings,
'searchbar_groupby': searchbar_groupby,
'searchbar_inputs': searchbar_inputs,
'search_in': search_in,
'search': search,
'sortby': sortby,
'groupby': groupby,
})
return values
def _get_my_tasks_searchbar_filters(self, project_domain=None, task_domain=None):
searchbar_filters = {
'all': {'label': _('All'), 'domain': [('project_id', '!=', False)]},
}
# extends filterby criteria with project the customer has access to
projects = request.env['project.project'].search(project_domain or [])
for project in projects:
searchbar_filters.update({
str(project.id): {'label': project.name, 'domain': [('project_id', '=', project.id)]}
})
# extends filterby criteria with project (criteria name is the project id)
# Note: portal users can't view projects they don't follow
project_groups = request.env['project.task']._read_group(AND([[('project_id', 'not in', projects.ids)], task_domain or []]),
['project_id'])
for [project] in project_groups:
proj_name = project.sudo().display_name if project else _('Others')
searchbar_filters.update({
str(project.id): {'label': proj_name, 'domain': [('project_id', '=', project.id)]}
})
return searchbar_filters
@http.route(['/my/tasks', '/my/tasks/page/<int:page>'], type='http', auth="user", website=True)
def portal_my_tasks(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', groupby=None, **kw):
searchbar_filters = self._get_my_tasks_searchbar_filters()
if not filterby:
filterby = 'all'
domain = searchbar_filters.get(filterby, searchbar_filters.get('all'))['domain']
values = self._prepare_tasks_values(page, date_begin, date_end, sortby, search, search_in, groupby, domain=domain)
# pager
pager_vals = values['pager']
pager_vals['url_args'].update(filterby=filterby)
pager = portal_pager(**pager_vals)
values.update({
'grouped_tasks': values['grouped_tasks'](pager['offset']),
'pager': pager,
'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())),
'filterby': filterby,
})
return request.render("project.portal_my_tasks", values)
def _show_task_report(self, task_sudo, report_type, download):
# This method is to be overriden to report timesheets if the module is installed.
# The route should not be called if at least hr_timesheet is not installed
raise MissingError(_('There is nothing to report.'))
@http.route(['/my/tasks/<int:task_id>'], type='http', auth="public", website=True)
def portal_my_task(self, task_id, report_type=None, access_token=None, project_sharing=False, **kw):
try:
task_sudo = self._document_check_access('project.task', task_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
if report_type in ('pdf', 'html', 'text'):
return self._show_task_report(task_sudo, report_type, download=kw.get('download'))
# ensure attachment are accessible with access token inside template
for attachment in task_sudo.attachment_ids:
attachment.generate_access_token()
if project_sharing is True:
# Then the user arrives to the stat button shown in form view of project.task and the portal user can see only 1 task
# so the history should be reset.
request.session['my_tasks_history'] = task_sudo.ids
values = self._task_get_page_view_values(task_sudo, access_token, **kw)
return request.render("project.portal_my_task", values)

View File

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from werkzeug.exceptions import Forbidden
from odoo.http import request, route
from odoo.addons.portal.controllers.mail import PortalChatter
from .portal import ProjectCustomerPortal
class ProjectSharingChatter(PortalChatter):
def _check_project_access_and_get_token(self, project_id, res_model, res_id, token):
""" Check if the chatter in project sharing can be accessed
If the portal user is in the project sharing, then we do not have the access token of the task
but we can have the one of the project (if the user accessed to the project sharing views via the shared link).
So, we need to check if the chatter is for a task and if the res_id is a task
in the project shared. Then, if we had the project token and this one is the one in the project
then we return the token of the task to continue the portal chatter process.
If we do not have any token, then we need to check if the portal user is a follower of the project shared.
If it is the case, then we give the access token of the task.
"""
project_sudo = ProjectCustomerPortal._document_check_access(self, 'project.project', project_id, token)
can_access = project_sudo and res_model == 'project.task' and project_sudo.with_user(request.env.user)._check_project_sharing_access()
task = None
if can_access:
task = request.env['project.task'].sudo().search([('id', '=', res_id), ('project_id', '=', project_sudo.id)])
if not can_access or not task:
raise Forbidden()
return task[task._mail_post_token_field]
# ============================================================ #
# Note concerning the methods portal_chatter_(init/post/fetch)
# ============================================================ #
#
# When the project is shared to a portal user with the edit rights,
# he has the read/write access to the related tasks. So it could be
# possible to call directly the message_post method on a task.
#
# This change is considered as safe, as we only willingly expose
# records, for some assumed fields only, and this feature is
# optional and opt-in. (like the public employee model for example).
# It doesn't allow portal users to access other models, like
# a timesheet or an invoice.
#
# It could seem odd to use those routes, and converting the project
# access token into the task access token, as the user has actually
# access to the records.
#
# However, it has been decided that it was the less hacky way to
# achieve this, as:
#
# - We're reusing the existing routes, that convert all the data
# into valid arguments for the methods we use (message_post, ...).
# That way, we don't have to reinvent the wheel, duplicating code
# from mail/portal that surely will lead too desynchronization
# and inconsistencies over the time.
#
# - We don't define new routes, to do the exact same things than portal,
# considering that the portal user can use message_post for example
# because he has access to the record.
# Let's suppose that we remove this in a future development, those
# new routes won't be valid anymore.
#
# - We could have reused the mail widgets, as we already reuse the
# form/list/kanban views, etc. However, we only want to display
# the messages and allow to post. We don't need the next activities
# the followers system, etc. This required to override most of the
# mail.thread basic methods, without being sure that this would
# work with other installed applications or customizations
@route()
def portal_chatter_init(self, res_model, res_id, domain=False, limit=False, **kwargs):
project_sharing_id = kwargs.get('project_sharing_id')
if project_sharing_id:
# if there is a token in `kwargs` then it should be the access_token of the project shared
token = self._check_project_access_and_get_token(project_sharing_id, res_model, res_id, kwargs.get('token'))
if token:
del kwargs['project_sharing_id']
kwargs['token'] = token
return super().portal_chatter_init(res_model, res_id, domain=domain, limit=limit, **kwargs)
@route()
def portal_chatter_post(self, res_model, res_id, message, attachment_ids=None, attachment_tokens=None, **kw):
project_sharing_id = kw.get('project_sharing_id')
if project_sharing_id:
token = self._check_project_access_and_get_token(project_sharing_id, res_model, res_id, kw.get('token'))
if token:
del kw['project_sharing_id']
kw['token'] = token
return super().portal_chatter_post(res_model, res_id, message, attachment_ids=attachment_ids, attachment_tokens=attachment_tokens, **kw)
@route()
def portal_message_fetch(self, res_model, res_id, domain=False, limit=10, offset=0, **kw):
project_sharing_id = kw.get('project_sharing_id')
if project_sharing_id:
token = self._check_project_access_and_get_token(project_sharing_id, res_model, res_id, kw.get('token'))
if token is not None:
kw['token'] = token # Update token (either string which contains token value or False)
return super().portal_message_fetch(res_model, res_id, domain=domain, limit=limit, offset=offset, **kw)

43
data/digest_data.xml Normal file
View File

@ -0,0 +1,43 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<data noupdate="1">
<record id="digest.digest_digest_default" model="digest.digest">
<field name="kpi_project_task_opened">True</field>
</record>
</data>
<data>
<record id="digest_tip_project_0" model="digest.tip">
<field name="name">Tip: Use task states to keep track of your tasks' progression</field>
<field name="sequence">1200</field>
<field name="group_id" ref="project.group_project_user"/>
<field name="tip_description" type="html">
<div>
<p class="tip_title">Tip: Use task states to keep track of your tasks' progression</p>
<p class="tip_content">
Quickly check the status of tasks for approvals or change requests and identify those on hold until dependencies are resolved with the hourglass icon.
</p>
<img src="https://download.odoocdn.com/digests/project/static/src/img/task-state-img.png" width="720" class="illustration_border" />
</div>
</field>
</record>
<record id="digest_tip_project_1" model="digest.tip">
<field name="name">Tip: Create tasks from incoming emails</field>
<field name="sequence">1300</field>
<field name="group_id" ref="project.group_project_user"/>
<field name="tip_description" type="html">
<div>
<t t-set="project_record" t-value="object.env['project.project'].search([('alias_name', '!=', False), ('alias_domain_id', '!=', False)], limit=1, order='sequence asc')"/>
<p class="tip_title">Tip: Create tasks from incoming emails</p>
<t t-if="project_record.alias_email">
<p class="tip_content">Emails sent to <a t-attf-href="mailto:{{project_record.alias_email}}" target="_blank" style="color: #875a7b; text-decoration: none;"><t t-out="project_record.alias_email" /></a> will generate tasks in your <t t-out="project_record.name"></t> project.</p>
</t>
<t t-else="">
<p class="tip_content">Create tasks by sending an email to the email address of your project.</p>
</t>
</div>
</field>
</record>
</data>
</odoo>

11
data/ir_cron_data.xml Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="ir_cron_rating_project" model="ir.cron">
<field name="name">Project: Send rating</field>
<field name="model_id" ref="project.model_project_project"/>
<field name="state">code</field>
<field name="code">model._send_rating_all()</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
</record>
</odoo>

View File

@ -0,0 +1,167 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Task-related subtypes for messaging / Chatter -->
<record id="mt_task_new" model="mail.message.subtype">
<field name="name">Task Created</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
<field name="hidden" eval="True"/>
<field name="description">Task Created</field>
</record>
<record id="mt_task_stage" model="mail.message.subtype">
<field name="name">Stage Changed</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
<field name="description">Stage changed</field>
</record>
<!-- new state subtypes-->
<record id="mt_task_in_progress" model="mail.message.subtype">
<field name="name">Task In Progress</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
<field name="sequence" eval="101"/>
<field name="description">Task In Progress</field>
</record>
<record id="mt_task_changes_requested" model="mail.message.subtype">
<field name="name">Changes Requested</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
<field name="sequence" eval="102"/>
<field name="description">Changes Requested</field>
</record>
<record id="mt_task_approved" model="mail.message.subtype">
<field name="name">Task Approved</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
<field name="sequence" eval="103"/>
<field name="description">Task approved</field>
</record>
<record id="mt_task_canceled" model="mail.message.subtype">
<field name="name">Task Canceled</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
<field name="sequence" eval="104"/>
<field name="description">Task canceled</field>
</record>
<record id="mt_task_done" model="mail.message.subtype">
<field name="name">Task Done</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
<field name="sequence" eval="105"/>
<field name="description">Task done</field>
</record>
<record id="mt_task_waiting" model="mail.message.subtype">
<field name="name">Task Waiting</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
<field name="sequence" eval="106"/>
<field name="description">Task Waiting</field>
<field name="hidden" eval="True"/>
</record>
<record id="mt_task_rating" model="mail.message.subtype">
<field name="name">Task Rating</field>
<field name="res_model">project.task</field>
<field name="sequence" eval="108"/>
<field name="default" eval="False"/>
<field name="hidden" eval="True"/>
</record>
<!-- Update-related subtypes for messaging / Chatter -->
<record id="mt_update_create" model="mail.message.subtype">
<field name="name">Update Created</field>
<field name="res_model">project.update</field>
<field name="default" eval="False"/>
<field name="description">Update Created</field>
<field name="hidden" eval="True"/>
</record>
<!-- Project-related subtypes for messaging / Chatter -->
<record id="mt_project_stage_change" model="mail.message.subtype">
<field name="name">Project Stage Changed</field>
<field name="sequence">9</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="hidden" eval="True"/>
</record>
<record id="mt_project_task_new" model="mail.message.subtype">
<field name="name">Task Created</field>
<field name="sequence">10</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_new"/>
<field name="relation_field">project_id</field>
</record>
<record id="mt_project_task_stage" model="mail.message.subtype">
<field name="name">Task Stage Changed</field>
<field name="sequence">16</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_stage"/>
<field name="relation_field">project_id</field>
</record>
<record id="mt_project_task_rating" model="mail.message.subtype">
<field name="name">Task Rating</field>
<field name="sequence">27</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_rating"/>
<field name="relation_field">project_id</field>
<field name="hidden" eval="True"/>
</record>
<record id="mt_project_update_create" model="mail.message.subtype">
<field name="name">Update Created</field>
<field name="sequence">19</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_update_create"/>
<field name="relation_field">project_id</field>
<field name="hidden" eval="True"/>
</record>
<record id="mt_project_task_in_progress" model="mail.message.subtype">
<field name="name">Task In Progress</field>
<field name="sequence">20</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_in_progress"/>
<field name="relation_field">project_id</field>
</record>
<record id="mt_project_task_changes_requested" model="mail.message.subtype">
<field name="name">Changes Requested</field>
<field name="sequence">21</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_changes_requested"/>
<field name="relation_field">project_id</field>
</record>
<record id="mt_project_task_approved" model="mail.message.subtype">
<field name="name">Task Approved</field>
<field name="sequence">22</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_approved"/>
<field name="relation_field">project_id</field>
</record>
<record id="mt_project_task_canceled" model="mail.message.subtype">
<field name="name">Task Canceled</field>
<field name="sequence">23</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_canceled"/>
<field name="relation_field">project_id</field>
</record>
<record id="mt_project_task_done" model="mail.message.subtype">
<field name="name">Task Done</field>
<field name="sequence">24</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_done"/>
<field name="relation_field">project_id</field>
</record>
<record id="mt_project_task_waiting" model="mail.message.subtype">
<field name="name">Task Waiting</field>
<field name="sequence">25</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_waiting"/>
<field name="relation_field">project_id</field>
<field name="hidden" eval="True"/>
</record>
</odoo>

126
data/mail_template_data.xml Normal file
View File

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Sample stage-related template -->
<record id="mail_template_data_project_task" model="mail.template">
<field name="name">Project: Request Acknowledgment</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="subject">Reception of {{ object.name }}</field>
<field name="use_default_to" eval="True"/>
<field name="description">Set this template on a project's stage to automate email when tasks reach stages</field>
<field name="body_html" type="html">
<div>
Dear <t t-out="object.partner_id.name or 'customer'">Brandon Freeman</t>,<br/>
Thank you for your enquiry.<br />
If you have any questions, please let us know.
<br/><br/>
Thank you,
<t t-if="user.signature">
<br />
<t t-out="user.signature or ''">--<br/>Mitchell Admin</t>
</t>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- Mail sent to request a rating for a task -->
<record id="rating_project_request_email_template" model="mail.template">
<field name="name">Project: Task Rating Request</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="active" eval="False"/>
<field name="subject">{{ object.project_id.company_id.name or user.env.company.name }}: Satisfaction Survey</field>
<field name="email_from">{{ (object._rating_get_operator().email_formatted if object._rating_get_operator() else user.email_formatted) }}</field>
<field name="partner_to" >{{ object._rating_get_partner().id }}</field>
<field name="description">Set this template on a project stage to request feedback from your customers. Enable the "customer ratings" feature on the project</field>
<field name="body_html" type="html">
<div>
<t t-set="access_token" t-value="object._rating_get_access_token()"/>
<t t-set="partner" t-value="object._rating_get_partner()"/>
<table border="0" cellpadding="0" cellspacing="0" width="590" style="width:100%; margin:0px auto;">
<tbody>
<tr><td valign="top" style="font-size: 13px;">
<t t-if="partner.name">
Hello <t t-out="partner.name or ''">Brandon Freeman</t>,<br/><br/>
</t>
<t t-else="">
Hello,<br/><br/>
</t>
Please take a moment to rate our services related to the <strong t-out="object.name or ''">Planning and budget</strong> task
<t t-if="object._rating_get_operator().name">
assigned to <strong t-out="object._rating_get_operator().name or ''">Mitchell Admin</strong>.<br/>
</t>
<t t-else="">
.<br/>
</t>
</td></tr>
<tr><td style="text-align: center;">
<table border="0" cellpadding="0" cellspacing="0" width="590" summary="o_mail_notification" style="width:100%; margin: 32px 0px 32px 0px;">
<tr><td style="font-size: 13px;">
<strong>Tell us how you feel about our services</strong><br/>
<span style="font-size: 12px; opacity: 0.5; color: #454748;">(click on one of these smileys)</span>
</td></tr>
<tr><td style="font-size: 13px;">
<table style="width:100%;text-align:center;margin-top:2rem;">
<tr>
<td>
<a t-attf-href="/rate/{{ access_token }}/5">
<img alt="Satisfied" src="/rating/static/src/img/rating_5.png" title="Satisfied"/>
</a>
</td>
<td>
<a t-attf-href="/rate/{{ access_token }}/3">
<img alt="Okay" src="/rating/static/src/img/rating_3.png" title="Okay"/>
</a>
</td>
<td>
<a t-attf-href="/rate/{{ access_token }}/1">
<img alt="Dissatisfied" src="/rating/static/src/img/rating_1.png" title="Dissatisfied"/>
</a>
</td>
</tr>
</table>
</td></tr>
</table>
</td></tr>
<tr><td valign="top" style="font-size: 13px;">
We appreciate your feedback. It helps us improve continuously.
<t t-if="object.project_id.rating_status == 'stage'">
<br/><span style="margin: 0; font-size: 12px; opacity: 0.5; color: #454748;">This satisfaction survey has been sent because your task has been moved to the <b t-out="object.stage_id.name or ''">In progress</b> stage</span>
</t>
<t t-if="object.project_id.rating_status == 'periodic'">
<br/><span style="margin: 0; font-size: 12px; opacity: 0.5; color: #454748;">This satisfaction survey is sent <b t-out="object.project_id.rating_status_period or ''">weekly</b> as long as the task is in the <b t-out="object.stage_id.name or ''">In progress</b> stage.</span>
</t>
</td></tr>
<tr><td><br/>Best regards,</td></tr>
<tr><td>
<t t-out="object.project_id.company_id.name or ''">YourCompany</t>
</td></tr>
<tr><td style="opacity: 0.5;">
<t t-out="object.project_id.company_id.phone or ''">1 650-123-4567</t>
<t t-if="object.project_id.company_id.email">
| <a t-attf-href="mailto:{{ object.project_id.company_id.email }}" style="text-decoration:none; color: #454748;" t-out="object.project_id.company_id.email or ''">info@yourcompany.com</a>
</t>
<t t-if="object.project_id.company_id.website">
| <a t-attf-href="{{ object.project_id.company_id.website }}" style="text-decoration:none; color: #454748;" t-out="object.project_id.company_id.website or ''">http://www.example.com</a>
</t>
</td></tr>
</tbody>
</table>
</div>
</field>
<field name="lang">{{ object._rating_get_partner().lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- You have been assigned email -->
<template id="project_message_user_assigned">
<div>
Dear <t t-esc="assignee_name"/>,
<br/><br/>
<span style="margin-top: 8px;">You have been assigned to the <t t-esc="model_description or 'document'"/> <t t-esc="object.display_name"/>.</span>
</div>
</template>
</data>
</odoo>

View File

@ -0,0 +1,25 @@
<odoo>
<data>
<record id="project_done_email_template" model="mail.template">
<field name="name">Project: Project Completed</field>
<field name="model_id" ref="project.model_project_project"/>
<field name="subject">Project status - {{ object.name }}</field>
<field name="email_from">{{ (object.partner_id.email_formatted if object.partner_id else user.email_formatted) }}</field>
<field name="partner_to" >{{ object.partner_id.id }}</field>
<field name="description">Set on project's stages to inform customers when a project reaches that stage</field>
<field name="body_html" type="html">
<div>
Dear <t t-out="object.partner_id.name or 'customer'">Brandon Freeman</t>,<br/>
It is my pleasure to let you know that we have successfully completed the project "<strong t-out="object.name or ''">Renovations</strong>".
<t t-if="user.signature">
<br />
<t t-out="user.signature or ''">--<br/>Mitchell Admin</t>
</t>
</div>
<br/><span style="margin: 0px 0px 0px 0px; font-size: 12px; opacity: 0.5; color: #454748;" groups="project.group_project_stages">You are receiving this email because your project has been moved to the stage <b t-out="object.stage_id.name or ''">Done</b></span>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>

25
data/project_data.xml Normal file
View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Project Stages -->
<record id="project_project_stage_0" model="project.project.stage">
<field name="sequence">10</field>
<field name="name">To Do</field>
</record>
<record id="project_project_stage_1" model="project.project.stage">
<field name="sequence">15</field>
<field name="name">In Progress</field>
</record>
<record id="project_project_stage_2" model="project.project.stage">
<field name="sequence">20</field>
<field name="name">Done</field>
<field name="fold" eval="True"/>
</record>
<record id="project_project_stage_3" model="project.project.stage">
<field name="sequence">25</field>
<field name="name">Canceled</field>
<field name="fold" eval="True"/>
</record>
</odoo>

1373
data/project_demo.xml Normal file

File diff suppressed because it is too large Load Diff

16
doc/changelog.rst Normal file
View File

@ -0,0 +1,16 @@
.. _changelog:
Changelog
=========
`trunk (saas-2)`
----------------
- Stage/state update
- ``project.task``: removed inheritance from ``base_stage`` class and removed
``state`` field. Added ``date_last_stage_update`` field holding last stage_id
modification. Updated reports.
- ``project.task.type``: removed ``state`` field.
- Removed ``project.task.reevaluate`` wizard.

22
doc/index.rst Normal file
View File

@ -0,0 +1,22 @@
=====================
Project DevDoc
=====================
Project module documentation
===================================
Documentation topics
''''''''''''''''''''
.. toctree::
:maxdepth: 1
stage_status.rst
Changelog
'''''''''
.. toctree::
:maxdepth: 1
changelog.rst

55
doc/stage_status.rst Normal file
View File

@ -0,0 +1,55 @@
.. _stage_status:
Stage and Status
================
.. versionchanged:: 8.0 saas-2 state/stage cleaning
Stage
+++++
This revision removed the concept of state on project.task objects. The ``state``
field has been totally removed and replaced by stages, using ``stage_id``. The
following models are impacted:
- ``project.task`` now use only stages. However a convention still exists about
'New' stage. A task is consdered as ``new`` when it has the following
properties:
- ``stage_id and stage_id.sequence = 1``
- ``project.task.type`` do not have any ``state`` field anymore.
- ``project.task.report`` do not have any ``state`` field anymore.
By default a newly created task is in a new stage. It means that it will
fetch the stage having ``sequence = 1``. Stage mangement is done using the
kanban view or the clikable statusbar. It is not done using buttons anymore.
Stage analysis
++++++++++++++
Stage analysis can be performed using the newly introduced ``date_last_stage_update``
datetime field. This field is updated everytime ``stage_id`` is updated.
``project.task.report`` model also uses the ``date_last_stage_update`` field.
This allows to group and analyse the time spend in the various stages.
Open / Assignment date
+++++++++++++++++++++++
The ``date_open`` field meaning has been updated. It is now set when the ``user_id``
(responsible) is set. It is therefore the assignment date.
Subtypes
++++++++
The following subtypes are triggered on ``project.task``:
- ``mt_task_new``: new tasks. Condition: ``obj.stage_id and obj.stage_id.sequence == 1``
- ``mt_task_stage``: stage changed. Condition: ``obj.stage_id and obj.stage_id.sequence != 1``
- ``mt_task_assigned``: user assigned. condition: ``obj.user_id and obj.user_id.id``
- ``mt_task_blocked``: kanban state blocked. Condition: ``obj.kanban_state == 'blocked'``
Those subtypes are also available on the ``project.project`` model and are used
for the auto subscription.

5138
i18n/af.po Normal file

File diff suppressed because it is too large Load Diff

5993
i18n/ar.po Normal file

File diff suppressed because it is too large Load Diff

5147
i18n/az.po Normal file

File diff suppressed because it is too large Load Diff

5696
i18n/bg.po Normal file

File diff suppressed because it is too large Load Diff

5139
i18n/bs.po Normal file

File diff suppressed because it is too large Load Diff

5885
i18n/ca.po Normal file

File diff suppressed because it is too large Load Diff

5759
i18n/cs.po Normal file

File diff suppressed because it is too large Load Diff

5744
i18n/da.po Normal file

File diff suppressed because it is too large Load Diff

6098
i18n/de.po Normal file

File diff suppressed because it is too large Load Diff

5151
i18n/el.po Normal file

File diff suppressed because it is too large Load Diff

5137
i18n/en_GB.po Normal file

File diff suppressed because it is too large Load Diff

6075
i18n/es.po Normal file

File diff suppressed because it is too large Load Diff

6073
i18n/es_419.po Normal file

File diff suppressed because it is too large Load Diff

5136
i18n/es_BO.po Normal file

File diff suppressed because it is too large Load Diff

5136
i18n/es_CL.po Normal file

File diff suppressed because it is too large Load Diff

5145
i18n/es_CO.po Normal file

File diff suppressed because it is too large Load Diff

5136
i18n/es_CR.po Normal file

File diff suppressed because it is too large Load Diff

5140
i18n/es_DO.po Normal file

File diff suppressed because it is too large Load Diff

5146
i18n/es_EC.po Normal file

File diff suppressed because it is too large Load Diff

5137
i18n/es_PE.po Normal file

File diff suppressed because it is too large Load Diff

5136
i18n/es_PY.po Normal file

File diff suppressed because it is too large Load Diff

5140
i18n/es_VE.po Normal file

File diff suppressed because it is too large Load Diff

6047
i18n/et.po Normal file

File diff suppressed because it is too large Load Diff

5136
i18n/eu.po Normal file

File diff suppressed because it is too large Load Diff

5733
i18n/fa.po Normal file

File diff suppressed because it is too large Load Diff

6065
i18n/fi.po Normal file

File diff suppressed because it is too large Load Diff

6082
i18n/fr.po Normal file

File diff suppressed because it is too large Load Diff

5136
i18n/gl.po Normal file

File diff suppressed because it is too large Load Diff

5142
i18n/gu.po Normal file

File diff suppressed because it is too large Load Diff

5730
i18n/he.po Normal file

File diff suppressed because it is too large Load Diff

5170
i18n/hr.po Normal file

File diff suppressed because it is too large Load Diff

5723
i18n/hu.po Normal file

File diff suppressed because it is too large Load Diff

6044
i18n/id.po Normal file

File diff suppressed because it is too large Load Diff

5142
i18n/is.po Normal file

File diff suppressed because it is too large Load Diff

6062
i18n/it.po Normal file

File diff suppressed because it is too large Load Diff

5876
i18n/ja.po Normal file

File diff suppressed because it is too large Load Diff

5136
i18n/ka.po Normal file

File diff suppressed because it is too large Load Diff

5136
i18n/kab.po Normal file

File diff suppressed because it is too large Load Diff

5141
i18n/km.po Normal file

File diff suppressed because it is too large Load Diff

5898
i18n/ko.po Normal file

File diff suppressed because it is too large Load Diff

5138
i18n/lb.po Normal file

File diff suppressed because it is too large Load Diff

5724
i18n/lt.po Normal file

File diff suppressed because it is too large Load Diff

5684
i18n/lv.po Normal file

File diff suppressed because it is too large Load Diff

5141
i18n/mk.po Normal file

File diff suppressed because it is too large Load Diff

5165
i18n/mn.po Normal file

File diff suppressed because it is too large Load Diff

5148
i18n/nb.po Normal file

File diff suppressed because it is too large Load Diff

6059
i18n/nl.po Normal file

File diff suppressed because it is too large Load Diff

5887
i18n/pl.po Normal file

File diff suppressed because it is too large Load Diff

5647
i18n/project.pot Normal file

File diff suppressed because it is too large Load Diff

5716
i18n/pt.po Normal file

File diff suppressed because it is too large Load Diff

6064
i18n/pt_BR.po Normal file

File diff suppressed because it is too large Load Diff

5191
i18n/ro.po Normal file

File diff suppressed because it is too large Load Diff

6081
i18n/ru.po Normal file

File diff suppressed because it is too large Load Diff

5717
i18n/sk.po Normal file

File diff suppressed because it is too large Load Diff

5695
i18n/sl.po Normal file

File diff suppressed because it is too large Load Diff

5868
i18n/sr.po Normal file

File diff suppressed because it is too large Load Diff

5140
i18n/sr@latin.po Normal file

File diff suppressed because it is too large Load Diff

5805
i18n/sv.po Normal file

File diff suppressed because it is too large Load Diff

5994
i18n/th.po Normal file

File diff suppressed because it is too large Load Diff

5864
i18n/tr.po Normal file

File diff suppressed because it is too large Load Diff

5987
i18n/uk.po Normal file

File diff suppressed because it is too large Load Diff

6001
i18n/vi.po Normal file

File diff suppressed because it is too large Load Diff

5857
i18n/zh_CN.po Normal file

File diff suppressed because it is too large Load Diff

5851
i18n/zh_TW.po Normal file

File diff suppressed because it is too large Load Diff

20
models/__init__.py Normal file
View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_analytic_account
from . import mail_message
from . import project_project_stage
from . import project_task_recurrence
# `project_task_stage_personal` has to be loaded before `project_project` and `project_milestone`
from . import project_task_stage_personal
from . import project_milestone
from . import project_project
from . import project_task
from . import project_task_type
from . import project_tags
from . import project_collaborator
from . import project_update
from . import res_config_settings
from . import res_partner
from . import digest_digest
from . import ir_ui_menu

View File

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountAnalyticAccount(models.Model):
_inherit = 'account.analytic.account'
_description = 'Analytic Account'
project_ids = fields.One2many('project.project', 'analytic_account_id', string='Projects')
project_count = fields.Integer("Project Count", compute='_compute_project_count')
@api.depends('project_ids')
def _compute_project_count(self):
project_data = self.env['project.project']._read_group([('analytic_account_id', 'in', self.ids)], ['analytic_account_id'], ['__count'])
mapping = {analytic_account.id: count for analytic_account, count in project_data}
for account in self:
account.project_count = mapping.get(account.id, 0)
@api.ondelete(at_uninstall=False)
def _unlink_except_existing_tasks(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.'))
def action_view_projects(self):
kanban_view_id = self.env.ref('project.view_project_kanban').id
result = {
"type": "ir.actions.act_window",
"res_model": "project.project",
"views": [[kanban_view_id, "kanban"], [False, "form"]],
"domain": [['analytic_account_id', '=', self.id]],
"context": {"create": False},
"name": _("Projects"),
}
if len(self.project_ids) == 1:
result['views'] = [(False, "form")]
result['res_id'] = self.project_ids.id
return result

27
models/digest_digest.py Normal file
View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import AccessError
class Digest(models.Model):
_inherit = 'digest.digest'
kpi_project_task_opened = fields.Boolean('Open Tasks')
kpi_project_task_opened_value = fields.Integer(compute='_compute_project_task_opened_value')
def _compute_project_task_opened_value(self):
if not self.env.user.has_group('project.group_project_user'):
raise AccessError(_("Do not have access, skip this data for user's digest email"))
self._calculate_company_based_kpi(
'project.task',
'kpi_project_task_opened_value',
additional_domain=[('stage_id.fold', '=', False), ('project_id', '!=', False)],
)
def _compute_kpis_actions(self, company, user):
res = super(Digest, self)._compute_kpis_actions(company, user)
res['kpi_project_task_opened'] = 'project.open_view_project_all&menu_id=%s' % self.env.ref('project.menu_main_pm').id
return res

17
models/ir_ui_menu.py Normal file
View File

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

18
models/mail_message.py Normal file
View File

@ -0,0 +1,18 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.tools.sql import create_index
class MailMessage(models.Model):
_inherit = 'mail.message'
def init(self):
super().init()
create_index(
self._cr,
'mail_message_date_res_id_id_for_burndown_chart',
self._table,
['date', 'res_id', 'id'],
where="model='project.task' AND message_type='notification'"
)

View File

@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ProjectCollaborator(models.Model):
_name = 'project.collaborator'
_description = 'Collaborators in project shared'
project_id = fields.Many2one('project.project', 'Project Shared', domain=[('privacy_visibility', '=', 'portal')], required=True, readonly=True)
partner_id = fields.Many2one('res.partner', 'Collaborator', required=True, readonly=True)
partner_email = fields.Char(related='partner_id.email')
_sql_constraints = [
('unique_collaborator', 'UNIQUE(project_id, partner_id)', 'A collaborator cannot be selected more than once in the project sharing access. Please remove duplicate(s) and try again.'),
]
@api.depends('project_id', 'partner_id')
def _compute_display_name(self):
for collaborator in self:
collaborator.display_name = f'{collaborator.project_id.display_name} - {collaborator.partner_id.display_name}'
@api.model_create_multi
def create(self, vals_list):
collaborator = self.env['project.collaborator'].search([], limit=1)
project_collaborators = super().create(vals_list)
if not collaborator:
self._toggle_project_sharing_portal_rules(True)
return project_collaborators
def unlink(self):
res = super().unlink()
# Check if it remains at least a collaborator in all shared projects.
collaborator = self.env['project.collaborator'].search([], limit=1)
if not collaborator: # then disable the project sharing feature
self._toggle_project_sharing_portal_rules(False)
return res
@api.model
def _toggle_project_sharing_portal_rules(self, active):
""" Enable/disable project sharing feature
When the first collaborator is added in the model then we need to enable the feature.
In the inverse case, if no collaborator is stored in the model then we disable the feature.
To enable/disable the feature, we just need to enable/disable the ir.model.access and ir.rule
added to portal user that we do not want to give when we know the project sharing is unused.
:param active: contains boolean value, True to enable the project sharing feature, otherwise we disable the feature.
"""
access_project_sharing_portal = self.env.ref('project.access_project_sharing_task_portal').sudo()
if access_project_sharing_portal.active != active:
access_project_sharing_portal.write({'active': active})
task_portal_ir_rule = self.env.ref('project.project_task_rule_portal_project_sharing').sudo()
if task_portal_ir_rule.active != active:
task_portal_ir_rule.write({'active': active})

121
models/project_milestone.py Normal file
View File

@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import api, fields, models
from .project_task import CLOSED_STATES
class ProjectMilestone(models.Model):
_name = 'project.milestone'
_description = "Project Milestone"
_inherit = ['mail.thread']
_order = 'deadline, is_reached desc, name'
def _get_default_project_id(self):
return self.env.context.get('default_project_id') or self.env.context.get('active_id')
name = fields.Char(required=True)
project_id = fields.Many2one('project.project', required=True, default=_get_default_project_id, ondelete='cascade')
deadline = fields.Date(tracking=True, copy=False)
is_reached = fields.Boolean(string="Reached", default=False, copy=False)
reached_date = fields.Date(compute='_compute_reached_date', store=True)
task_ids = fields.One2many('project.task', 'milestone_id', 'Tasks')
# computed non-stored fields
is_deadline_exceeded = fields.Boolean(compute="_compute_is_deadline_exceeded")
is_deadline_future = fields.Boolean(compute="_compute_is_deadline_future")
task_count = fields.Integer('# of Tasks', compute='_compute_task_count', groups='project.group_project_milestone')
done_task_count = fields.Integer('# of Done Tasks', compute='_compute_task_count', groups='project.group_project_milestone')
can_be_marked_as_done = fields.Boolean(compute='_compute_can_be_marked_as_done', groups='project.group_project_milestone')
@api.depends('is_reached')
def _compute_reached_date(self):
for ms in self:
ms.reached_date = ms.is_reached and fields.Date.context_today(self)
@api.depends('is_reached', 'deadline')
def _compute_is_deadline_exceeded(self):
today = fields.Date.context_today(self)
for ms in self:
ms.is_deadline_exceeded = not ms.is_reached and ms.deadline and ms.deadline < today
@api.depends('deadline')
def _compute_is_deadline_future(self):
for ms in self:
ms.is_deadline_future = ms.deadline and ms.deadline > fields.Date.context_today(self)
@api.depends('task_ids.milestone_id')
def _compute_task_count(self):
all_and_done_task_count_per_milestone = {
milestone.id: (count, sum(state in CLOSED_STATES for state in state_list))
for milestone, count, state_list in self.env['project.task']._read_group(
[('milestone_id', 'in', self.ids), ('allow_milestones', '=', True)],
['milestone_id'], ['__count', 'state:array_agg'],
)
}
for milestone in self:
milestone.task_count, milestone.done_task_count = all_and_done_task_count_per_milestone.get(milestone.id, (0, 0))
def _compute_can_be_marked_as_done(self):
if not any(self._ids):
for milestone in self:
milestone.can_be_marked_as_done = not milestone.is_reached and all(milestone.task_ids.mapped(lambda t: t.state in CLOSED_STATES))
return
unreached_milestones = self.filtered(lambda milestone: not milestone.is_reached)
(self - unreached_milestones).can_be_marked_as_done = False
task_read_group = self.env['project.task']._read_group(
[('milestone_id', 'in', unreached_milestones.ids)],
['milestone_id', 'state'],
['__count'],
)
task_count_per_milestones = defaultdict(lambda: (0, 0))
for milestone, state, count in task_read_group:
opened_task_count, closed_task_count = task_count_per_milestones[milestone.id]
if state in CLOSED_STATES:
closed_task_count += count
else:
opened_task_count += count
task_count_per_milestones[milestone.id] = opened_task_count, closed_task_count
for milestone in unreached_milestones:
opened_task_count, closed_task_count = task_count_per_milestones[milestone.id]
milestone.can_be_marked_as_done = closed_task_count > 0 and not opened_task_count
def toggle_is_reached(self, is_reached):
self.ensure_one()
self.update({'is_reached': is_reached})
return self._get_data()
def action_view_tasks(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id('project.action_view_task_from_milestone')
action['context'] = {'default_project_id': self.project_id.id, 'default_milestone_id': self.id}
if self.task_count == 1:
action['view_mode'] = 'form'
action['res_id'] = self.task_ids.id
if 'views' in action:
action['views'] = [(view_id, view_type) for view_id, view_type in action['views'] if view_type == 'form']
return action
@api.model
def _get_fields_to_export(self):
return ['id', 'name', 'deadline', 'is_reached', 'reached_date', 'is_deadline_exceeded', 'is_deadline_future', 'can_be_marked_as_done']
def _get_data(self):
self.ensure_one()
return {field: self[field] for field in self._get_fields_to_export()}
def _get_data_list(self):
return [ms._get_data() for ms in self]
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
if default is None:
default = {}
milestone_copy = super(ProjectMilestone, self).copy(default)
if self.project_id.allow_milestones:
milestone_mapping = self.env.context.get('milestone_mapping', {})
milestone_mapping[self.id] = milestone_copy.id
return milestone_copy

987
models/project_project.py Normal file
View File

@ -0,0 +1,987 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import json
from collections import defaultdict
from datetime import timedelta
from odoo import api, Command, fields, models, _, _lt
from odoo.addons.rating.models import rating_data
from odoo.exceptions import UserError
from odoo.tools import get_lang, SQL
from .project_update import STATUS_COLOR
from .project_task import CLOSED_STATES
class Project(models.Model):
_name = "project.project"
_description = "Project"
_inherit = ['portal.mixin', 'mail.alias.mixin', 'rating.parent.mixin', 'mail.thread', 'mail.activity.mixin']
_order = "sequence, name, id"
_rating_satisfaction_days = 30 # takes 30 days by default
_systray_view = 'activity'
def _compute_attached_docs_count(self):
docs_count = {}
if self.ids:
self.env.cr.execute(
"""
WITH docs AS (
SELECT res_id as id, count(*) as count
FROM ir_attachment
WHERE res_model = 'project.project'
AND res_id IN %(project_ids)s
GROUP BY res_id
UNION ALL
SELECT t.project_id as id, count(*) as count
FROM ir_attachment a
JOIN project_task t ON a.res_model = 'project.task' AND a.res_id = t.id
WHERE t.project_id IN %(project_ids)s
GROUP BY t.project_id
)
SELECT id, sum(count)
FROM docs
GROUP BY id
""",
{"project_ids": tuple(self.ids)}
)
docs_count = dict(self.env.cr.fetchall())
for project in self:
project.doc_count = docs_count.get(project.id, 0)
def _compute_task_count(self):
project_and_state_counts = self.env['project.task'].with_context(
active_test=any(project.active for project in self)
)._read_group(
[('project_id', 'in', self.ids)],
['project_id', 'state'],
['__count'],
)
task_counts_per_project_id = defaultdict(lambda: {
'open_task_count': 0,
'closed_task_count': 0,
})
for project, state, count in project_and_state_counts:
task_counts_per_project_id[project.id]['closed_task_count' if state in CLOSED_STATES else 'open_task_count'] += count
for project in self:
open_task_count, closed_task_count = task_counts_per_project_id[project.id].values()
project.open_task_count = open_task_count
project.closed_task_count = closed_task_count
project.task_count = open_task_count + closed_task_count
def _default_stage_id(self):
# Since project stages are order by sequence first, this should fetch the one with the lowest sequence number.
return self.env['project.project.stage'].search([], limit=1)
@api.model
def _search_is_favorite(self, operator, value):
if operator not in ['=', '!='] or not isinstance(value, bool):
raise NotImplementedError(_('Operation not supported'))
return [('favorite_user_ids', 'in' if (operator == '=') == value else 'not in', self.env.uid)]
def _compute_is_favorite(self):
for project in self:
project.is_favorite = self.env.user in project.favorite_user_ids
def _inverse_is_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)]})
def _get_default_favorite_user_ids(self):
return [(6, 0, [self.env.uid])]
@api.model
def _read_group_stage_ids(self, stages, domain, order):
return self.env['project.project.stage'].search([], order=order)
name = fields.Char("Name", index='trigram', required=True, tracking=True, translate=True, default_export_compatible=True)
description = fields.Html(help="Description to provide more information and context about this project")
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)
partner_id = fields.Many2one('res.partner', string='Customer', auto_join=True, tracking=True, domain="['|', ('company_id', '=?', company_id), ('company_id', '=', False)]")
company_id = fields.Many2one('res.company', string='Company', compute="_compute_company_id", inverse="_inverse_company_id", store=True, readonly=False)
currency_id = fields.Many2one('res.currency', compute="_compute_currency_id", string="Currency", readonly=True)
analytic_account_id = fields.Many2one('account.analytic.account', string="Analytic Account", copy=False, ondelete='set null',
domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id)]", check_company=True,
help="Analytic account to which this project, its tasks and its timesheets are linked. \n"
"Track the costs and revenues of your project by setting this analytic account on your related documents (e.g. sales orders, invoices, purchase orders, vendor bills, expenses etc.).\n"
"This analytic account can be changed on each task individually if necessary.\n"
"An analytic account is required in order to use timesheets.")
analytic_account_balance = fields.Monetary(related="analytic_account_id.balance")
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', inverse='_inverse_is_favorite', search='_search_is_favorite',
compute_sudo=True, string='Show Project on Dashboard')
label_tasks = fields.Char(string='Use Tasks as', default='Tasks', translate=True,
help="Name used to refer to the tasks of your project e.g. tasks, tickets, sprints, etc...")
tasks = fields.One2many('project.task', 'project_id', string="Task Activities")
resource_calendar_id = fields.Many2one(
'resource.calendar', string='Working Time', compute='_compute_resource_calendar_id')
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="Task Count")
open_task_count = fields.Integer(compute='_compute_task_count', string="Open Task Count")
# [XBO] TODO: remove me in master
closed_task_count = fields.Integer(compute='_compute_task_count', string="Closed Task Count")
task_ids = fields.One2many('project.task', 'project_id', string='Tasks',
domain=lambda self: [('state', 'in', self.env['project.task'].OPEN_STATES)])
color = fields.Integer(string='Color Index')
user_id = fields.Many2one('res.users', string='Project Manager', default=lambda self: self.env.user, tracking=True)
alias_id = fields.Many2one(help="Internal email associated with this project. Incoming emails are automatically synchronized "
"with Tasks (or optionally Issues if the Issue Tracker module is installed).")
privacy_visibility = fields.Selection([
('followers', 'Invited internal users (private)'),
('employees', 'All internal users'),
('portal', 'Invited portal users and all internal users (public)'),
],
string='Visibility', required=True,
default='portal',
help="People to whom this project and its tasks will be visible.\n\n"
"- Invited internal users: when following a project, internal users will get access to all of its tasks without distinction. "
"Otherwise, they will only get access to the specific tasks they are following.\n "
"A user with the project > administrator access right level can still access this project and its tasks, even if they are not explicitly part of the followers.\n\n"
"- All internal users: all internal users can access the project and all of its tasks without distinction.\n\n"
"- Invited portal users and all internal users: all internal users can access the project and all of its tasks without distinction.\n"
"When following a project, portal users will get access to all of its tasks without distinction. Otherwise, they will only get access to the specific tasks they are following.\n\n"
"When a project is shared in read-only, the portal user is redirected to their portal. They can view the tasks, but not edit them.\n"
"When a project is shared in edit, the portal user is redirected to the kanban and list views of the tasks. They can modify a selected number of fields on the tasks.\n\n"
"In any case, an internal user with no project access rights can still access a task, "
"provided that they are given the corresponding URL (and that they are part of the followers if the project is private).")
privacy_visibility_warning = fields.Char('Privacy Visibility Warning', compute='_compute_privacy_visibility_warning')
access_instruction_message = fields.Char('Access Instruction Message', compute='_compute_access_instruction_message')
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, tracking=True,
help="Date on which this project ends. The timeframe defined on the project is taken into account when viewing its planning.")
allow_task_dependencies = fields.Boolean('Task Dependencies', default=lambda self: self.env.user.has_group('project.group_project_task_dependencies'))
allow_milestones = fields.Boolean('Milestones', default=lambda self: self.env.user.has_group('project.group_project_milestone'))
tag_ids = fields.Many2many('project.tags', relation='project_project_project_tags_rel', string='Tags')
task_properties_definition = fields.PropertiesDefinition('Task Properties')
# Project Sharing fields
collaborator_ids = fields.One2many('project.collaborator', 'project_id', string='Collaborators', copy=False)
collaborator_count = fields.Integer('# Collaborators', compute='_compute_collaborator_count', compute_sudo=True)
# rating fields
rating_request_deadline = fields.Datetime(compute='_compute_rating_request_deadline', store=True)
rating_active = fields.Boolean('Customer Ratings', default=lambda self: self.env.user.has_group('project.group_project_rating'))
allow_rating = fields.Boolean('Allow Customer Ratings', compute="_compute_allow_rating", default=lambda self: self.env.user.has_group('project.group_project_rating'))
rating_status = fields.Selection(
[('stage', 'when reaching a given stage'),
('periodic', 'on a periodic basis')
], 'Customer Ratings Status', default="stage", required=True,
help="Collect feedback from your customers by sending them a rating request when a task enters a certain stage. To do so, define a rating email template on the corresponding stages.\n"
"Rating when changing stage: an email will be automatically sent when the task reaches the stage on which the rating email template is set.\n"
"Periodic rating: an email will be automatically sent at regular intervals as long as the task remains in the stage in which the rating email template is set.")
rating_status_period = fields.Selection([
('daily', 'Daily'),
('weekly', 'Weekly'),
('bimonthly', 'Twice a Month'),
('monthly', 'Once a Month'),
('quarterly', 'Quarterly'),
('yearly', 'Yearly')], 'Rating Frequency', required=True, default='monthly')
# Not `required` since this is an option to enable in project settings.
stage_id = fields.Many2one('project.project.stage', string='Stage', ondelete='restrict', groups="project.group_project_stages",
tracking=True, index=True, copy=False, default=_default_stage_id, group_expand='_read_group_stage_ids')
update_ids = fields.One2many('project.update', 'project_id')
last_update_id = fields.Many2one('project.update', string='Last Update', copy=False)
last_update_status = fields.Selection(selection=[
('on_track', 'On Track'),
('at_risk', 'At Risk'),
('off_track', 'Off Track'),
('on_hold', 'On Hold'),
('to_define', 'Set Status'),
('done', 'Done'),
], default='to_define', compute='_compute_last_update_status', store=True, readonly=False, required=True)
last_update_color = fields.Integer(compute='_compute_last_update_color')
milestone_ids = fields.One2many('project.milestone', 'project_id')
milestone_count = fields.Integer(compute='_compute_milestone_count', groups='project.group_project_milestone')
milestone_count_reached = fields.Integer(compute='_compute_milestone_reached_count', groups='project.group_project_milestone')
is_milestone_exceeded = fields.Boolean(compute="_compute_is_milestone_exceeded", search='_search_is_milestone_exceeded')
_sql_constraints = [
('project_date_greater', 'check(date >= date_start)', "The project's start date must be before its end date.")
]
@api.onchange('company_id')
def _onchange_company_id(self):
if (self.env.user.has_group('project.group_project_stages') and self.stage_id.company_id
and self.stage_id.company_id != self.company_id):
self.stage_id = self.env['project.project.stage'].search(
[('company_id', 'in', [self.company_id.id, False])],
order=f"sequence asc, {self.env['project.project.stage']._order}",
limit=1,
).id
def _compute_access_url(self):
super(Project, self)._compute_access_url()
for project in self:
project.access_url = f'/my/projects/{project.id}'
def _compute_access_warning(self):
super(Project, self)._compute_access_warning()
for project in self.filtered(lambda x: x.privacy_visibility != 'portal'):
project.access_warning = _(
"The project cannot be shared with the recipient(s) because the privacy of the project is too restricted. Set the privacy to 'Visible by following customers' in order to make it accessible by the recipient(s).")
@api.depends_context('uid')
def _compute_allow_rating(self):
self.allow_rating = self.env.user.has_group('project.group_project_rating')
@api.depends('analytic_account_id.company_id')
def _compute_company_id(self):
for project in self:
# if a new restriction is put on the account, the restriction on the project is updated.
if project.analytic_account_id.company_id:
project.company_id = project.analytic_account_id.company_id
@api.depends_context('company')
@api.depends('company_id', 'company_id.resource_calendar_id')
def _compute_resource_calendar_id(self):
for project in self:
project.resource_calendar_id = project.company_id.resource_calendar_id or self.env.company.resource_calendar_id
def _inverse_company_id(self):
"""
Ensures that the new company of the project is valid for the account. If not set back the previous company, and raise a user Error.
Ensures that the new company of the project is valid for the partner
"""
for project in self:
account = project.analytic_account_id
if project.partner_id and project.partner_id.company_id and project.company_id and project.company_id != project.partner_id.company_id:
raise UserError(_('The project and the associated partner must be linked to the same company.'))
if not account or not account.company_id:
continue
# if the account of the project has more than one company linked to it, or if it has aal, do not update the account, and set back the old company on the project.
if (account.project_count > 1 or account.line_ids) and project.company_id != account.company_id:
raise UserError(
_("The project's company cannot be changed if its analytic account has analytic lines or if more than one project is linked to it."))
account.company_id = project.company_id
@api.depends('rating_status', 'rating_status_period')
def _compute_rating_request_deadline(self):
periods = {'daily': 1, 'weekly': 7, 'bimonthly': 15, 'monthly': 30, 'quarterly': 90, 'yearly': 365}
for project in self:
project.rating_request_deadline = fields.datetime.now() + timedelta(days=periods.get(project.rating_status_period, 0))
@api.depends('last_update_id.status')
def _compute_last_update_status(self):
for project in self:
project.last_update_status = project.last_update_id.status or 'to_define'
@api.depends('last_update_status')
def _compute_last_update_color(self):
for project in self:
project.last_update_color = STATUS_COLOR[project.last_update_status]
@api.depends('milestone_ids')
def _compute_milestone_count(self):
read_group = self.env['project.milestone']._read_group([('project_id', 'in', self.ids)], ['project_id'], ['__count'])
mapped_count = {project.id: count for project, count in read_group}
for project in self:
project.milestone_count = mapped_count.get(project.id, 0)
@api.depends('milestone_ids.is_reached')
def _compute_milestone_reached_count(self):
read_group = self.env['project.milestone']._read_group(
[('project_id', 'in', self.ids), ('is_reached', '=', True)],
['project_id'],
['__count'],
)
mapped_count = {project.id: count for project, count in read_group}
for project in self:
project.milestone_count_reached = mapped_count.get(project.id, 0)
@api.depends('milestone_ids', 'milestone_ids.is_reached', 'milestone_ids.deadline', 'allow_milestones')
def _compute_is_milestone_exceeded(self):
today = fields.Date.context_today(self)
read_group = self.env['project.milestone']._read_group([
('project_id', 'in', self.filtered('allow_milestones').ids),
('is_reached', '=', False),
('deadline', '<=', today)], ['project_id'], ['__count'])
mapped_count = {project.id: count for project, count in read_group}
for project in self:
project.is_milestone_exceeded = bool(mapped_count.get(project.id, 0))
@api.depends_context('company')
@api.depends('company_id')
def _compute_currency_id(self):
default_currency_id = self.env.company.currency_id
for project in self:
project.currency_id = project.company_id.currency_id or default_currency_id
@api.model
def _search_is_milestone_exceeded(self, operator, value):
if not isinstance(value, bool):
raise ValueError(_('Invalid value: %s', value))
if operator not in ['=', '!=']:
raise ValueError(_('Invalid operator: %s', operator))
query = """
SELECT P.id
FROM project_project P
LEFT JOIN project_milestone M ON P.id = M.project_id
WHERE M.is_reached IS false
AND P.allow_milestones IS true
AND M.deadline <= CAST(now() AS date)
"""
if (operator == '=' and value is True) or (operator == '!=' and value is False):
operator_new = 'inselect'
else:
operator_new = 'not inselect'
return [('id', operator_new, (query, ()))]
@api.depends('collaborator_ids', 'privacy_visibility')
def _compute_collaborator_count(self):
project_sharings = self.filtered(lambda project: project.privacy_visibility == 'portal')
collaborator_read_group = self.env['project.collaborator']._read_group(
[('project_id', 'in', project_sharings.ids)],
['project_id'],
['__count'],
)
collaborator_count_by_project = {project.id: count for project, count in collaborator_read_group}
for project in self:
project.collaborator_count = collaborator_count_by_project.get(project.id, 0)
@api.depends('privacy_visibility')
def _compute_privacy_visibility_warning(self):
for project in self:
if not project.ids:
project.privacy_visibility_warning = ''
elif project.privacy_visibility == 'portal' and project._origin.privacy_visibility != 'portal':
project.privacy_visibility_warning = _('Customers will be added to the followers of their project and tasks.')
elif project.privacy_visibility != 'portal' and project._origin.privacy_visibility == 'portal':
project.privacy_visibility_warning = _('Portal users will be removed from the followers of the project and its tasks.')
else:
project.privacy_visibility_warning = ''
@api.depends('privacy_visibility')
def _compute_access_instruction_message(self):
for project in self:
if project.privacy_visibility == 'portal':
project.access_instruction_message = _('Grant portal users access to your project or tasks by adding them as followers. Customers automatically get access to their tasks in their portal.')
elif project.privacy_visibility == 'followers':
project.access_instruction_message = _('Grant employees access to your project or tasks by adding them as followers. Employees automatically get access to the tasks they are assigned to.')
else:
project.access_instruction_message = ''
# TODO: Remove in master
@api.onchange('date_start', 'date')
def _onchange_planned_date(self):
return
@api.model
def _map_tasks_default_valeus(self, task, project):
""" get the default value for the copied task on project duplication """
return {
'stage_id': task.stage_id.id,
'name': task.name,
'state': task.state,
'company_id': project.company_id.id,
'project_id': project.id,
}
def map_tasks(self, new_project_id):
""" copy and map tasks from old to new project """
project = self.browse(new_project_id)
new_tasks = self.env['project.task']
# We want to copy archived task, but do not propagate an active_test context key
task_ids = self.env['project.task'].with_context(active_test=False).search([('project_id', '=', self.id), ('parent_id', '=', False)]).ids
if self.allow_task_dependencies and 'task_mapping' not in self.env.context:
self = self.with_context(task_mapping=dict())
for task in self.env['project.task'].browse(task_ids):
# preserve task name and stage, normally altered during copy
defaults = self._map_tasks_default_valeus(task, project)
new_tasks |= task.copy(defaults)
all_subtasks = new_tasks._get_all_subtasks()
subtasks_not_displayed = all_subtasks.filtered(
lambda task: not task.display_in_project
)
project.write({'tasks': [Command.set(new_tasks.ids)]})
subtasks_not_displayed.write({
'display_in_project': False
})
return True
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
if default is None:
default = {}
if not default.get('name'):
default['name'] = _("%s (copy)", self.name)
self_with_mail_context = self.with_context(mail_auto_subscribe_no_notify=True, mail_create_nosubscribe=True)
project = super(Project, self_with_mail_context).copy(default)
for follower in self.message_follower_ids:
project.message_subscribe(partner_ids=follower.partner_id.ids, subtype_ids=follower.subtype_ids.ids)
if self.allow_milestones:
if 'milestone_mapping' not in self.env.context:
self = self.with_context(milestone_mapping=dict())
project.milestone_ids = [milestone.copy().id for milestone in self.milestone_ids]
if 'tasks' not in default:
self.map_tasks(project.id)
return project
@api.model
def name_create(self, name):
res = super().name_create(name)
if res:
# We create a default stage `new` for projects created on the fly.
self.browse(res[0]).type_ids += self.env['project.task.type'].sudo().create({'name': _('New')})
return res
@api.model_create_multi
def create(self, vals_list):
# Prevent double project creation
self = self.with_context(mail_create_nosubscribe=True)
if any('label_tasks' in vals and not vals['label_tasks'] for vals in vals_list):
task_label = _("Tasks")
for vals in vals_list:
if 'label_tasks' in vals and not vals['label_tasks']:
vals['label_tasks'] = task_label
if len(self.env.companies) > 1 and self.env.user.has_group('project.group_project_stages'):
# Select the stage whether the default_stage_id field is set in context (quick create) or if it is not (normal create)
stage = self.env['project.project.stage'].browse(self._context['default_stage_id']) if 'default_stage_id' in self._context else self._default_stage_id()
# The project's company_id must be the same as the stage's company_id
if stage.company_id:
for vals in vals_list:
vals['company_id'] = stage.company_id.id
projects = super().create(vals_list)
return projects
def write(self, vals):
# Here we modify the project's stage according to the selected company (selecting the first
# stage in sequence that is linked to the company).
company_id = vals.get('company_id')
if self.env.user.has_group('project.group_project_stages') and company_id:
projects_already_with_company = self.filtered(lambda p: p.company_id.id == company_id)
if projects_already_with_company:
projects_already_with_company.write({key: value for key, value in vals.items() if key != 'company_id'})
self -= projects_already_with_company
if company_id not in (None, *self.company_id.ids) and self.stage_id.company_id:
ProjectStage = self.env['project.project.stage']
vals["stage_id"] = ProjectStage.search(
[('company_id', 'in', (company_id, False))],
order=f"sequence asc, {ProjectStage._order}",
limit=1,
).id
# directly compute is_favorite to dodge allow write access right
if 'is_favorite' in vals:
vals.pop('is_favorite')
self._fields['is_favorite'].determine_inverse(self)
if 'last_update_status' in vals and vals['last_update_status'] != 'to_define':
for project in self:
# This does not benefit from multi create, this is to allow the default description from being built.
# This does seem ok since last_update_status should only be updated on one record at once.
self.env['project.update'].with_context(default_project_id=project.id).create({
'name': _('Status Update - ') + fields.Date.today().strftime(get_lang(self.env).date_format),
'status': vals.get('last_update_status'),
})
vals.pop('last_update_status')
if vals.get('privacy_visibility'):
self._change_privacy_visibility(vals['privacy_visibility'])
date_start = vals.get('date_start', True)
date_end = vals.get('date', True)
if not date_start or not date_end:
vals['date_start'] = False
vals['date'] = False
else:
no_current_date_begin = not all(project.date_start for project in self)
no_current_date_end = not all(project.date for project in self)
date_start_update = 'date_start' in vals
date_end_update = 'date' in vals
if (date_start_update and no_current_date_end and not date_end_update):
del vals['date_start']
elif (date_end_update and no_current_date_begin and not date_start_update):
del vals['date']
res = super(Project, self).write(vals) if vals else True
if 'allow_task_dependencies' in vals and not vals.get('allow_task_dependencies'):
self.env['project.task'].search([('project_id', 'in', self.ids), ('state', '=', '04_waiting_normal')]).write({'state': '01_in_progress'})
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']})
if 'name' in vals and self.analytic_account_id:
projects_read_group = self.env['project.project']._read_group(
[('analytic_account_id', 'in', self.analytic_account_id.ids)],
['analytic_account_id'],
having=[('__count', '=', 1)],
)
analytic_account_to_update = self.env['account.analytic.account'].browse([
analytic_account.id for [analytic_account] in projects_read_group
])
analytic_account_to_update.write({'name': self.name})
return res
def unlink(self):
# Delete the empty related analytic account
analytic_accounts_to_delete = self.env['account.analytic.account']
tasks = self.with_context(active_test=False).tasks
for project in self:
if project.analytic_account_id and not project.analytic_account_id.line_ids:
analytic_accounts_to_delete |= project.analytic_account_id
result = super(Project, self).unlink()
tasks.unlink()
analytic_accounts_to_delete.unlink()
return result
def _order_field_to_sql(self, alias, field_name, direction, nulls, query):
if field_name == 'is_favorite':
sql_field = SQL(
"%s IN (SELECT project_id FROM project_favorite_user_rel WHERE user_id = %s)",
SQL.identifier(alias, 'id'), self.env.uid,
)
return SQL("%s %s %s", sql_field, direction, nulls)
return super()._order_field_to_sql(alias, field_name, direction, nulls, query)
def message_subscribe(self, partner_ids=None, subtype_ids=None):
"""
Subscribe to newly created task but not all existing active task when subscribing to a project.
User update notification preference of project its propagated to all the tasks that the user is
currently following.
"""
res = super(Project, self).message_subscribe(partner_ids=partner_ids, subtype_ids=subtype_ids)
if subtype_ids:
project_subtypes = self.env['mail.message.subtype'].browse(subtype_ids)
task_subtypes = (project_subtypes.mapped('parent_id') | project_subtypes.filtered(lambda sub: sub.internal or sub.default)).ids
if task_subtypes:
for task in self.task_ids:
partners = set(task.message_partner_ids.ids) & set(partner_ids)
if partners:
task.message_subscribe(partner_ids=list(partners), subtype_ids=task_subtypes)
self.update_ids.message_subscribe(partner_ids=partner_ids, subtype_ids=subtype_ids)
return res
def _alias_get_creation_values(self):
values = super(Project, self)._alias_get_creation_values()
values['alias_model_id'] = self.env['ir.model']._get('project.task').id
if self.id:
values['alias_defaults'] = defaults = ast.literal_eval(self.alias_defaults or "{}")
defaults['project_id'] = self.id
return values
@api.constrains('stage_id')
def _ensure_stage_has_same_company(self):
for project in self:
if project.stage_id.company_id and project.stage_id.company_id != project.company_id:
raise UserError(
_('This project is associated with %s, whereas the selected stage belongs to %s. '
'There are a couple of options to consider: either remove the company designation '
'from the project or from the stage. Alternatively, you can update the company '
'information for these records to align them under the same company.', project.company_id.name, project.stage_id.company_id.name)
if project.company_id else
_('This project is not associated to any company, while the stage is associated to %s. '
'There are a couple of options to consider: either change the project\'s company '
'to align with the stage\'s company or remove the company designation from the stage', project.stage_id.company_id.name)
)
# ---------------------------------------------------
# Mail gateway
# ---------------------------------------------------
def _track_template(self, changes):
res = super()._track_template(changes)
project = self[0]
if self.user_has_groups('project.group_project_stages') and 'stage_id' in changes and project.stage_id.mail_template_id:
res['stage_id'] = (project.stage_id.mail_template_id, {
'auto_delete_keep_log': False,
'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'),
'email_layout_xmlid': 'mail.mail_notification_light',
})
return res
def _track_subtype(self, init_values):
self.ensure_one()
if 'stage_id' in init_values:
return self.env.ref('project.mt_project_stage_change')
return super()._track_subtype(init_values)
def _mail_get_message_subtypes(self):
res = super()._mail_get_message_subtypes()
if not self.rating_active:
res -= self.env.ref('project.mt_project_task_rating')
if len(self) == 1:
waiting_subtype = self.env.ref('project.mt_project_task_waiting')
if not self.allow_task_dependencies and waiting_subtype in res:
res -= waiting_subtype
return res
# ---------------------------------------------------
# Actions
# ---------------------------------------------------
def action_project_task_burndown_chart_report(self):
action = self.env['ir.actions.act_window']._for_xml_id('project.action_project_task_burndown_chart_report')
action['display_name'] = _("%(name)s's Burndown Chart", name=self.name)
context = action['context'].replace('active_id', str(self.id))
context = ast.literal_eval(context)
context.update({
'stage_name_and_sequence_per_id': {
stage.id: {
'sequence': stage.sequence,
'name': stage.name
} for stage in self.type_ids
}
})
action['context'] = context
return action
# TODO to remove in master
def action_project_timesheets(self):
pass
def project_update_all_action(self):
action = self.env['ir.actions.act_window']._for_xml_id('project.project_update_all_action')
action['display_name'] = _("%(name)s's Updates", name=self.name)
return action
def action_project_sharing(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id('project.project_sharing_project_task_action')
action['context'] = {
'default_project_id': self.id,
'delete': False,
'search_default_open_tasks': True,
'active_id_chatter': self.id,
}
action['display_name'] = self.name
return action
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)]})
def action_view_tasks(self):
action = self.env['ir.actions.act_window'].with_context({'active_id': self.id})._for_xml_id('project.act_project_project_2_project_task_all')
action['display_name'] = _("%(name)s", name=self.name)
context = action['context'].replace('active_id', str(self.id))
context = ast.literal_eval(context)
context.update({
'create': self.active,
'active_test': self.active
})
action['context'] = context
return action
def action_view_all_rating(self):
""" return the action to see all the rating of the project and activate default filters"""
action = self.env['ir.actions.act_window']._for_xml_id('project.rating_rating_action_view_project_rating')
action['display_name'] = _("%(name)s's Rating", name=self.name)
action_context = ast.literal_eval(action['context']) if action['context'] else {}
action_context.update(self._context)
action_context['search_default_rating_last_30_days'] = 1
action_context.pop('group_by', None)
action['domain'] = [('consumed', '=', True), ('parent_res_model', '=', 'project.project'), ('parent_res_id', '=', self.id)]
if self.rating_count == 1:
action.update({
'view_mode': 'form',
'views': [(view_id, view_type) for view_id, view_type in action['views'] if view_type == 'form'],
'res_id': self.rating_ids[0].id, # [0] since rating_ids might be > then rating_count
})
return dict(action, context=action_context)
def action_view_tasks_analysis(self):
""" return the action to see the tasks analysis report of the project """
action = self.env['ir.actions.act_window']._for_xml_id('project.action_project_task_user_tree')
action['display_name'] = _("%(name)s's Tasks Analysis", name=self.name)
action_context = ast.literal_eval(action['context']) if action['context'] else {}
action_context['search_default_project_id'] = self.id
return dict(action, context=action_context)
def action_get_list_view(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _("%(name)s's Milestones", name=self.name),
'domain': [('project_id', '=', self.id)],
'res_model': 'project.milestone',
'views': [(self.env.ref('project.project_milestone_view_tree').id, 'tree')],
'view_mode': 'tree',
'help': _("""
<p class="o_view_nocontent_smiling_face">
No milestones found. Let's create one!
</p><p>
Track major progress points that must be reached to achieve success.
</p>
"""),
'context': {
'default_project_id': self.id,
**self.env.context
}
}
# ---------------------------------------------
# PROJECT UPDATES
# ---------------------------------------------
def action_profitability_items(self, section_name, domain=None, res_id=False):
return {}
def get_last_update_or_default(self):
self.ensure_one()
labels = dict(self._fields['last_update_status']._description_selection(self.env))
return {
'status': labels.get(self.last_update_status, _('Set Status')),
'color': self.last_update_color,
}
def get_panel_data(self):
self.ensure_one()
if not self.user_has_groups('project.group_project_user'):
return {}
show_profitability = self._show_profitability()
panel_data = {
'user': self._get_user_values(),
'buttons': sorted(self._get_stat_buttons(), key=lambda k: k['sequence']),
'currency_id': self.currency_id.id,
'show_project_profitability_helper': show_profitability and self._show_profitability_helper(),
}
if self.allow_milestones:
panel_data['milestones'] = self._get_milestones()
if show_profitability:
profitability_items = self._get_profitability_items()
if self._get_profitability_sequence_per_invoice_type() and profitability_items and 'revenues' in profitability_items and 'costs' in profitability_items: # sort the data values
profitability_items['revenues']['data'] = sorted(profitability_items['revenues']['data'], key=lambda k: k['sequence'])
profitability_items['costs']['data'] = sorted(profitability_items['costs']['data'], key=lambda k: k['sequence'])
panel_data['profitability_items'] = profitability_items
panel_data['profitability_labels'] = self._get_profitability_labels()
return panel_data
def get_milestones(self):
if self.user_has_groups('project.group_project_user'):
return self._get_milestones()
return {}
def _get_profitability_labels(self):
return {}
def _get_profitability_sequence_per_invoice_type(self):
return {}
def _get_already_included_profitability_invoice_line_ids(self):
# To be extended to avoid account.move.line overlap between
# profitability reports.
return []
def _get_user_values(self):
return {
'is_project_user': self.user_has_groups('project.group_project_user'),
}
def _show_profitability(self):
self.ensure_one()
return True
def _show_profitability_helper(self):
return self.user_has_groups('analytic.group_analytic_accounting')
def _get_profitability_aal_domain(self):
return [('account_id', 'in', self.analytic_account_id.ids)]
def _get_profitability_items(self, with_action=True):
return self._get_items_from_aal(with_action)
def _get_items_from_aal(self, with_action=True):
return {
'revenues': {'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}},
'costs': {'data': [], 'total': {'billed': 0.0, 'to_bill': 0.0}},
}
def _get_milestones(self):
self.ensure_one()
return {
'data': self.milestone_ids._get_data_list(),
}
def _get_stat_buttons(self):
self.ensure_one()
if self.task_count:
number = _lt(
"%(closed_task_count)s / %(task_count)s (%(closed_rate)s%%)",
closed_task_count=self.closed_task_count,
task_count=self.task_count,
closed_rate=round(100 * self.closed_task_count / self.task_count),
)
else:
number = _lt(
"%(closed_task_count)s / %(task_count)s",
closed_task_count=self.closed_task_count,
task_count=self.task_count,
)
buttons = [{
'icon': 'check',
'text': _lt('Tasks'),
'number': number,
'action_type': 'object',
'action': 'action_view_tasks',
'show': True,
'sequence': 1,
}]
if self.rating_count != 0 and self.user_has_groups('project.group_project_rating'):
if self.rating_avg >= rating_data.RATING_AVG_TOP:
icon = 'smile-o text-success'
elif self.rating_avg >= rating_data.RATING_AVG_OK:
icon = 'meh-o text-warning'
else:
icon = 'frown-o text-danger'
buttons.append({
'icon': icon,
'text': _lt('Average Rating'),
'number': f'{int(self.rating_avg) if self.rating_avg.is_integer() else round(self.rating_avg, 1)} / 5',
'action_type': 'object',
'action': 'action_view_all_rating',
'show': self.rating_active,
'sequence': 15,
})
if self.user_has_groups('project.group_project_user'):
buttons.append({
'icon': 'area-chart',
'text': _lt('Burndown Chart'),
'action_type': 'action',
'action': 'project.action_project_task_burndown_chart_report',
'additional_context': json.dumps({
'active_id': self.id,
'stage_name_and_sequence_per_id': {
stage.id: {
'sequence': stage.sequence,
'name': stage.name
} for stage in self.type_ids
},
}),
'show': True,
'sequence': 60,
})
return buttons
# ---------------------------------------------------
# Business Methods
# ---------------------------------------------------
def _get_hide_partner(self):
return False
@api.model
def _create_analytic_account_from_values(self, values):
company = self.env['res.company'].browse(values.get('company_id', False))
project_plan, _other_plans = self.env['account.analytic.plan']._get_all_plans()
analytic_account = self.env['account.analytic.account'].create({
'name': values.get('name', _('Unknown Analytic Account')),
'company_id': company.id,
'partner_id': values.get('partner_id'),
'plan_id': project_plan.id,
})
return analytic_account
def _create_analytic_account(self):
for project in self:
company_id = project.company_id.id
project_plan, _other_plans = self.env['account.analytic.plan']._get_all_plans()
analytic_account = self.env['account.analytic.account'].create({
'name': project.name,
'company_id': company_id,
'partner_id': project.partner_id.id,
'plan_id': project_plan.id,
'active': True,
})
project.write({'analytic_account_id': analytic_account.id})
def _get_projects_to_make_billable_domain(self):
return [('partner_id', '!=', False)]
# ---------------------------------------------------
# Rating business
# ---------------------------------------------------
# This method should be called once a day by the scheduler
@api.model
def _send_rating_all(self):
projects = self.search([
('rating_active', '=', True),
('rating_status', '=', 'periodic'),
('rating_request_deadline', '<=', fields.Datetime.now())
])
for project in projects:
project.task_ids._send_task_rating_mail()
project._compute_rating_request_deadline()
self.env.cr.commit()
# ---------------------------------------------------
# Privacy
# ---------------------------------------------------
def _change_privacy_visibility(self, new_visibility):
"""
Unsubscribe non-internal users from the project and tasks if the project privacy visibility
goes from 'portal' to a different value.
If the privacy visibility is set to 'portal', subscribe back project and tasks partners.
"""
for project in self:
if project.privacy_visibility == new_visibility:
continue
if new_visibility == 'portal':
project.message_subscribe(partner_ids=project.partner_id.ids)
for task in project.task_ids.filtered('partner_id'):
task.message_subscribe(partner_ids=task.partner_id.ids)
elif project.privacy_visibility == 'portal':
portal_users = project.message_partner_ids.user_ids.filtered('share')
project.message_unsubscribe(partner_ids=portal_users.partner_id.ids)
project.tasks._unsubscribe_portal_users()
# ---------------------------------------------------
# Project sharing
# ---------------------------------------------------
def _check_project_sharing_access(self):
self.ensure_one()
if self.privacy_visibility != 'portal':
return False
if self.env.user.has_group('base.group_portal'):
return self.env['project.collaborator'].search([('project_id', '=', self.sudo().id), ('partner_id', '=', self.env.user.partner_id.id)])
return self.env.user._is_internal()
def _add_collaborators(self, partners):
self.ensure_one()
user_group_id = self.env['ir.model.data']._xmlid_to_res_id('base.group_user')
all_collaborators = self.collaborator_ids.partner_id
new_collaborators = partners.filtered(
lambda partner:
partner not in all_collaborators
and (not partner.user_ids or user_group_id not in partner.user_ids[0].groups_id.ids)
)
if not new_collaborators:
# Then we have nothing to do
return
self.write({'collaborator_ids': [
Command.create({
'partner_id': collaborator.id,
}) for collaborator in new_collaborators],
})

View File

@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import UserError
class ProjectProjectStage(models.Model):
_name = 'project.project.stage'
_description = 'Project Stage'
_order = 'sequence, id'
active = fields.Boolean(default=True)
sequence = fields.Integer(default=50)
name = fields.Char(required=True, translate=True)
mail_template_id = fields.Many2one('mail.template', string='Email Template', domain=[('model', '=', 'project.project')],
help="If set, an email will be automatically sent to the customer when the project reaches this stage.")
fold = fields.Boolean('Folded in Kanban',
help="If enabled, this stage will be displayed as folded in the Kanban view of your projects. Projects in a folded stage are considered as closed.")
company_id = fields.Many2one('res.company', string="Company")
def copy(self, default=None):
default = dict(default or {})
if not default.get('name'):
default['name'] = _("%s (copy)", self.name)
return super().copy(default)
def unlink_wizard(self, stage_view=False):
wizard = self.with_context(active_test=False).env['project.project.stage.delete.wizard'].create({
'stage_ids': self.ids
})
context = dict(self.env.context)
context['stage_view'] = stage_view
return {
'name': _('Delete Project Stage'),
'view_mode': 'form',
'res_model': 'project.project.stage.delete.wizard',
'views': [(self.env.ref('project.view_project_project_stage_delete_wizard').id, 'form')],
'type': 'ir.actions.act_window',
'res_id': wizard.id,
'target': 'new',
'context': context,
}
def write(self, vals):
if vals.get('company_id'):
# Checking if there is a project with a different company_id than the target one. If so raise an error since this is not allowed
project = self.env['project.project'].search(['&', ('stage_id', 'in', self.ids), ('company_id', '!=', vals['company_id'])], limit=1)
if project:
company = self.env['res.company'].browse(vals['company_id'])
raise UserError(
_("You are not able to switch the company of this stage to %(company_name)s since it currently "
"includes projects associated with %(project_company_name)s. Please ensure that this stage exclusively "
"consists of projects linked to %(company_name)s.",
company_name=company.name,
project_company_name=project.company_id.name or "no company"
)
)
if 'active' in vals and not vals['active']:
self.env['project.project'].search([('stage_id', 'in', self.ids)]).write({'active': False})
return super().write(vals)
def toggle_active(self):
res = super().toggle_active()
stage_active = self.filtered('active')
inactive_projects = self.env['project.project'].with_context(active_test=False).search(
[('active', '=', False), ('stage_id', 'in', stage_active.ids)], limit=1)
if stage_active and inactive_projects:
wizard = self.env['project.project.stage.delete.wizard'].create({
'stage_ids': stage_active.ids,
})
return {
'name': _('Unarchive Projects'),
'view_mode': 'form',
'res_model': 'project.project.stage.delete.wizard',
'views': [(self.env.ref('project.view_project_project_stage_unarchive_wizard').id, 'form')],
'type': 'ir.actions.act_window',
'res_id': wizard.id,
'target': 'new',
}
return res

97
models/project_tags.py Normal file
View File

@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from random import randint
from odoo import api, fields, models, SUPERUSER_ID
from odoo.osv import expression
class ProjectTags(models.Model):
""" Tags of project's tasks """
_name = "project.tags"
_description = "Project Tags"
_order = "name"
def _get_default_color(self):
return randint(1, 11)
name = fields.Char('Name', required=True, translate=True)
color = fields.Integer(string='Color', default=_get_default_color,
help="Transparent tags are not visible in the kanban view of your projects and tasks.")
project_ids = fields.Many2many('project.project', 'project_project_project_tags_rel', string='Projects')
task_ids = fields.Many2many('project.task', string='Tasks')
_sql_constraints = [
('name_uniq', 'unique (name)', "A tag with the same name already exists."),
]
def _get_project_tags_domain(self, domain, project_id):
# TODO: Remove in master
return domain
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
if 'project_id' in self.env.context:
tag_ids = self._name_search('')
domain = expression.AND([domain, [('id', 'in', tag_ids)]])
return super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
@api.model
def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None):
if 'project_id' in self.env.context:
tag_ids = self._name_search('')
domain = expression.AND([domain, [('id', 'in', tag_ids)]])
return self.arrange_tag_list_by_id(super().search_read(domain=domain, fields=fields, offset=offset, limit=limit), tag_ids)
return super().search_read(domain=domain, fields=fields, offset=offset, limit=limit, order=order)
@api.model
def arrange_tag_list_by_id(self, tag_list, id_order):
"""arrange_tag_list_by_id re-order a list of record values (dict) following a given id sequence
complexity: O(n)
param:
- tag_list: ordered (by id) list of record values, each record being a dict
containing at least an 'id' key
- id_order: list of value (int) corresponding to the id of the records to re-arrange
result:
- Sorted list of record values (dict)
"""
tags_by_id = {tag['id']: tag for tag in tag_list}
return [tags_by_id[id] for id in id_order if id in tags_by_id]
@api.model
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
ids = []
if not (name == '' and operator in ('like', 'ilike')):
if domain is None:
domain = []
domain += [('name', operator, name)]
if self.env.context.get('project_id'):
# optimisation for large projects, we look first for tags present on the last 1000 tasks of said project.
# when not enough results are found, we complete them with a fallback on a regular search
self.env.cr.execute("""
SELECT DISTINCT project_tasks_tags.id
FROM (
SELECT rel.project_tags_id AS id
FROM project_tags_project_task_rel AS rel
JOIN project_task AS task
ON task.id=rel.project_task_id
AND task.project_id=%(project_id)s
ORDER BY task.id DESC
LIMIT 1000
) AS project_tasks_tags
""", {'project_id': self.env.context['project_id']})
project_tasks_tags_domain = [('id', 'in', [row[0] for row in self.env.cr.fetchall()])]
# we apply the domain and limit to the ids we've already found
ids += self.env['project.tags'].search(expression.AND([domain, project_tasks_tags_domain]), limit=limit, order=order).ids
if not limit or len(ids) < limit:
limit = limit and limit - len(ids)
ids += self.env['project.tags'].search(expression.AND([domain, [('id', 'not in', ids)]]), limit=limit, order=order).ids
return ids
@api.model
def name_create(self, name):
existing_tag = self.search([('name', '=ilike', name.strip())], limit=1)
if existing_tag:
return existing_tag.id, existing_tag.display_name
return super().name_create(name)

1745
models/project_task.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models, Command
from odoo.exceptions import ValidationError
from dateutil.relativedelta import relativedelta
class ProjectTaskRecurrence(models.Model):
_name = 'project.task.recurrence'
_description = 'Task Recurrence'
task_ids = fields.One2many('project.task', 'recurrence_id', copy=False)
repeat_interval = fields.Integer(string='Repeat Every', default=1)
repeat_unit = fields.Selection([
('day', 'Days'),
('week', 'Weeks'),
('month', 'Months'),
('year', 'Years'),
], default='week')
repeat_type = fields.Selection([
('forever', 'Forever'),
('until', 'Until'),
], default="forever", string="Until")
repeat_until = fields.Date(string="End Date")
@api.constrains('repeat_interval')
def _check_repeat_interval(self):
if self.filtered(lambda t: t.repeat_interval <= 0):
raise ValidationError(_('The interval should be greater than 0'))
@api.constrains('repeat_type', 'repeat_until')
def _check_repeat_until_date(self):
today = fields.Date.today()
if self.filtered(lambda t: t.repeat_type == 'until' and t.repeat_until < today):
raise ValidationError(_('The end date should be in the future'))
@api.model
def _get_recurring_fields_to_copy(self):
return [
'analytic_account_id',
'company_id',
'description',
'displayed_image_id',
'email_cc',
'message_partner_ids',
'name',
'parent_id',
'partner_id',
'allocated_hours',
'project_id',
'project_privacy_visibility',
'recurrence_id',
'recurring_task',
'sequence',
'tag_ids',
'user_ids',
]
@api.model
def _get_recurring_fields_to_postpone(self):
return [
'date_deadline',
]
def _get_last_task_id_per_recurrence_id(self):
return {} if not self else {
recurrence.id: max_task_id
for recurrence, max_task_id in self.env['project.task'].sudo()._read_group(
[('recurrence_id', 'in', self.ids)],
['recurrence_id'],
['id:max'],
)
}
def _get_recurrence_delta(self):
return relativedelta(**{
f"{self.repeat_unit}s": self.repeat_interval
})
def _create_next_occurrence(self, occurrence_from):
self.ensure_one()
self.env['project.task'].sudo().create(
self._create_next_occurrence_values(occurrence_from)
)
def _create_next_occurrence_values(self, occurrence_from):
self.ensure_one()
fields_to_copy = occurrence_from.read(self._get_recurring_fields_to_copy()).pop()
create_values = {
field: value[0] if isinstance(value, tuple) else value
for field, value in fields_to_copy.items()
}
fields_to_postpone = occurrence_from.read(self._get_recurring_fields_to_postpone()).pop()
fields_to_postpone.pop('id', None)
create_values.update({
field: value and value + self._get_recurrence_delta()
for field, value in fields_to_postpone.items()
})
create_values['stage_id'] = occurrence_from.project_id.type_ids[0].id if occurrence_from.project_id.type_ids else occurrence_from.stage_id.id
create_values['child_ids'] = [
Command.create(self._create_next_occurrence_values(child)) for child in occurrence_from.with_context(active_test=False).child_ids
]
return create_values

View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ProjectTaskStagePersonal(models.Model):
_name = 'project.task.stage.personal'
_description = 'Personal Task Stage'
_table = 'project_task_user_rel'
_rec_name = 'stage_id'
task_id = fields.Many2one('project.task', required=True, ondelete='cascade', index=True)
user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
stage_id = fields.Many2one('project.task.type', domain="[('user_id', '=', user_id)]", ondelete='set null')
_sql_constraints = [
('project_personal_stage_unique', 'UNIQUE (task_id, user_id)', 'A task can only have a single personal stage per user.'),
]

187
models/project_task_type.py Normal file
View File

@ -0,0 +1,187 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class ProjectTaskType(models.Model):
_name = 'project.task.type'
_description = 'Task Stage'
_order = 'sequence, id'
def _get_default_project_ids(self):
default_project_id = self.env.context.get('default_project_id')
return [default_project_id] if default_project_id else None
def _default_user_id(self):
return 'default_project_id' not in self.env.context and self.env.uid
active = fields.Boolean('Active', default=True)
name = fields.Char(string='Name', required=True, translate=True)
description = fields.Text(translate=True)
sequence = fields.Integer(default=1)
project_ids = fields.Many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', string='Projects',
default=lambda self: self._get_default_project_ids(),
help="Projects in which this stage is present. If you follow a similar workflow in several projects,"
" you can share this stage among them and get consolidated information this way.")
mail_template_id = fields.Many2one(
'mail.template',
string='Email Template',
domain=[('model', '=', 'project.task')],
help="If set, an email will be automatically sent to the customer when the task reaches this stage.")
fold = fields.Boolean(string='Folded in Kanban')
rating_template_id = fields.Many2one(
'mail.template',
string='Rating Email Template',
domain=[('model', '=', 'project.task')],
help="If set, a rating request will automatically be sent by email to the customer when the task reaches this stage. \n"
"Alternatively, it will be sent at a regular interval as long as the task remains in this stage, depending on the configuration of your project. \n"
"To use this feature make sure that the 'Customer Ratings' option is enabled on your project.")
auto_validation_state = fields.Boolean('Automatic Kanban Status', default=False,
help="Automatically modify the state when the customer replies to the feedback for this stage.\n"
" * Good feedback from the customer will update the state to 'Approved' (green bullet).\n"
" * Neutral or bad feedback will set the kanban state to 'Changes Requested' (orange bullet).\n")
disabled_rating_warning = fields.Text(compute='_compute_disabled_rating_warning')
user_id = fields.Many2one('res.users', 'Stage Owner', default=_default_user_id, compute='_compute_user_id', store=True, index=True)
def unlink_wizard(self, stage_view=False):
self = self.with_context(active_test=False)
# retrieves all the projects with a least 1 task in that stage
# a task can be in a stage even if the project is not assigned to the stage
readgroup = self.with_context(active_test=False).env['project.task']._read_group([('stage_id', 'in', self.ids)], ['project_id'])
project_ids = list(set([project.id for [project] in readgroup] + self.project_ids.ids))
wizard = self.with_context(project_ids=project_ids).env['project.task.type.delete.wizard'].create({
'project_ids': project_ids,
'stage_ids': self.ids
})
context = dict(self.env.context)
context['stage_view'] = stage_view
return {
'name': _('Delete Stage'),
'view_mode': 'form',
'res_model': 'project.task.type.delete.wizard',
'views': [(self.env.ref('project.view_project_task_type_delete_wizard').id, 'form')],
'type': 'ir.actions.act_window',
'res_id': wizard.id,
'target': 'new',
'context': context,
}
def write(self, vals):
if 'active' in vals and not vals['active']:
self.env['project.task'].search([('stage_id', 'in', self.ids)]).write({'active': False})
return super(ProjectTaskType, self).write(vals)
def copy(self, default=None):
default = dict(default or {})
if not default.get('name'):
default['name'] = _("%s (copy)", self.name)
return super().copy(default)
@api.ondelete(at_uninstall=False)
def _unlink_if_remaining_personal_stages(self):
""" Prepare personal stages for deletion (i.e. move task to other personal stages) and
avoid unlink if no remaining personal stages for an active internal user.
"""
# Personal stages are processed if the user still has at least one personal stage after unlink
personal_stages = self.filtered('user_id')
if not personal_stages:
return
remaining_personal_stages_all = self.env['project.task.type']._read_group(
[('user_id', 'in', personal_stages.user_id.ids), ('id', 'not in', personal_stages.ids)],
groupby=['user_id', 'sequence', 'id'],
order="user_id,sequence DESC",
)
remaining_personal_stages_by_user = defaultdict(list)
for user, sequence, stage in remaining_personal_stages_all:
remaining_personal_stages_by_user[user].append({'id': stage.id, 'seq': sequence})
# For performance issue, project.task.stage.personal records that need to be modified are listed before calling _prepare_personal_stages_deletion
personal_stages_to_update = self.env['project.task.stage.personal']._read_group([('stage_id', 'in', personal_stages.ids)], ['stage_id'], ['id:recordset'])
for user in personal_stages.user_id:
if not user.active or user.share:
continue
user_stages_to_unlink = personal_stages.filtered(lambda stage: stage.user_id == user)
user_remaining_stages = remaining_personal_stages_by_user[user]
if not user_remaining_stages:
raise UserError(_("Each user should have at least one personal stage. Create a new stage to which the tasks can be transferred after the selected ones are deleted."))
user_stages_to_unlink._prepare_personal_stages_deletion(user_remaining_stages, personal_stages_to_update)
def _prepare_personal_stages_deletion(self, remaining_stages_dict, personal_stages_to_update):
""" _prepare_personal_stages_deletion prepare the deletion of personal stages of a single user.
Tasks using that stage will be moved to the first stage with a lower sequence if it exists
higher if not.
:param self: project.task.type recordset containing the personal stage of a user
that need to be deleted
:param remaining_stages_dict: list of dict representation of the personal stages of a user that
can be used to replace the deleted ones. Can not be empty.
e.g: [{'id': stage1_id, 'seq': stage1_sequence}, ...]
:param personal_stages_to_update: project.task.stage.personal recordset containing the records
that need to be updated after stage modification. Is passed to
this method as an argument to avoid to reload it for each users
when this method is called multiple times.
"""
stages_to_delete_dict = sorted([{'id': stage.id, 'seq': stage.sequence} for stage in self],
key=lambda stage: stage['seq'])
replacement_stage_id = remaining_stages_dict.pop()['id']
next_replacement_stage = remaining_stages_dict and remaining_stages_dict.pop()
personal_stages_by_stage = {
stage.id: personal_stages
for stage, personal_stages in personal_stages_to_update
}
for stage in stages_to_delete_dict:
while next_replacement_stage and next_replacement_stage['seq'] < stage['seq']:
replacement_stage_id = next_replacement_stage['id']
next_replacement_stage = remaining_stages_dict and remaining_stages_dict.pop()
if stage['id'] in personal_stages_by_stage:
personal_stages_by_stage[stage['id']].stage_id = replacement_stage_id
def toggle_active(self):
res = super().toggle_active()
stage_active = self.filtered('active')
inactive_tasks = self.env['project.task'].with_context(active_test=False).search(
[('active', '=', False), ('stage_id', 'in', stage_active.ids)], limit=1)
if stage_active and inactive_tasks:
wizard = self.env['project.task.type.delete.wizard'].create({
'stage_ids': stage_active.ids,
})
return {
'name': _('Unarchive Tasks'),
'view_mode': 'form',
'res_model': 'project.task.type.delete.wizard',
'views': [(self.env.ref('project.view_project_task_type_unarchive_wizard').id, 'form')],
'type': 'ir.actions.act_window',
'res_id': wizard.id,
'target': 'new',
}
return res
@api.depends('project_ids', 'project_ids.rating_active')
def _compute_disabled_rating_warning(self):
for stage in self:
disabled_projects = stage.project_ids.filtered(lambda p: not p.rating_active)
if disabled_projects:
stage.disabled_rating_warning = '\n'.join('- %s' % p.name for p in disabled_projects)
else:
stage.disabled_rating_warning = False
@api.depends('project_ids')
def _compute_user_id(self):
""" Fields project_ids and user_id cannot be set together for a stage. It can happen that
project_ids is set after stage creation (e.g. when setting demo data). In such case, the
default user_id has to be removed.
"""
self.sudo().filtered('project_ids').user_id = False
@api.constrains('user_id', 'project_ids')
def _check_personal_stage_not_linked_to_projects(self):
if any(stage.user_id and stage.project_ids for stage in self):
raise UserError(_('A personal stage cannot be linked to a project because it is only visible to its corresponding user.'))

192
models/project_update.py Normal file
View File

@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from werkzeug.urls import url_encode
from odoo import api, fields, models
from odoo.osv import expression
from odoo.tools import formatLang
STATUS_COLOR = {
'on_track': 20, # green / success
'at_risk': 22, # orange
'off_track': 23, # red / danger
'on_hold': 21, # light blue
'done': 24, # purple
False: 0, # default grey -- for studio
# Only used in project.task
'to_define': 0,
}
class ProjectUpdate(models.Model):
_name = 'project.update'
_description = 'Project Update'
_order = 'id desc'
_inherit = ['mail.thread.cc', 'mail.activity.mixin']
def default_get(self, fields):
result = super().default_get(fields)
if 'project_id' in fields and not result.get('project_id'):
result['project_id'] = self.env.context.get('active_id')
if result.get('project_id'):
project = self.env['project.project'].browse(result['project_id'])
if 'progress' in fields and not result.get('progress'):
result['progress'] = project.last_update_id.progress
if 'description' in fields and not result.get('description'):
result['description'] = self._build_description(project)
if 'status' in fields and not result.get('status'):
# `to_define` is not an option for self.status, here we actually want to default to `on_track`
# the goal of `to_define` is for a project to start without an actual status.
result['status'] = project.last_update_status if project.last_update_status != 'to_define' else 'on_track'
return result
name = fields.Char("Title", required=True, tracking=True)
status = fields.Selection(selection=[
('on_track', 'On Track'),
('at_risk', 'At Risk'),
('off_track', 'Off Track'),
('on_hold', 'On Hold'),
('done', 'Done'),
], required=True, tracking=True)
color = fields.Integer(compute='_compute_color')
progress = fields.Integer(tracking=True)
progress_percentage = fields.Float(compute='_compute_progress_percentage')
user_id = fields.Many2one('res.users', string='Author', required=True, default=lambda self: self.env.user)
description = fields.Html()
date = fields.Date(default=fields.Date.context_today, tracking=True)
project_id = fields.Many2one('project.project', required=True)
name_cropped = fields.Char(compute="_compute_name_cropped")
task_count = fields.Integer("Task Count", readonly=True)
closed_task_count = fields.Integer("Closed Task Count", readonly=True)
closed_task_percentage = fields.Integer("Closed Task Percentage", compute="_compute_closed_task_percentage")
@api.depends('status')
def _compute_color(self):
for update in self:
update.color = STATUS_COLOR[update.status]
@api.depends('progress')
def _compute_progress_percentage(self):
for update in self:
update.progress_percentage = update.progress / 100
@api.depends('name')
def _compute_name_cropped(self):
for update in self:
update.name_cropped = (update.name[:57] + '...') if len(update.name) > 60 else update.name
def _compute_closed_task_percentage(self):
for update in self:
update.closed_task_percentage = update.task_count and round(update.closed_task_count * 100 / update.task_count)
# ---------------------------------
# ORM Override
# ---------------------------------
@api.model_create_multi
def create(self, vals_list):
updates = super().create(vals_list)
for update in updates:
project = update.project_id
project.sudo().last_update_id = update
update.write({
"task_count": project.task_count,
"closed_task_count": project.closed_task_count,
})
return updates
def unlink(self):
projects = self.project_id
res = super().unlink()
for project in projects:
project.last_update_id = self.search([('project_id', "=", project.id)], order="date desc", limit=1)
return res
# ---------------------------------
# Build default description
# ---------------------------------
@api.model
def _build_description(self, project):
return self.env['ir.qweb']._render('project.project_update_default_description', self._get_template_values(project))
@api.model
def _get_template_values(self, project):
milestones = self._get_milestone_values(project)
return {
'user': self.env.user,
'project': project,
'show_activities': milestones['show_section'],
'milestones': milestones,
'format_lang': lambda value, digits: formatLang(self.env, value, digits=digits),
}
@api.model
def _get_milestone_values(self, project):
Milestone = self.env['project.milestone']
if not project.allow_milestones:
return {
'show_section': False,
'list': [],
'updated': [],
'last_update_date': None,
'created': []
}
list_milestones = Milestone.search(
[('project_id', '=', project.id),
'|', ('deadline', '<', fields.Date.context_today(self) + relativedelta(years=1)), ('deadline', '=', False)])._get_data_list()
updated_milestones = self._get_last_updated_milestone(project)
domain = [('project_id', '=', project.id)]
if project.last_update_id.create_date:
domain = expression.AND([domain, [('create_date', '>', project.last_update_id.create_date)]])
created_milestones = Milestone.search(domain)._get_data_list()
return {
'show_section': (list_milestones or updated_milestones or created_milestones) and True or False,
'list': list_milestones,
'updated': updated_milestones,
'last_update_date': project.last_update_id.create_date or None,
'created': created_milestones,
}
@api.model
def _get_last_updated_milestone(self, project):
query = """
SELECT DISTINCT pm.id as milestone_id,
pm.deadline as deadline,
FIRST_VALUE(old_value_datetime::date) OVER w_partition as old_value,
pm.deadline as new_value
FROM mail_message mm
INNER JOIN mail_tracking_value mtv
ON mm.id = mtv.mail_message_id
INNER JOIN ir_model_fields imf
ON mtv.field_id = imf.id
AND imf.model = 'project.milestone'
AND imf.name = 'deadline'
INNER JOIN project_milestone pm
ON mm.res_id = pm.id
WHERE mm.model = 'project.milestone'
AND mm.message_type = 'notification'
AND pm.project_id = %(project_id)s
"""
if project.last_update_id.create_date:
query = query + "AND mm.date > %(last_update_date)s"
query = query + """
WINDOW w_partition AS (
PARTITION BY pm.id
ORDER BY mm.date ASC
)
ORDER BY pm.deadline ASC
LIMIT 1;
"""
query_params = {'project_id': project.id}
if project.last_update_id.create_date:
query_params['last_update_date'] = project.last_update_id.create_date
self.env.cr.execute(query, query_params)
results = self.env.cr.dictfetchall()
mapped_result = {res['milestone_id']: {'new_value': res['new_value'], 'old_value': res['old_value']} for res in results}
milestones = self.env['project.milestone'].search([('id', 'in', list(mapped_result.keys()))])
return [{
**milestone._get_data(),
'new_value': mapped_result[milestone.id]['new_value'],
'old_value': mapped_result[milestone.id]['old_value'],
} for milestone in milestones]

View File

@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
module_hr_timesheet = fields.Boolean(string="Task Logs")
group_project_rating = fields.Boolean("Customer Ratings", implied_group='project.group_project_rating')
group_project_stages = fields.Boolean("Project Stages", implied_group="project.group_project_stages")
group_project_recurring_tasks = fields.Boolean("Recurring Tasks", implied_group="project.group_project_recurring_tasks")
group_project_task_dependencies = fields.Boolean("Task Dependencies", implied_group="project.group_project_task_dependencies")
group_project_milestone = fields.Boolean('Milestones', implied_group='project.group_project_milestone', group='base.group_portal,base.group_user')
# Analytic Accounting
analytic_plan_id = fields.Many2one(
comodel_name='account.analytic.plan',
string="Analytic Plan",
config_parameter="analytic.analytic_plan_projects",
)
@api.model
def _get_basic_project_domain(self):
return []
def set_values(self):
# Ensure that settings on existing projects match the above fields
projects = self.env["project.project"].search([])
basic_projects = projects.filtered_domain(self._get_basic_project_domain())
features = {
# key: (config_flag, is_global), value: project_flag
("group_project_rating", True): "rating_active",
("group_project_task_dependencies", False): "allow_task_dependencies",
("group_project_milestone", False): "allow_milestones",
}
for (config_flag, is_global), project_flag in features.items():
config_flag_global = f"project.{config_flag}"
config_feature_enabled = self[config_flag]
if self.user_has_groups(config_flag_global) != config_feature_enabled:
if config_feature_enabled and not is_global:
basic_projects[project_flag] = config_feature_enabled
else:
projects[project_flag] = config_feature_enabled
task_waiting_subtype_id = self.env.ref('project.mt_task_waiting')
project_task_waiting_subtype_id = self.env.ref('project.mt_project_task_waiting')
if task_waiting_subtype_id.hidden != (not self['group_project_task_dependencies']):
task_waiting_subtype_id.hidden = not self['group_project_task_dependencies']
project_task_waiting_subtype_id.hidden = not self['group_project_task_dependencies']
# Hide Project Stage Changed mail subtype according to the settings
project_stage_change_mail_type = self.env.ref('project.mt_project_stage_change')
if project_stage_change_mail_type.hidden == self['group_project_stages']:
project_stage_change_mail_type.hidden = not self['group_project_stages']
# Hide task rating tempalate when customer rating is disbled
task_rating_subtype_id = self.env.ref('project.mt_project_task_rating')
task_rating_subtype_id.hidden = not self['group_project_rating']
self.env.ref('project.mt_task_rating').hidden = not self['group_project_rating']
task_rating_subtype_id.default = self['group_project_rating']
rating_project_request_email_template = self.env.ref('project.rating_project_request_email_template')
if rating_project_request_email_template.active != self['group_project_rating']:
rating_project_request_email_template.active = self['group_project_rating']
if not self['group_project_recurring_tasks']:
self.env['project.task'].sudo().search([('recurring_task', '=', True)]).write({'recurring_task': False})
super().set_values()

63
models/res_partner.py Normal file
View File

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import email_normalize
class ResPartner(models.Model):
""" Inherits partner and adds Tasks information in the partner form """
_inherit = 'res.partner'
project_ids = fields.One2many('project.project', 'partner_id', string='Projects')
task_ids = fields.One2many('project.task', 'partner_id', string='Tasks')
task_count = fields.Integer(compute='_compute_task_count', string='# Tasks')
@api.constrains('company_id', 'project_ids')
def _ensure_same_company_than_projects(self):
for partner in self:
if partner.company_id and partner.project_ids.company_id and partner.project_ids.company_id != partner.company_id:
raise UserError(_("Partner company cannot be different from its assigned projects' company"))
@api.constrains('company_id', 'task_ids')
def _ensure_same_company_than_tasks(self):
for partner in self:
if partner.company_id and partner.task_ids.company_id and partner.task_ids.company_id != partner.company_id:
raise UserError(_("Partner company cannot be different from its assigned tasks' company"))
def _compute_task_count(self):
# retrieve all children partners and prefetch 'parent_id' on them
all_partners = self.with_context(active_test=False).search_fetch(
[('id', 'child_of', self.ids)],
['parent_id'],
)
task_data = self.env['project.task']._read_group(
domain=[('partner_id', 'in', all_partners.ids)],
groupby=['partner_id'], aggregates=['__count']
)
self_ids = set(self._ids)
self.task_count = 0
for partner, count in task_data:
while partner:
if partner.id in self_ids:
partner.task_count += count
partner = partner.parent_id
# Deprecated: remove me in MASTER
def _create_portal_users(self):
partners_without_user = self.filtered(lambda partner: not partner.user_ids)
if not partners_without_user:
return self.env['res.users']
created_users = self.env['res.users']
for partner in partners_without_user:
created_users += self.env['res.users'].with_context(no_reset_password=True).sudo()._create_user_from_template({
'email': email_normalize(partner.email),
'login': email_normalize(partner.email),
'partner_id': partner.id,
'company_id': self.env.company.id,
'company_ids': [(6, 0, self.env.company.ids)],
'active': True,
})
return created_users

1
populate/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import project

139
populate/project.py Normal file
View File

@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import collections
from odoo import models, Command
from odoo.tools import populate
_logger = logging.getLogger(__name__)
class ProjectStage(models.Model):
_inherit = "project.task.type"
_populate_sizes = {"small": 10, "medium": 50, "large": 500}
def _populate_factories(self):
return [
("name", populate.constant('stage_{counter}')),
("sequence", populate.randomize([False] + [i for i in range(1, 101)])),
("description", populate.constant('project_stage_description_{counter}')),
("active", populate.randomize([True, False], [0.8, 0.2])),
("fold", populate.randomize([True, False], [0.9, 0.1]))
]
class ProjectProject(models.Model):
_inherit = "project.project"
_populate_sizes = {"small": 10, "medium": 50, "large": 1000}
_populate_dependencies = ["res.company", "project.task.type"]
def _populate_factories(self):
company_ids = self.env.registry.populated_models["res.company"]
stage_ids = self.env.registry.populated_models["project.task.type"]
def get_company_id(random, **kwargs):
return random.choice(company_ids)
# user_ids from company.user_ids ?
# Also add a partner_ids on res_company ?
def get_stage_ids(random, **kwargs):
return [
(6, 0, [
random.choice(stage_ids)
for i in range(random.choice([j for j in range(1, 10)]))
])
]
return [
("name", populate.constant('project_{counter}')),
("sequence", populate.randomize([False] + [i for i in range(1, 101)])),
("active", populate.randomize([True, False], [0.8, 0.2])),
("company_id", populate.compute(get_company_id)),
("type_ids", populate.compute(get_stage_ids)),
('color', populate.randomize([False] + [i for i in range(1, 7)])),
# TODO user_id but what about multi-company coherence ??
]
class ProjectTask(models.Model):
_inherit = "project.task"
_populate_sizes = {"small": 500, "medium": 5000, "large": 50000}
_populate_dependencies = ["project.project", "res.partner", "res.users"]
def _populate_factories(self):
project_ids = self.env.registry.populated_models["project.project"]
stage_ids = self.env.registry.populated_models["project.task.type"]
user_ids = self.env.registry.populated_models['res.users']
partner_ids_per_company_id = {
company.id: ids
for company, ids in self.env['res.partner']._read_group(
[('company_id', '!=', False), ('id', 'in', self.env.registry.populated_models["res.partner"])],
['company_id'],
['id:array_agg'],
)
}
company_id_per_project_id = {
record['id']: record['company_id']
for record in self.env['project.project'].search_read(
[('company_id', '!=', False), ('id', 'in', self.env.registry.populated_models["project.project"])],
['company_id'],
load=False
)
}
partner_ids_per_project_id = {
project_id: partner_ids_per_company_id[company_id]
for project_id, company_id in company_id_per_project_id.items()
}
def get_project_id(random, **kwargs):
return random.choice([False, False, False] + project_ids)
def get_stage_id(random, **kwargs):
return random.choice([False, False] + stage_ids)
def get_partner_id(random, **kwargs):
project_id = kwargs['values'].get('project_id')
partner_ids = partner_ids_per_project_id.get(project_id, False)
return partner_ids and random.choice(partner_ids + [False] * len(partner_ids))
def get_user_ids(values, counter, random):
return [Command.set([random.choice(user_ids) for i in range(random.randint(0, 3))])]
return [
("name", populate.constant('project_task_{counter}')),
("sequence", populate.randomize([False] + [i for i in range(1, 101)])),
("active", populate.randomize([True, False], [0.8, 0.2])),
("color", populate.randomize([False] + [i for i in range(1, 7)])),
("state", populate.randomize(['01_in_progress', '03_approved', '02_changes_requested', '1_done', '1_canceled'])),
("project_id", populate.compute(get_project_id)),
("stage_id", populate.compute(get_stage_id)),
('partner_id', populate.compute(get_partner_id)),
('user_ids', populate.compute(get_user_ids)),
]
def _populate(self, size):
records = super()._populate(size)
# set parent_ids
self._populate_set_children_tasks(records)
return records
def _populate_set_children_tasks(self, tasks):
_logger.info('Setting parent tasks')
rand = populate.Random('project.task+children_generator')
task_ids_per_company = collections.defaultdict(set)
for task in tasks:
if task.project_id:
task_ids_per_company[task.company_id].add(task.id)
for task_ids in task_ids_per_company.values():
parent_ids = set()
for task_id in task_ids:
if not rand.getrandbits(4):
parent_ids.add(task_id)
child_ids = task_ids - parent_ids
parent_ids = list(parent_ids)
child_ids_per_parent_id = collections.defaultdict(set)
for child_id in child_ids:
if not rand.getrandbits(4):
child_ids_per_parent_id[rand.choice(parent_ids)].add(child_id)
for count, (parent_id, child_ids) in enumerate(child_ids_per_parent_id.items()):
if (count + 1) % 100 == 0:
_logger.info('Setting parent: %s/%s', count + 1, len(child_ids_per_parent_id))
self.browse(child_ids).write({'parent_id': parent_id})

5
report/__init__.py Normal file
View File

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

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