Init
This commit is contained in:
parent
16aaea4afe
commit
39c358a61c
120
README.md
120
README.md
|
@ -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 there’s 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
35
__init__.py
Normal 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
209
__manifest__.py
Normal 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
5
controllers/__init__.py
Normal 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
546
controllers/portal.py
Normal 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)
|
101
controllers/project_sharing_chatter.py
Normal file
101
controllers/project_sharing_chatter.py
Normal 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
43
data/digest_data.xml
Normal 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
11
data/ir_cron_data.xml
Normal 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>
|
167
data/mail_message_subtype_data.xml
Normal file
167
data/mail_message_subtype_data.xml
Normal 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
126
data/mail_template_data.xml
Normal 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>
|
25
data/mail_template_demo.xml
Normal file
25
data/mail_template_demo.xml
Normal 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
25
data/project_data.xml
Normal 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
1373
data/project_demo.xml
Normal file
File diff suppressed because it is too large
Load Diff
16
doc/changelog.rst
Normal file
16
doc/changelog.rst
Normal 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
22
doc/index.rst
Normal 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
55
doc/stage_status.rst
Normal 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
5138
i18n/af.po
Normal file
File diff suppressed because it is too large
Load Diff
5993
i18n/ar.po
Normal file
5993
i18n/ar.po
Normal file
File diff suppressed because it is too large
Load Diff
5147
i18n/az.po
Normal file
5147
i18n/az.po
Normal file
File diff suppressed because it is too large
Load Diff
5696
i18n/bg.po
Normal file
5696
i18n/bg.po
Normal file
File diff suppressed because it is too large
Load Diff
5139
i18n/bs.po
Normal file
5139
i18n/bs.po
Normal file
File diff suppressed because it is too large
Load Diff
5885
i18n/ca.po
Normal file
5885
i18n/ca.po
Normal file
File diff suppressed because it is too large
Load Diff
5759
i18n/cs.po
Normal file
5759
i18n/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
5744
i18n/da.po
Normal file
5744
i18n/da.po
Normal file
File diff suppressed because it is too large
Load Diff
6098
i18n/de.po
Normal file
6098
i18n/de.po
Normal file
File diff suppressed because it is too large
Load Diff
5151
i18n/el.po
Normal file
5151
i18n/el.po
Normal file
File diff suppressed because it is too large
Load Diff
5137
i18n/en_GB.po
Normal file
5137
i18n/en_GB.po
Normal file
File diff suppressed because it is too large
Load Diff
6075
i18n/es.po
Normal file
6075
i18n/es.po
Normal file
File diff suppressed because it is too large
Load Diff
6073
i18n/es_419.po
Normal file
6073
i18n/es_419.po
Normal file
File diff suppressed because it is too large
Load Diff
5136
i18n/es_BO.po
Normal file
5136
i18n/es_BO.po
Normal file
File diff suppressed because it is too large
Load Diff
5136
i18n/es_CL.po
Normal file
5136
i18n/es_CL.po
Normal file
File diff suppressed because it is too large
Load Diff
5145
i18n/es_CO.po
Normal file
5145
i18n/es_CO.po
Normal file
File diff suppressed because it is too large
Load Diff
5136
i18n/es_CR.po
Normal file
5136
i18n/es_CR.po
Normal file
File diff suppressed because it is too large
Load Diff
5140
i18n/es_DO.po
Normal file
5140
i18n/es_DO.po
Normal file
File diff suppressed because it is too large
Load Diff
5146
i18n/es_EC.po
Normal file
5146
i18n/es_EC.po
Normal file
File diff suppressed because it is too large
Load Diff
5137
i18n/es_PE.po
Normal file
5137
i18n/es_PE.po
Normal file
File diff suppressed because it is too large
Load Diff
5136
i18n/es_PY.po
Normal file
5136
i18n/es_PY.po
Normal file
File diff suppressed because it is too large
Load Diff
5140
i18n/es_VE.po
Normal file
5140
i18n/es_VE.po
Normal file
File diff suppressed because it is too large
Load Diff
6047
i18n/et.po
Normal file
6047
i18n/et.po
Normal file
File diff suppressed because it is too large
Load Diff
5136
i18n/eu.po
Normal file
5136
i18n/eu.po
Normal file
File diff suppressed because it is too large
Load Diff
5733
i18n/fa.po
Normal file
5733
i18n/fa.po
Normal file
File diff suppressed because it is too large
Load Diff
6065
i18n/fi.po
Normal file
6065
i18n/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
6082
i18n/fr.po
Normal file
6082
i18n/fr.po
Normal file
File diff suppressed because it is too large
Load Diff
5136
i18n/gl.po
Normal file
5136
i18n/gl.po
Normal file
File diff suppressed because it is too large
Load Diff
5142
i18n/gu.po
Normal file
5142
i18n/gu.po
Normal file
File diff suppressed because it is too large
Load Diff
5730
i18n/he.po
Normal file
5730
i18n/he.po
Normal file
File diff suppressed because it is too large
Load Diff
5170
i18n/hr.po
Normal file
5170
i18n/hr.po
Normal file
File diff suppressed because it is too large
Load Diff
5723
i18n/hu.po
Normal file
5723
i18n/hu.po
Normal file
File diff suppressed because it is too large
Load Diff
6044
i18n/id.po
Normal file
6044
i18n/id.po
Normal file
File diff suppressed because it is too large
Load Diff
5142
i18n/is.po
Normal file
5142
i18n/is.po
Normal file
File diff suppressed because it is too large
Load Diff
6062
i18n/it.po
Normal file
6062
i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
5876
i18n/ja.po
Normal file
5876
i18n/ja.po
Normal file
File diff suppressed because it is too large
Load Diff
5136
i18n/ka.po
Normal file
5136
i18n/ka.po
Normal file
File diff suppressed because it is too large
Load Diff
5136
i18n/kab.po
Normal file
5136
i18n/kab.po
Normal file
File diff suppressed because it is too large
Load Diff
5141
i18n/km.po
Normal file
5141
i18n/km.po
Normal file
File diff suppressed because it is too large
Load Diff
5898
i18n/ko.po
Normal file
5898
i18n/ko.po
Normal file
File diff suppressed because it is too large
Load Diff
5138
i18n/lb.po
Normal file
5138
i18n/lb.po
Normal file
File diff suppressed because it is too large
Load Diff
5724
i18n/lt.po
Normal file
5724
i18n/lt.po
Normal file
File diff suppressed because it is too large
Load Diff
5684
i18n/lv.po
Normal file
5684
i18n/lv.po
Normal file
File diff suppressed because it is too large
Load Diff
5141
i18n/mk.po
Normal file
5141
i18n/mk.po
Normal file
File diff suppressed because it is too large
Load Diff
5165
i18n/mn.po
Normal file
5165
i18n/mn.po
Normal file
File diff suppressed because it is too large
Load Diff
5148
i18n/nb.po
Normal file
5148
i18n/nb.po
Normal file
File diff suppressed because it is too large
Load Diff
6059
i18n/nl.po
Normal file
6059
i18n/nl.po
Normal file
File diff suppressed because it is too large
Load Diff
5887
i18n/pl.po
Normal file
5887
i18n/pl.po
Normal file
File diff suppressed because it is too large
Load Diff
5647
i18n/project.pot
Normal file
5647
i18n/project.pot
Normal file
File diff suppressed because it is too large
Load Diff
5716
i18n/pt.po
Normal file
5716
i18n/pt.po
Normal file
File diff suppressed because it is too large
Load Diff
6064
i18n/pt_BR.po
Normal file
6064
i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load Diff
5191
i18n/ro.po
Normal file
5191
i18n/ro.po
Normal file
File diff suppressed because it is too large
Load Diff
6081
i18n/ru.po
Normal file
6081
i18n/ru.po
Normal file
File diff suppressed because it is too large
Load Diff
5717
i18n/sk.po
Normal file
5717
i18n/sk.po
Normal file
File diff suppressed because it is too large
Load Diff
5695
i18n/sl.po
Normal file
5695
i18n/sl.po
Normal file
File diff suppressed because it is too large
Load Diff
5868
i18n/sr.po
Normal file
5868
i18n/sr.po
Normal file
File diff suppressed because it is too large
Load Diff
5140
i18n/sr@latin.po
Normal file
5140
i18n/sr@latin.po
Normal file
File diff suppressed because it is too large
Load Diff
5805
i18n/sv.po
Normal file
5805
i18n/sv.po
Normal file
File diff suppressed because it is too large
Load Diff
5994
i18n/th.po
Normal file
5994
i18n/th.po
Normal file
File diff suppressed because it is too large
Load Diff
5864
i18n/tr.po
Normal file
5864
i18n/tr.po
Normal file
File diff suppressed because it is too large
Load Diff
5987
i18n/uk.po
Normal file
5987
i18n/uk.po
Normal file
File diff suppressed because it is too large
Load Diff
6001
i18n/vi.po
Normal file
6001
i18n/vi.po
Normal file
File diff suppressed because it is too large
Load Diff
5857
i18n/zh_CN.po
Normal file
5857
i18n/zh_CN.po
Normal file
File diff suppressed because it is too large
Load Diff
5851
i18n/zh_TW.po
Normal file
5851
i18n/zh_TW.po
Normal file
File diff suppressed because it is too large
Load Diff
20
models/__init__.py
Normal file
20
models/__init__.py
Normal 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
|
42
models/account_analytic_account.py
Normal file
42
models/account_analytic_account.py
Normal 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
27
models/digest_digest.py
Normal 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
17
models/ir_ui_menu.py
Normal 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
18
models/mail_message.py
Normal 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'"
|
||||||
|
)
|
57
models/project_collaborator.py
Normal file
57
models/project_collaborator.py
Normal 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
121
models/project_milestone.py
Normal 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
987
models/project_project.py
Normal 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],
|
||||||
|
})
|
83
models/project_project_stage.py
Normal file
83
models/project_project_stage.py
Normal 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
97
models/project_tags.py
Normal 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
1745
models/project_task.py
Normal file
File diff suppressed because it is too large
Load Diff
107
models/project_task_recurrence.py
Normal file
107
models/project_task_recurrence.py
Normal 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
|
18
models/project_task_stage_personal.py
Normal file
18
models/project_task_stage_personal.py
Normal 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
187
models/project_task_type.py
Normal 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
192
models/project_update.py
Normal 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]
|
69
models/res_config_settings.py
Normal file
69
models/res_config_settings.py
Normal 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
63
models/res_partner.py
Normal 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
1
populate/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from . import project
|
139
populate/project.py
Normal file
139
populate/project.py
Normal 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
5
report/__init__.py
Normal 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
Loading…
Reference in New Issue
Block a user