Initial commit: Project packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:52 +02:00
commit 89613c97b0
753 changed files with 496325 additions and 0 deletions

12
README.md Normal file
View file

@ -0,0 +1,12 @@
# Project
This repository contains OCA OCB packages for project.
## Packages Included
- odoo-bringout-oca-ocb-project
- odoo-bringout-oca-ocb-project_hr_expense
- odoo-bringout-oca-ocb-project_mail_plugin
- odoo-bringout-oca-ocb-project_mrp
- odoo-bringout-oca-ocb-project_purchase
- odoo-bringout-oca-ocb-project_timesheet_holidays

View file

@ -0,0 +1,54 @@
# Project
Odoo addon: project
## Installation
```bash
pip install odoo-bringout-oca-ocb-project
```
## Dependencies
This addon depends on:
- analytic
- base_setup
- mail
- portal
- rating
- resource
- web
- web_tour
- digest
## Manifest Information
- **Name**: Project
- **Version**: 1.3
- **Category**: Services/Project
- **License**: LGPL-3
- **Installable**: True
## Source
Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `project`.
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Project Module - project
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for project. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,17 @@
# Controllers
HTTP routes provided by this module.
```mermaid
sequenceDiagram
participant U as User/Client
participant C as Module Controllers
participant O as ORM/Views
U->>C: HTTP GET/POST (routes)
C->>O: ORM operations, render templates
O-->>U: HTML/JSON/PDF
```
Notes
- See files in controllers/ for route definitions.

View file

@ -0,0 +1,13 @@
# Dependencies
This addon depends on:
- [analytic](../../odoo-bringout-oca-ocb-analytic)
- [base_setup](../../odoo-bringout-oca-ocb-base_setup)
- [mail](../../odoo-bringout-oca-ocb-mail)
- [portal](../../odoo-bringout-oca-ocb-portal)
- [rating](../../odoo-bringout-oca-ocb-rating)
- [resource](../../odoo-bringout-oca-ocb-resource)
- [web](../../odoo-bringout-oca-ocb-web)
- [web_tour](../../odoo-bringout-oca-ocb-web_tour)
- [digest](../../odoo-bringout-oca-ocb-digest)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon project or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-ocb-project"
# or
uv pip install odoo-bringout-oca-ocb-project"
```

View file

@ -0,0 +1,29 @@
# Models
Detected core models and extensions in project.
```mermaid
classDiagram
class project_collaborator
class project_milestone
class project_project
class project_project_stage
class project_tags
class project_task
class project_task_recurrence
class project_task_stage_personal
class project_task_type
class project_update
class res_company
class account_analytic_account
class digest_digest
class ir_ui_menu
class mail_message
class res_company
class res_config_settings
class res_partner
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: project. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon project
- License: LGPL-3

View file

@ -0,0 +1,32 @@
# Reports
Report definitions and templates in project.
```mermaid
classDiagram
class ReportProjectTaskUser
Model <|-- ReportProjectTaskUser
class ReportProjectTaskBurndownChart
AbstractModel <|-- ReportProjectTaskBurndownChart
```
## Available Reports
### Analytical/Dashboard Reports
- **Tasks Analysis** (Analysis/Dashboard)
- **Burndown Chart** (Analysis/Dashboard)
## Report Files
- **__init__.py** (Python logic)
- **project_report.py** (Python logic)
- **project_report_views.xml** (XML template/definition)
- **project_task_burndown_chart_report.py** (Python logic)
- **project_task_burndown_chart_report_views.xml** (XML template/definition)
## Notes
- Named reports above are accessible through Odoo's reporting menu
- Python files define report logic and data processing
- XML files contain report templates, definitions, and formatting
- Reports are integrated with Odoo's printing and email systems

View file

