[ADD] todo, note, *: replace Notes with To-do

- Impacted modules:
    - todo (new module)
    - note (deprecated app)
    - project
    - test_discuss_full (adding a dependency)

- Main changes:
    - The Notes app is deprecated
    - A To-do app is created to replace it
    - The To-do app uses the model project.task (from module project)
      for to-dos. To-dos are equivalent to private tasks (tasks without
      project) with no parent task set.
    - Private tasks/to-dos can either be displayed in the My Tasks menu
      of the Project app or in the To-do app that proposes simplified
      views to edit them.

- Details:
    - depracation of Notes: Before the module note can be fully removed,
      some changes still need to be done in other modules that use its
      models (mainly note.note model). This commit deprecate the Notes
      app in the meantime by removing it from the main menu/dashboard
      and by replacing the creation of notes directly from the systray
      by the creation of private tasks (to-dos).
    - activity: In the systray, it is currently not possible to
      segregate records using the same model. Therefore to-dos will
      appear in the task section. This will change in the future when
      this possibility will be implemented in mail.
    - name generation: When edited from To-do, a name is auto-genrated
      for private tasks. This behavior is similar to the one existing in
      notes with a few adaptation to Todo UX. When a to-do is untitled
      and saved (through the save button or when leaving the form view),
      a name is generated based on the first line of the description of
      this to-do. If the generated name is longer than 100 characters it
      is truncated.
    - security:
        - project.task model need to be accessible in all crud modes to
          all internal users as it is used in the to-do module. This is
          a big change compared to the previous behavior where only
          project users could edit tasks.
        - project.tags model access is way more permisive now. All users
          have access to edition/delition of tags as they are use in the
          to-do app.
        - Record rules: A similar behavior as the current one is
          implemented, i.e:
            - project users can access all tasks (with edition rights)
              verifying one of the following conditions:
                - they follow the task
                - they follow the project the task belongs to
                - they are assigned to the task
                - the project the task belongs to is accessible for all
                  internal users.
            - internal users have only read-access to the non-private
              tasks shared with them.
        - Those changes related to security only occurs when the app
          To-do is installed. When it is note, the current behavior of
          Project security-wise is kept unchanged.

Note on this last point: As the write access of internal users to
project.task is now managed with record rules, the form of this model
won't be displayed in read-only anymore when To-do is installed. This
will results in access error message for the tasks for which the write
access is restricted. Unfortunatelly, there is no better solution at the
moment as neither restricted read access, nor extendent write access for
internal users are desired. An improvement of the framework to deal with
such situation could be benificial.