@ -0,0 +1,45 @@
# Security
Access control and security definitions in project.
## Access Control Lists (ACLs)
Model access permissions defined in:
- **[ir.model.access.csv](../project/security/ir.model.access.csv)**
- 43 model access rules
## Record Rules
Row-level security rules defined in:
## Security Groups & Configuration
Security groups and permissions defined in:
- **[ir.model.access.xml](../project/security/ir.model.access.xml)**
- **[project_security.xml](../project/security/project_security.xml)**
- 8 security groups defined
```mermaid
graph TB
subgraph "Security Layers"
A[Users] --> B[Groups]
B --> C[Access Control Lists]
C --> D[Models]
B --> E[Record Rules]
E --> F[Individual Records]
end
```
Security files overview:
- **[ir.model.access.csv](../project/security/ir.model.access.csv)**
- Model access permissions (CRUD rights)
- **[ir.model.access.xml](../project/security/ir.model.access.xml)**
- Security groups, categories, and XML-based rules
- **[project_security.xml](../project/security/project_security.xml)**
- Security groups, categories, and XML-based rules
Notes
- Access Control Lists define which groups can access which models
- Record Rules provide row-level security (filter records by user/group)
- Security groups organize users and define permission sets
- All security is enforced at the ORM level by Odoo

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon project
```

View file

@ -0,0 +1,9 @@
# Wizards
Transient models exposed as UI wizards in project.
```mermaid
classDiagram
class ProjectShareWizard
class ProjectTaskTypeDelete
```

View file

@ -0,0 +1,69 @@
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.
Designed to Fit Your Own Process
--------------------------------
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.
Easy to Use
-----------
Get organized as fast as you can think. The easy-to-use interface takes no time
to learn, and every action is instantaneous, so theres nothing standing
between you and your sweet productive flow.
Work Together
-------------
### Real-time chats, document sharing, email integration
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.
Talk to other users or customers with the website live chat feature.
Collaborative Writing
---------------------
### The power of etherpad, inside your tasks
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.
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 Work Done
-------------
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.
Timesheets, Contracts & Invoicing
---------------------------------
Projects are automatically integrated with customer contracts, allowing you to
invoice based on time & materials and record timesheets easily.
Track Issues
------------
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.

View file

@ -0,0 +1,43 @@
# -*- 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 import api, SUPERUSER_ID
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(cr, registry):
env = api.Environment(cr, SUPERUSER_ID, {})
_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(
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={project_task_stage_field_id}'
)
def _project_uninstall_hook(cr, registry):
"""Since the m2m table for the project share wizard's `partner_ids` field is not dropped at uninstall, it is
necessary to ensure it is emptied, else re-installing the module will fail due to foreign keys constraints."""
env = api.Environment(cr, SUPERUSER_ID, {})
env['project.share.wizard'].search([("partner_ids", "!=", False)]).partner_ids = False

View file

@ -0,0 +1,226 @@
# -*- 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_report_views.xml',
'report/project_task_burndown_chart_report_views.xml',
'views/analytic_views.xml',
'views/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_views.xml',
'views/project_milestone_views.xml',
'views/res_partner_views.xml',
'views/res_config_settings_views.xml',
'views/mail_activity_views.xml',
'views/project_sharing_views.xml',
'views/project_portal_templates.xml',
'views/project_task_templates.xml',
'views/project_sharing_templates.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',
],
'demo': [
'data/mail_template_demo.xml',
'data/project_demo.xml',
],
'installable': True,
'application': True,
'post_init_hook': '_project_post_init',
'uninstall_hook': '_project_uninstall_hook',
'assets': {
'web.assets_backend': [
'project/static/src/css/project.css',
'project/static/src/utils/**/*',
'project/static/src/services/**/*',
'project/static/src/components/**/*',
'project/static/src/views/**/*',
'project/static/src/js/project_activity.js',
'project/static/src/js/project_control_panel.js',
'project/static/src/js/project_graph_view.js',
'project/static/src/js/project_pivot_view.js',
'project/static/src/js/project_rating_graph_view.js',
'project/static/src/js/project_rating_pivot_view.js',
'project/static/src/js/project_task_kanban_examples.js',
'project/static/src/js/tours/project.js',
'project/static/src/js/widgets/*',
'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',
('include', 'web._assets_bootstrap'),
'base/static/src/css/modules.css',
'web/static/src/core/utils/transitions.scss',
'web/static/src/core/**/*',
'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/**/*',
('remove', 'web/static/src/views/fields/journal_dashboard_graph/**/*'), # only works with graph view in assets
'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_dialogs/**/*',
'web/static/src/views/widgets/**/*',
'web/static/src/webclient/**/*',
('remove', 'web/static/src/webclient/navbar/navbar.scss'), # already in assets_common
('remove', 'web/static/src/webclient/clickbot/clickbot.js'), # lazy loaded
('remove', 'web/static/src/views/form/button_box/*.scss'),
# 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',
'web/static/lib/jquery.scrollTo/jquery.scrollTo.js',
'web/static/lib/py.js/lib/py.js',
'web/static/lib/py.js/lib/py_extras.js',
'web/static/lib/jquery.ba-bbq/jquery.ba-bbq.js',
'web/static/src/legacy/scss/fields.scss',
'web/static/src/legacy/scss/views.scss',
'web/static/src/legacy/scss/form_view.scss',
'web/static/src/legacy/scss/list_view.scss',
'web/static/src/legacy/scss/kanban_dashboard.scss',
'web/static/src/legacy/scss/kanban_examples_dialog.scss',
'web/static/src/legacy/scss/kanban_column_progressbar.scss',
'web/static/src/legacy/scss/kanban_view.scss',
'base/static/src/scss/res_partner.scss',
# Form style should be computed before
'web/static/src/views/form/button_box/*.scss',
'web/static/src/legacy/action_adapters.js',
'web/static/src/legacy/debug_manager.js',
'web/static/src/legacy/legacy_service_provider.js',
'web/static/src/legacy/legacy_client_actions.js',
'web/static/src/legacy/legacy_dialog.js',
'web/static/src/legacy/legacy_load_views.js',
'web/static/src/legacy/legacy_promise_error_handler.js',
'web/static/src/legacy/legacy_rpc_error_handler.js',
'web/static/src/legacy/root_widget.js',
'web/static/src/legacy/legacy_setup.js',
'web/static/src/legacy/root_widget.js',
'web/static/src/legacy/backend_utils.js',
'web/static/src/legacy/utils.js',
'web/static/src/legacy/web_client.js',
'web/static/src/legacy/js/_deprecated/data.js',
'web/static/src/legacy/js/chrome/*',
'web/static/src/legacy/js/components/*',
'web/static/src/legacy/js/control_panel/*',
'web/static/src/legacy/js/core/domain.js',
'web/static/src/legacy/js/core/mvc.js',
'web/static/src/legacy/js/core/py_utils.js',
'web/static/src/legacy/js/core/context.js',
'web/static/src/legacy/js/core/misc.js',
'web/static/src/legacy/js/fields/abstract_field.js',
'web/static/src/legacy/js/fields/abstract_field_owl.js',
'web/static/src/legacy/js/_deprecated/basic_fields.js',
'web/static/src/legacy/js/fields/basic_fields.js',
'web/static/src/legacy/js/fields/basic_fields_owl.js',
'web/static/src/legacy/js/fields/field_utils.js',
'web/static/src/legacy/js/fields/relational_fields.js',
'web/static/src/legacy/js/fields/special_fields.js',
'web/static/src/legacy/js/fields/field_registry.js',
'web/static/src/legacy/js/fields/field_registry_owl.js',
'web/static/src/legacy/js/fields/field_utils.js',
'web/static/src/legacy/js/fields/field_wrapper.js',
'web/static/src/legacy/js/views/sample_server.js',
'web/static/src/legacy/js/views/abstract_model.js',
'web/static/src/legacy/js/views/basic/basic_model.js',
'web/static/src/legacy/js/views/action_model.js',
'web/static/src/legacy/js/views/view_utils.js',
'web/static/src/legacy/js/services/data_manager.js',
'web/static/src/legacy/js/services/session.js',
'web/static/src/legacy/js/tools/tools.js',
'web/static/src/legacy/js/views/**/*',
'web/static/src/legacy/js/widgets/data_export.js',
'web/static/src/legacy/js/widgets/date_picker.js',
'web/static/src/legacy/js/widgets/domain_selector_dialog.js',
'web/static/src/legacy/js/widgets/domain_selector.js',
'web/static/src/legacy/js/widgets/model_field_selector.js',
'web/static/src/legacy/js/widgets/model_field_selector_popover.js',
'web/static/src/legacy/js/env.js',
'web/static/src/legacy/js/model.js',
'web/static/src/legacy/js/owl_compatibility.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/wysiwyg/dialog.js',
'web_editor/static/src/js/frontend/loader.js',
'web_editor/static/src/js/backend/**/*',
'web_editor/static/src/xml/backend.xml',
'mail/static/src/scss/variables/*.scss',
'mail/static/src/widgets/**/*.scss',
'project/static/src/components/project_task_name_with_subtask_count_char_field/*',
'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',
'web/static/src/legacy/legacy_setup.js',
],
},
'license': 'LGPL-3',
}

View file

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

View file

@ -0,0 +1,530 @@
# 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))
# 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',
)
# default value is set to 'project' in _prepare_tasks_values, so we have to set it to 'none' here.
if not groupby:
values['groupby'] = 'none'
return self._get_page_view_values(project, access_token, values, 'my_projects_history', False, **kwargs)
def _prepare_project_domain(self):
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 or sortby not in searchbar_sortings:
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)
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 _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 = project.company_id
session_info.update(
cache_hashes=cache_hashes,
action_name='project.project_sharing_project_task_action',
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': [],
}
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):
values = {
'date': {'label': _('Newest'), 'order': 'create_date desc', 'sequence': 1},
'name': {'label': _('Title'), 'order': 'name', 'sequence': 2},
'project': {'label': _('Project'), 'order': 'project_id, stage_id', 'sequence': 3},
'stage': {'label': _('Stage'), 'order': 'stage_id, project_id', 'sequence': 5},
'status': {'label': _('Status'), 'order': 'kanban_state', 'sequence': 6},
'priority': {'label': _('Priority'), 'order': 'priority desc', 'sequence': 8},
'date_deadline': {'label': _('Deadline'), 'order': 'date_deadline asc', 'sequence': 9},
'update': {'label': _('Last Stage Update'), 'order': 'date_last_stage_update desc', 'sequence': 11},
}
if milestones_allowed:
values['milestone'] = {'label': _('Milestone'), 'order': 'milestone_id', 'sequence': 7}
return values
def _task_get_searchbar_groupby(self, milestones_allowed):
values = {
'none': {'input': 'none', 'label': _('None'), 'order': 1},
'project': {'input': 'project', 'label': _('Project'), 'order': 2},
'stage': {'input': 'stage', 'label': _('Stage'), 'order': 4},
'status': {'input': 'status', 'label': _('Status'), 'order': 5},
'priority': {'input': 'priority', 'label': _('Priority'), 'order': 7},
'customer': {'input': 'customer', 'label': _('Customer'), 'order': 10},
}
if milestones_allowed:
values['milestone'] = {'input': 'milestone', 'label': _('Milestone'), 'order': 6}
return dict(sorted(values.items(), key=lambda item: item[1]["order"]))
def _task_get_groupby_mapping(self):
return {
'project': 'project_id',
'stage': 'stage_id',
'customer': 'partner_id',
'milestone': 'milestone_id',
'priority': 'priority',
'status': 'kanban_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):
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},
'project': {'input': 'project', 'label': _('Search in Project'), 'order': 2},
'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},
'message': {'input': 'message', 'label': _('Search in Messages'), 'order': 11},
}
if milestones_allowed:
values['milestone'] = {'input': 'milestone', 'label': _('Search in Milestone'), 'order': 6}
return dict(sorted(values.items(), key=lambda item: item[1]["order"]))
def _task_get_search_domain(self, search_in, search):
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'):
search_domain.append([
('kanban_state', 'ilike', 'normal' if search == 'In Progress' else 'done' if search == 'Ready' else 'blocked' if search == 'Blocked' else 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):
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).items(),
key=lambda item: item[1]["sequence"]))
searchbar_inputs = self._task_get_searchbar_inputs(milestones_allowed)
searchbar_groupby = self._task_get_searchbar_groupby(milestones_allowed)
if not domain:
domain = []
if not su and Task.check_access_rights('read'):
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 not in searchbar_sortings 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['kanban_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.kanban_state))
else:
grouped_tasks.sort(key=lambda tasks: task_states.get(tasks[0].kanban_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'], ['project_id'])
for group in project_groups:
proj_id = group['project_id'][0] if group['project_id'] else False
proj_name = group['project_id'][1] if group['project_id'] else _('Others')
searchbar_filters.update({
str(proj_id): {'label': proj_name, 'domain': [('project_id', '=', proj_id)]}
})
return searchbar_filters
@http.route(['/my/tasks', '/my/tasks/page/<int:page>'], type='http', auth="user", website=True)
def portal_my_tasks(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', groupby=None, **kw):
searchbar_filters = self._get_my_tasks_searchbar_filters()
if not filterby:
filterby = 'all'
domain = searchbar_filters.get(filterby, searchbar_filters.get('all'))['domain']
values = self._prepare_tasks_values(page, date_begin, date_end, sortby, search, search_in, groupby, domain=domain)
# pager
pager_vals = values['pager']
pager_vals['url_args'].update(filterby=filterby)
pager = portal_pager(**pager_vals)
values.update({
'grouped_tasks': values['grouped_tasks'](pager['offset']),
'pager': pager,
'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())),
'filterby': filterby,
})
return request.render("project.portal_my_tasks", values)
def _show_task_report(self, task_sudo, report_type, download):
# This method is to be overriden to report timesheets if the module is installed.
# The route should not be called if at least hr_timesheet is not installed
raise MissingError(_('There is nothing to report.'))
@http.route(['/my/tasks/<int:task_id>'], type='http', auth="public", website=True)
def portal_my_task(self, task_id, report_type=None, access_token=None, project_sharing=False, **kw):
try:
task_sudo = self._document_check_access('project.task', task_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
if report_type in ('pdf', 'html', 'text'):
return self._show_task_report(task_sudo, report_type, download=kw.get('download'))
# ensure attachment are accessible with access token inside template
for attachment in task_sudo.attachment_ids:
attachment.generate_access_token()
if project_sharing is True:
# Then the user arrives to the stat button shown in form view of project.task and the portal user can see only 1 task
# so the history should be reset.
request.session['my_tasks_history'] = task_sudo.ids
values = self._task_get_page_view_values(task_sudo, access_token, **kw)
return request.render("project.portal_my_task", values)

View file

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

View file

@ -0,0 +1,41 @@
<?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: Customize tasks and stages according to the project</field>
<field name="sequence">1200</field>
<field name="group_id" ref="project.group_project_manager"/>
<field name="tip_description" type="html">
<div>
<p class="tip_title">Tip: Customize tasks and stages according to the project</p>
<p class="tip_content">Customize how tasks are named according to the project and create tailor made status messages for each step of the workflow. It helps to document your workflow: what should be done at which step.</p>
<img src="https://download.odoocdn.com/digests/project/static/src/img/project-custom-tasks.gif" 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)], limit=1, order='sequence asc')"/>
<p class="tip_title">Tip: Create tasks from incoming emails</p>
<t t-if="project_record and project_record.alias_domain">
<p class="tip_content">Emails sent to <a t-attf-href="mailto:{{project_record.alias_value}}" target="_blank" style="color: #875a7b; text-decoration: none;"><t t-out="project_record.alias_value" /></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>

View file

@ -0,0 +1,21 @@
<?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>
<record id="ir_cron_recurring_tasks" model="ir.cron">
<field name="name">Project: Create Recurring Tasks</field>
<field name="model_id" ref="project.model_project_task_recurrence"/>
<field name="state">code</field>
<field name="code">model._cron_create_recurring_tasks()</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="nextcall" eval="(DateTime.now().replace(hour=3, minute=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')" />
</record>
</odoo>

View file

@ -0,0 +1,119 @@
<?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>
<record id="mt_task_blocked" model="mail.message.subtype">
<field name="name">Task Blocked</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
<field name="description">Task blocked</field>
</record>
<record id="mt_task_ready" model="mail.message.subtype">
<field name="name">Task Ready</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
<field name="description">Task ready for Next Stage</field>
</record>
<record id="mt_task_progress" model="mail.message.subtype">
<field name="name">Task in Progress</field>
<field name="res_model">project.task</field>
<field name="default" eval="False"/>
</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="default" eval="False"/>
</record>
<record id="mt_task_dependency_change" model="mail.message.subtype">
<field name="name">Task Dependency Changes</field>
<field name="res_model">project.task</field>
<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_blocked" model="mail.message.subtype">
<field name="name">Task Blocked</field>
<field name="sequence">11</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_blocked"/>
<field name="relation_field">project_id</field>
</record>
<record id="mt_project_task_ready" model="mail.message.subtype">
<field name="name">Task Ready</field>
<field name="sequence">12</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_ready"/>
<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">13</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">14</field>
<field name="res_model">project.project</field>
<field name="default" eval="True"/>
<field name="parent_id" ref="mt_task_rating"/>
<field name="relation_field">project_id</field>
</record>
<record id="mt_project_task_dependency_change" model="mail.message.subtype">
<field name="name">Task Dependency Changes</field>
<field name="sequence">15</field>
<field name="res_model">project.project</field>
<field name="default" eval="False"/>
<field name="parent_id" ref="mt_task_dependency_change"/>
<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">16</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>
</odoo>

View file

@ -0,0 +1,111 @@
<?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="subject">{{ object.project_id.company_id.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 task "<strong t-out="object.name or ''">Planning and budget</strong>"
<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 service</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 to improve continuously.
<t t-if="object.project_id.rating_status == 'stage'">
<br/><br/><span style="margin: 0px 0px 0px 0px; font-size: 12px; opacity: 0.5; color: #454748;">This customer survey has been sent because your task has been moved to the stage <b t-out="object.stage_id.name or ''">In progress</b></span>
</t>
<t t-if="object.project_id.rating_status == 'periodic'">
<br/><span style="margin: 0px 0px 0px 0px; font-size: 12px; opacity: 0.5; color: #454748;">This customer 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>
</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">
<span>Dear <t t-esc="assignee_name"/>,</span>
<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>
<br/>
</template>
</data>
</odoo>

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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