task-3085077
This commit is contained in:
Hugo Carlier (Huca)
2023-05-15 09:01:27 +02:00
parent 79b5e779b1
commit bc8332808f
34 changed files with 534 additions and 182 deletions
+1 -2
View File
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import models
-4
View File
@@ -14,7 +14,6 @@
'data': [
'security/note_security.xml',
'security/ir.model.access.csv',
'data/mail_activity_type_data.xml',
'data/note_data.xml',
'data/res_users_data.xml',
'views/note_views.xml',
@@ -28,9 +27,6 @@
'web.assets_backend': [
'note/static/src/**/*',
],
'web.qunit_suite_tests': [
'note/static/tests/**/*',
],
},
'license': 'LGPL-3',
}
-20
View File
@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
class NoteController(http.Controller):
@http.route('/note/new', type='json', auth='user')
def note_new_from_systray(self, note, activity_type_id=None, date_deadline=None):
""" Route to create note and their activity directly from the systray """
note = request.env['note.note'].create({'memo': note})
if date_deadline:
note.activity_schedule(
activity_type_id=activity_type_id or request.env['mail.activity.type'].search([('category', '=', 'reminder')], limit=1).id,
note=note.memo,
date_deadline=date_deadline
)
return note.id
-2
View File
@@ -1,7 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mail_activity
from . import mail_activity_type
from . import note
from . import res_users
-10
View File
@@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class MailActivity(models.Model):
_inherit = "mail.activity"
note_id = fields.Many2one('note.note', string="Related Note", ondelete='cascade')
-27
View File
@@ -45,30 +45,3 @@ GROUP BY id"""
stage.sudo().copy(default={'user_id': user.id})
else:
_logger.debug("Created note columns for %s", self)
@api.model
def systray_get_activities(self):
""" If user have not scheduled any note, it will not appear in activity menu.
Making note activity always visible with number of notes on label. If there is no notes,
activity menu not visible for note.
"""
activities = super(Users, self).systray_get_activities()
notes_count = self.env['note.note'].search_count([('user_id', '=', self.env.uid)])
if notes_count:
note_index = next((index for (index, a) in enumerate(activities) if a["model"] == "note.note"), None)
note_label = _('Notes')
if note_index is not None:
activities[note_index]['name'] = note_label
else:
activities.append({
'id': self.env['ir.model']._get('note.note').id,
'type': 'activity',
'name': note_label,
'model': 'note.note',
'icon': modules.module.get_module_icon(self.env['note.note']._original_module),
'total_count': 0,
'today_count': 0,
'overdue_count': 0,
'planned_count': 0
})
return activities
@@ -1,37 +0,0 @@
/** @odoo-module **/
// ensure mail override is applied first.
import '@mail/../tests/helpers/mock_server/models/res_users';
import { patch } from '@web/core/utils/patch';
import { MockServer } from '@web/../tests/helpers/mock_server';
patch(MockServer.prototype, 'note/models/res_users', {
/**
* Simulates `systray_get_activities` on `res.users`.
*
* @override
*/
_mockResUsersSystrayGetActivities() {
const activities = this._super(...arguments);
const noteCount = this.pyEnv['note.note'].searchCount([['user_id', '=', this.currentUserId]]);
if (noteCount) {
const noteIndex = activities.findIndex(act => act['model'] === 'note.note');
if (noteIndex) {
activities[noteIndex]['name'] = 'Notes';
} else {
activities.push({
id: 'note.note', // for simplicity
type: 'activity',
name: 'Notes',
model: 'note.note',
planned_count: 0,
today_count: 0,
overdue_count: 0,
total_count: 0,
});
}
}
return activities;
},
});
+3 -23
View File
@@ -171,6 +171,9 @@
</div>
<field name="stage_id" domain="[('user_id','=',uid)]" widget="statusbar" options="{'clickable': '1'}"/>
</header>
<div class="alert alert-warning oe_edit_only mb-0" role="status">
<p>The Notes app is deprecated and has been replaced by Todo. All your existing notes have been converted to to-dos that you will find in this new app. The Notes app will be removed in the future. It is therefore adviced to not use it anymore as all the notes that would be created/modified in it will be lost during future upgrade.</p>
</div>
<sheet>
<field name="memo" type="html" class="oe_memo" default_focus="1" options="{'resizable': false, 'collaborative': true}"/>
</sheet>
@@ -225,27 +228,4 @@
</field>
</record>
<menuitem
id="menu_note_notes"
name="Notes"
sequence="15"
action="note.action_note_note"
web_icon="note,static/description/icon.svg">
<menuitem
id="menu_note_configuration"
name="Configuration"
sequence="100"
groups="base.group_no_one">
<menuitem
id="menu_notes_stage"
name="Stages"
action="note.action_note_stage"
sequence="21"/>
<menuitem
id="notes_tag_menu"
action="note_tag_action"
sequence="22"/>
</menuitem>
</menuitem>
</odoo>
+2 -1
View File
@@ -91,7 +91,8 @@ class Task(models.Model):
@api.model
def _default_personal_stage_type_id(self):
return self.env['project.task.type'].search([('user_id', '=', self.env.user.id)], limit=1).id
default_id = self.env.context.get('default_personal_stage_type_ids')
return (default_id or self.env['project.task.type'].search([('user_id', '=', self.env.user.id)], limit=1).ids or [False])[0]
@api.model
def _default_company_id(self):
+1
View File
@@ -17,6 +17,7 @@
'mail',
'mail_bot',
'note',
'project_todo',
'website_livechat',
],
'installable': True,
+11
View File
@@ -0,0 +1,11 @@
from . import controllers
from . import models
def _todo_uninstall(env):
# The record rule project.task_visibility_rule needs to apply to all rights and not just read after uninstallation
project_task_visibility_rule_rec = env.ref("project.task_visibility_rule")
project_task_visibility_rule_rec.write({
'perm_create': True,
'perm_unlink': True,
'perm_write': True,
})
+35
View File
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'To-Do',
'version': '1.0',
'category': 'Productivity/To-Do',
'summary': 'Organize your work with memos and to-do lists',
'sequence': 260,
'depends': [
'project',
],
'auto_install': True,
'data': [
'security/ir.model.access.csv',
'security/todo_security.xml',
'data/mail_activity_type_data.xml',
'views/project_task_views.xml',
'views/todo_menus.xml',
],
'installable': True,
'application': True,
'uninstall_hook': '_todo_uninstall',
'assets': {
'web.assets_backend': [
'todo/static/src/scss/todo.scss',
'todo/static/src/views/**/*',
'todo/static/src/web/**/*',
],
'web.qunit_suite_tests': [
'todo/static/tests/**/*',
],
},
'license': 'LGPL-3',
}
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import note
from . import todo
+18
View File
@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import Controller, request, route
class TodoController(Controller):
@route('/project_todo/new', type='json', auth='user')
def todo_new_from_systray(self, todo_description, activity_type_id=None, date_deadline=None):
""" Route to create todo and their activity directly from the systray """
todo = request.env['project.task'].create({'description': todo_description})
if date_deadline:
todo.activity_schedule(
activity_type_id=activity_type_id or request.env['mail.activity.type'].search([('category', '=', 'reminder')], limit=1).id,
note=todo.description,
date_deadline=date_deadline
)
return todo.id
+2
View File
@@ -0,0 +1,2 @@
from . import mail_activity_type
from . import project_task
+21
View File
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, _
from odoo.tools import html2plaintext
class Task(models.Model):
_inherit = 'project.task'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') and not vals.get('project_id') and not vals.get('parent_id'):
if vals.get('description'):
# Generating name from first line of the description
text = html2plaintext(vals['description'])
name = text.strip().replace('*', '').partition("\n")[0]
vals['name'] = (name[:97] + '...') if len(name) > 100 else name
else:
vals['name'] = _('Untitled to-do')
return super().create(vals_list)
+4
View File
@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_project_task_type_user,project.task.type.user,project.model_project_task_type,base.group_user,1,1,1,1
access_task_on_partner,project.task on partners,project.model_project_task,base.group_user,1,1,1,1
access_project_tags_user,project.project_tags_user,project.model_project_tags,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_project_task_type_user project.task.type.user project.model_project_task_type base.group_user 1 1 1 1
3 access_task_on_partner project.task on partners project.model_project_task base.group_user 1 1 1 1
4 access_project_tags_user project.project_tags_user project.model_project_tags base.group_user 1 1 1 1
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record model="ir.rule" id="project.task_visibility_rule">
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="groups" eval="[(4,ref('base.group_user'))]"/>
</record>
<record model="ir.rule" id="task_edition_rule_internal">
<field name="name">Project/Task: employees: Full access to own private task only</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="domain_force">[('project_id', '=', False), ('user_ids', 'in', user.id), ('parent_id', '=', False)]</field>
<field name="groups" eval="[(4,ref('base.group_user'))]"/>
</record>
<record model="ir.rule" id="task_visibility_rule_project_user">
<field name="name">Project/Task: project users: follow required for follower-only projects</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="domain_force">[
'|',
'&amp;',
('project_id', '!=', False),
'|',
('project_id.privacy_visibility', '!=', 'followers'),
('project_id.message_partner_ids', 'in', [user.partner_id.id]),
'|',
('message_partner_ids', 'in', [user.partner_id.id]),
# to subscribe check access to the record, follower is not enough at creation
('user_ids', 'in', user.id)
]</field>
<field name="groups" eval="[(4,ref('project.group_project_user'))]"/>
</record>
</data>
</odoo>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg width="50" height="50" xmlns="http://www.w3.org/2000/svg"><path d="M10 41h36a3 3 0 0 1-3 3H10v-3Z" fill="var(--o-icons-teal-pure, #00CEB3)"/><path d="M10 41h36a3 3 0 0 1-3 3H10v-3Z" fill="url(#o_icon_note__a)" fill-opacity=".2"/><path d="M9 41h19c0 1.657-1.418 3-3.167 3H9v-3Z" fill="var(--o-icons-teal-teal, #00A079)"/><path d="M9 41h19c0 1.657-1.418 3-3.167 3H9v-3Z" fill="url(#o_icon_note__b)" fill-opacity=".2"/><path d="M40.706 7.282a4.35 4.35 0 0 1 0 6.193L9.906 44H4v-6.192L34.457 7.282a4.447 4.447 0 0 1 6.249 0Z" fill="var(--o-icons-purple-teal, #003458)"/><path d="M40.706 7.282a4.35 4.35 0 0 1 0 6.193L9.906 44H4v-6.192L34.457 7.282a4.447 4.447 0 0 1 6.249 0Z" fill="url(#o_icon_note__c)" fill-opacity=".3"/><defs><linearGradient id="o_icon_note__a" x1="10" y1="41" x2="14.5" y2="54.5" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient id="o_icon_note__b" x1="9" y1="41" x2="15.772" y2="51.723" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient id="o_icon_note__c" x1="4" y1="6" x2="48.706" y2="17.177" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+40
View File
@@ -0,0 +1,40 @@
.o_todo_form_view {
.o_form_sheet_bg {
display: flex;
flex-direction: column;
.o_form_statusbar {
margin: 0;
.o_field_widget {
align-self: center !important;
}
}
.o_form_sheet {
flex: 1;
max-width: 100%;
.oe_pad {
flex: 1;
margin-bottom: 0;
display: flex;
flex-direction: column;
.oe_pad_content {
border: none;
flex: 1;
}
}
}
.o_field_html .note-editable {
border: none !important;
padding: $o-sheet-vpadding $o-horizontal-padding 10px !important;
min-height: 300px;
}
}
&.o_form_readonly {
.oe_description {
padding: $o-sheet-vpadding $o-horizontal-padding;
}
}
}
.o_todo_kanban_card_body {
padding-left: 9px;
}
@@ -0,0 +1,11 @@
.o_form_view.o_xxl_form_view {
.o_todo_form_sheet_bg {
> .o_form_statusbar, > .alert {
margin-left: 0;
margin-right: 0;
}
}
}
@@ -7,63 +7,68 @@ import { DateTimeInput } from "@web/core/datetime/datetime_input";
import { useService } from "@web/core/utils/hooks";
import { patch } from "@web/core/utils/patch";
patch(ActivityMenu.prototype, "note", {
patch(ActivityMenu.prototype, "todo", {
setup() {
this._super(...arguments);
this.rpc = useService("rpc");
this.state = useState({ addingNote: false });
this.noteInputRef = useRef("noteInput");
this.addingNoteDate = false;
this.state = useState({ addingTodo: false });
this.todoInputRef = useRef("todoInput");
this.addingTodoDate = false;
this.notification = useService("notification");
useEffect(
(addingNote) => {
if (addingNote) {
this.noteInputRef.el.focus();
(addingTodo) => {
if (addingTodo) {
this.todoInputRef.el.focus();
}
},
() => [this.state.addingNote]
() => [this.state.addingTodo]
);
onExternalClick("noteInput", (ev) => {
onExternalClick("todoInput", (ev) => {
if (
ev.target.closest(".o-mail-ActivityMenu-show") ||
ev.target.closest(".o_datetime_picker")
) {
return;
}
this.state.addingNote = false;
this.state.addingTodo = false;
});
},
sortActivityGroups() {
this._super();
this.store.activityGroups.sort((g1, g2) => {
if (g1.model === "note.note" ? true : false) {
if (g1.model === "project.task" ? true : false) {
return -1;
}
if (g2.model === "note.note" ? true : false) {
if (g2.model === "project.task" ? true : false) {
return 1;
}
});
},
onKeydownNoteInput(ev) {
onKeydownTodoInput(ev) {
if (ev.key === "Enter") {
this.saveNote();
this.saveTodo();
}
},
async saveNote() {
async saveTodo() {
const { DateTime } = luxon;
const urlRegExp = /http(s)?:\/\/(www\.)?[a-zA-Z0-9@:%_+~#=~#?&/=\-;!.]{3,2000}/g;
const note = this.noteInputRef.el.value.replace(urlRegExp, '<a href="$&">$&</a>').trim();
if (!note) {
const todo = this.todoInputRef.el.value.replace(urlRegExp, '<a href="$&">$&</a>').trim();
if (!todo) {
return;
}
await this.rpc("/note/new", {
note: note,
date_deadline: this.addingNoteDate ? this.addingNoteDate : DateTime.local(),
await this.rpc("/project_todo/new", {
todo_description: todo,
date_deadline: this.addingTodoDate ? this.addingTodoDate : DateTime.local(),
});
this.state.addingNote = false;
this.fetchSystrayActivities();
document.body.click(); // hack to close dropdown
this.notification.add(
this.env._t("Your to-do has been successfully added to your tasks and scheduled for completion."),
{ type: "success" },
);
this.state.addingTodo = false;
},
});
@@ -2,26 +2,26 @@
<templates xml:space="preserve">
<t t-inherit="mail.ActivityMenu" t-inherit-mode="extension">
<xpath expr="//*[@name='activityGroupLoop']" position="after">
<div t-if="!state.addingNote" class="d-grid" t-on-click="() => this.state.addingNote = true">
<button type="button" class="btn text-center" t-on-click="() => this.state.addingNote = true">Add new note</button>
<div t-if="!state.addingTodo" class="d-grid" t-on-click="() => this.state.addingTodo = true">
<button type="button" class="btn text-center" t-on-click="() => this.state.addingTodo = true">Add a To-do</button>
</div>
<div t-if="state.addingNote" class="o-mail-ActivityMenu-show d-flex p-2 border-bottom">
<img src="/note/static/description/icon.svg" alt="Addnote"/>
<div t-if="state.addingTodo" class="o-mail-ActivityMenu-show d-flex p-2 border-bottom">
<img src="/todo/static/description/icon.svg" alt="AddTodo"/>
<div class="flex-grow-1">
<div class="d-flex px-2">
<span class="o-mail-ActivityMenu-text flex-grow-1"><strong>Add a note</strong></span>
<span class="o-mail-ActivityMenu-text flex-grow-1"><strong>Add a To-do</strong></span>
<DateTimeInput
type="'date'"
value="addingNoteDate"
onChange="(value) => (this.addingNoteDate = value || false)"
value="addingTodoDate"
onChange="(value) => (this.addingTodoDate = value || false)"
placeholder="'Today'"
/>
</div>
<div class="d-flex">
<input class="o-mail-ActivityMenu-input py-0 px-2 border-0" type="text" placeholder="Remember..." t-on-keydown="onKeydownNoteInput" t-ref="noteInput"/>
<input class="o-mail-ActivityMenu-input py-0 px-2 border-0" type="text" placeholder="Reminder to..." t-on-keydown="onKeydownTodoInput" t-ref="todoInput"/>
<span class="flex-grow-1"/>
<span class="ml8 mr4">
<button type="button" class="o-mail-ActivityMenu-save fw-bold btn btn-link py-0 px-2" t-on-click="saveNote">SAVE</button>
<button type="button" class="o-mail-ActivityMenu-save fw-bold btn btn-link py-0 px-2" t-on-click="saveTodo">SAVE</button>
</span>
</div>
</div>
@@ -5,13 +5,13 @@ import { getFixture, editInput, patchDate } from "@web/../tests/helpers/utils";
let target;
QUnit.module("note activity menu", {
QUnit.module("todo activity menu", {
async beforeEach() {
target = getFixture();
},
});
QUnit.test("create note from activity menu without date", async function (assert) {
QUnit.test("create todo from activity menu without date", async function (assert) {
await start();
assert.containsOnce(target, ".o_menu_systray i[aria-label='Activities']");
assert.containsNone(target, ".o-mail-ActivityMenu-counter");
@@ -20,28 +20,34 @@ QUnit.test("create note from activity menu without date", async function (assert
target,
".o-mail-ActivityMenu:contains(Congratulations, you're done with your activities.)"
);
assert.containsOnce(target, "button:contains(Add new note)");
await click("button:contains(Add new note)");
assert.containsNone(target, "button:contains(Add new note)");
assert.containsOnce(target, "button:contains(Add a To-do)");
await click("button:contains(Add a To-do)");
assert.containsNone(target, "button:contains(Add a To-do)");
assert.containsOnce(target, ".o-mail-ActivityMenu-show");
await editInput(target, "input.o-mail-ActivityMenu-input", "New Note");
await editInput(target, "input.o-mail-ActivityMenu-input", "New To-do");
await click("button:contains(SAVE)");
// Need to reopen systray as it automatically closes when a todo is created
assert.containsOnce(target, ".o_menu_systray i[aria-label='Activities']");
await click(".o_menu_systray i[aria-label='Activities']");
assert.containsOnce(target, ".o-mail-ActivityMenu-counter:contains(1)");
assert.containsOnce(target, ".o-mail-ActivityGroup");
assert.containsOnce(target, ".o-mail-ActivityGroup:contains(note.note)");
assert.containsOnce(target, ".o-mail-ActivityGroup:contains(project.task)");
assert.containsOnce(target, "button:contains(1 Today)");
assert.containsOnce(target, "button:contains(Add new note)");
assert.containsOnce(target, "button:contains(Add a To-do)");
assert.containsNone(target, ".o-mail-ActivityMenu-show");
assert.containsNone(target, ".o-mail-ActivityMenu-input");
await click("button:contains(Add new note)");
assert.containsNone(target, "button:contains(Add new note)");
await editInput(target, "input.o-mail-ActivityMenu-input", "New Note");
await click("button:contains(Add a To-do)");
assert.containsNone(target, "button:contains(Add a To-do)");
await editInput(target, "input.o-mail-ActivityMenu-input", "New To-do");
await click("button:contains(SAVE)");
// Need to reopen systray as it automatically closes when a todo is created
assert.containsOnce(target, ".o_menu_systray i[aria-label='Activities']");
await click(".o_menu_systray i[aria-label='Activities']");
assert.containsOnce(target, ".o-mail-ActivityMenu-counter:contains(2)");
assert.containsOnce(target, "button:contains(2 Today)");
});
QUnit.test("create note from activity menu with date", async function (assert) {
QUnit.test("create to-do from activity menu with date", async function (assert) {
patchDate(2023, 1, 1, 6, 0, 0);
const { DateTime } = luxon;
const today = DateTime.utc();
@@ -51,16 +57,19 @@ QUnit.test("create note from activity menu with date", async function (assert) {
assert.containsNone(target, ".o-mail-ActivityMenu-counter");
await click(".o_menu_systray i[aria-label='Activities']");
await click("button:contains(Add new note)");
await editInput(target, "input.o-mail-ActivityMenu-input", "New Note");
await click("button:contains(Add a To-do)");
await editInput(target, "input.o-mail-ActivityMenu-input", "New To-do");
await editInput(
target,
"input.o_datetime_input",
futureDay.toString(luxon.DateTime.DATE_SHORT)
);
await click("button:contains(SAVE)");
// Need to reopen systray as it automatically closes when a todo is created
assert.containsOnce(target, ".o_menu_systray i[aria-label='Activities']");
await click(".o_menu_systray i[aria-label='Activities']");
assert.containsOnce(target, ".o-mail-ActivityMenu-counter:contains(1)");
assert.containsOnce(target, ".o-mail-ActivityGroup");
assert.containsOnce(target, ".o-mail-ActivityGroup:contains(note.note)");
assert.containsOnce(target, ".o-mail-ActivityGroup:contains(project.task)");
assert.containsOnce(target, "button:contains(1 Future)");
});
@@ -5,7 +5,7 @@ import { MockServer } from '@web/../tests/helpers/mock_server';
import { date_to_str } from 'web.time';
patch(MockServer.prototype, 'note/controller/note', {
patch(MockServer.prototype, 'project_todo/controller/project_todo', {
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -14,8 +14,8 @@ patch(MockServer.prototype, 'note/controller/note', {
* @override
*/
async _performRPC(route, args) {
if (route === '/note/new') {
return this._mockRouteNoteNew(args);
if (route === '/project_todo/new') {
return this._mockRouteTodoNew(args);
}
return this._super(...arguments);
},
@@ -25,17 +25,18 @@ patch(MockServer.prototype, 'note/controller/note', {
//--------------------------------------------------------------------------
/**
* Simulates the `/note/new` route.
* Simulates the `/project_todo/new` route.
*
* @private
*/
_mockRouteNoteNew(values) {
const noteId = this.pyEnv['note.note'].create({ memo: values['note'] });
_mockRouteTodoNew(values) {
const taskId = this.pyEnv['project.task'].create({ description: values['todo_description'] });
if (values['date_deadline']) {
this.pyEnv['mail.activity'].create({
date_deadline: date_to_str(new Date(values['date_deadline'])),
note_id: noteId,
res_model: 'note.note',
note: values['todo_description'],
res_model: 'project.task',
res_id: taskId,
});
}
},
@@ -2,4 +2,4 @@
import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
addModelNamesToFetch(['note.note']);
addModelNamesToFetch(['project.task']);
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_access_rights
+20
View File
@@ -0,0 +1,20 @@
from odoo.tests.common import users
from odoo.exceptions import AccessError
from odoo.addons.project.tests.test_access_rights import TestAccessRights
class TestAccessRightsTodo(TestAccessRights):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.private_task = cls.env['project.task'].create({'name': 'OdooBot Private Task'})
@users('Internal user')
def test_internal_cannot_rud_private_task(self):
with self.assertRaises(AccessError):
self.private_task.with_user(self.env.user).write({'name': 'Test write'})
with self.assertRaises(AccessError):
self.private_task.with_user(self.env.user).unlink()
with self.assertRaises(AccessError):
self.private_task.with_user(self.env.user).read(['name'])
+244
View File
@@ -0,0 +1,244 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Todo Kanban view -->
<record id="project_task_view_todo_kanban" model="ir.ui.view">
<field name="name">project.task.kanban</field>
<field name="model">project.task</field>
<field name="priority">800</field>
<field name="arch" type="xml">
<kanban default_group_by="personal_stage_type_ids"
class="o_kanban_small_column"
on_create="quick_create"
quick_create_view="todo.project_task_view_todo_quick_create_form"
sample="1"
js_class="project_task_kanban"
default_order="state, priority desc, date_deadline asc, sequence, id desc">
<field name="color"/>
<field name="sequence"/>
<field name="name"/>
<field name="active"/>
<field name="description"/>
<field name="message_partner_ids"/>
<field name="activity_ids" />
<field name="activity_state" />
<field name="is_closed"/>
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}'/>
<templates>
<t t-name="kanban-menu">
<a role="menuitem" type="delete" class="dropdown-item">Delete</a>
<ul class="oe_kanban_colorpicker" data-field="color"/>
</t>
<t t-name="kanban-box">
<t t-set="todoHasAssignees" t-value="record.user_ids.raw_value.length &gt; 1"/>
<div t-attf-class="#{kanban_color(record.color.raw_value)} oe_kanban_global_click_edit oe_semantic_html_override oe_kanban_card">
<div class="oe_kanban_content">
<div class="o_kanban_record_top">
<div class="o_kanban_record_headings">
<div class="d-flex justify-content-start">
<div>
<field name="state" widget="todo_done_checkmark" options="{'hide_label': 1}"/>
</div>
<div t-att-class="record.is_closed.raw_value ? 'opacity-50' : ''">
<strong class="o_kanban_record_title align-middle">
<field name="name" widget="name_with_subtask_count"/>
</strong>
</div>
</div>
</div>
</div>
<div class="o_kanban_record_body o_todo_kanban_card_body" t-att-class="record.is_closed.raw_value ? 'opacity-50' : ''">
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left"/>
<div class="oe_kanban_bottom_right" t-att-class="record.is_closed.raw_value ? 'opacity-50' : ''">
<div t-attf-class="d-flex #{todoHasAssignees ? 'w-100 align-items-center justify-content-end' : 'align-items-end'}">
<div class="d-flex align-items-center me-2 mt-2">
<div t-if="todoHasAssignees">
<field name="user_ids" widget="many2many_avatar_user"/>
</div>
<field name="activity_ids" widget="kanban_activity"/>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- To-do Tree View -->
<record id="project_task_view_todo_tree" model="ir.ui.view">
<field name="name">project.task.todo.tree</field>
<field name="model">project.task</field>
<field name="arch" type="xml">
<tree string="To-dos"
default_group_by="personal_stage_type_ids"
editable="bottom"
multi_edit="1"
sample="1">
<field name="name"/>
<field name="user_ids" optional="show" widget="many2many_avatar_user" options="{'no_quick_create': True}"/>
<field name="tag_ids" optional="show" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
<field name="personal_stage_type_id" string="Stage" optional="hide"/>
<field name="activity_ids" optional="show" widget="list_activity"/>
</tree>
</field>
</record>
<!-- Todo Form view -->
<record id="project_task_view_todo_form" model="ir.ui.view">
<field name="name">project.task.view.todo.form</field>
<field name="model">project.task</field>
<field name="arch" type="xml">
<form string="To-do"
class="o_todo_form_view"
js_class="todo_form">
<header>
<div class="py-1 px-2 border rounded bg-view">
<field name="tag_ids"
widget="many2many_tags"
options="{'color_field': 'color', 'no_create_edit': True}"
class="me-2"
placeholder="Tags"/>
</div>
<div class="py-1 px-2 border rounded bg-view">
<field name="user_ids"
widget="many2many_avatar_user"
options="{'no_quick_create': True}"
placeholder="Assignees"/>
</div>
<field name="personal_stage_type_id"
domain="[('user_id','=',uid)]"
widget="statusbar"
options="{'clickable': '1', 'fold_field': 'fold'}"/>
<field name="active" invisible="1"/>
</header>
<sheet class="o_todo_form_sheet_bg">
<widget name="web_ribbon" class="todo_archived" title="Archived" bg_color="text-bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<field name="description" type="html" class="oe_description" default_focus="1" options="{'resizable': false, 'collaborative': true}"/>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- To-do Quick create form view -->
<record id="project_task_view_todo_quick_create_form" model="ir.ui.view">
<field name="name">project.task.view.todo.quick.create.todo</field>
<field name="model">project.task</field>
<field name="priority">1000</field>
<field name="arch" type="xml">
<form class="o_form_project_tasks">
<group>
<field name="name" string="To-do Title" placeholder="e.g. Send Invitations"/>
</group>
</form>
</field>
</record>
<!-- Todo Activity -->
<record id="project_task_view_todo_activity" model="ir.ui.view">
<field name="name">project.task.view.todo.activity</field>
<field name="model">project.task</field>
<field name="priority">1000</field>
<field name="arch" type="xml">
<activity string="To-dos" js_class="project_activity">
<field name="user_ids"/>
<templates>
<div class="w-100" t-name="activity-box" style="display: inline-grid; grid-template-columns: auto max-content;">
<div>
<span t-att-title="record.name.value">
<field name="name" display="full" class="w-100 text-truncate align-middle"/>
</span>
</div>
<field name="user_ids" widget="many2many_avatar_user"/>
</div>
</templates>
</activity>
</field>
</record>
<!-- Todo Search -->
<record id="project_task_view_todo_search" model="ir.ui.view">
<field name="name">project.task.view.todo.search</field>
<field name="model">project.task</field>
<field name="priority">1000</field>
<field name="arch" type="xml">
<search string="Todos">
<field name="name"/>
<field name="tag_ids"/>
<field name="user_ids"/>
<field name="personal_stage_type_ids" string="Stage"/>
<filter name="open_tasks" string="Open" domain="[('is_closed', '=', False)]"/>
<filter name="closed_tasks" string="Closed" domain="[('is_closed', '=', True)]"/>
<filter string="Closed On" name="closed_on" domain="[('is_closed', '=', True)]" date="date_last_stage_update"/>
<separator/>
<filter name="active_false" string="Archived" domain="[('active', '=', False)]"/>
<filter invisible="1" string="Late Activities" name="activities_overdue"
domain="[('my_activity_date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
help="Show all records which has next action date is before today"/>
<filter invisible="1" string="Today Activities" name="activities_today"
domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
<filter invisible="1" string="Future Activities" name="activities_upcoming_all"
domain="[('my_activity_date_deadline', '&gt;', context_today().strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Tags" name="tags" help="By assigned tags" context="{'group_by':'tag_ids'}"/>
<filter string="Assignees" name="user_ids" context="{'group_by': 'user_ids'}"/>
<filter string="Stage" name="stage" help="By personal stages" context="{'group_by':'personal_stage_type_ids'}"/>
</group>
</search>
</field>
</record>
<!-- Todo main action + view links-->
<record id="project_task_action_todo" model="ir.actions.act_window">
<field name="name">To-dos</field>
<field name="res_model">project.task</field>
<field name="domain">[('user_ids', 'in', [uid]), ('project_id', '=', False), ('parent_id', '=', False)]</field>
<field name="view_mode">kanban,form,tree,activity</field>
<field name="search_view_id" ref="project_task_view_todo_search"/>
<field name="context">{'search_default_open_tasks': 1, 'tree_view_ref': 'todo.project_task_view_todo_tree'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No to-do found. Let's create one!
</p><p>
Keep your work organized by using memos and to-do lists.
Your to-do items are private by default, but you can choose to share them with others by adding them as assignees.
</p>
</field>
</record>
<record model="ir.actions.act_window.view" id="project_task_action_todo_kanban_view">
<field name="view_mode">kanban</field>
<field name="view_id" ref="project_task_view_todo_kanban"/>
<field name="act_window_id" ref="project_task_action_todo"/>
</record>
<record model="ir.actions.act_window.view" id="project_task_action_todo_form_view">
<field name="view_mode">form</field>
<field name="view_id" ref="project_task_view_todo_form"/>
<field name="act_window_id" ref="project_task_action_todo"/>
</record>
<record model="ir.actions.act_window.view" id="project_task_action_todo_tree_view">
<field name="view_mode">tree</field>
<field name="view_id" ref="project_task_view_todo_tree"/>
<field name="act_window_id" ref="project_task_action_todo"/>
</record>
<record model="ir.actions.act_window.view" id="project_task_action_todo_activity_view">
<field name="view_mode">activity</field>
<field name="view_id" ref="project_task_view_todo_activity"/>
<field name="act_window_id" ref="project_task_action_todo"/>
</record>
</odoo>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<odoo>
<menuitem
id="menu_todo_todos"
name="To-do"
action="todo.project_task_action_todo"
web_icon="todo,static/description/icon.svg">
</menuitem>
</odoo>