Skip to content

[16.0] [ADD] project_required_field_by_stage: New module#1650

Merged
OCA-git-bot merged 4 commits intoOCA:16.0from
gfcapalbo:16.0-add-project_required_field_by_stage
Mar 5, 2026
Merged

[16.0] [ADD] project_required_field_by_stage: New module#1650
OCA-git-bot merged 4 commits intoOCA:16.0from
gfcapalbo:16.0-add-project_required_field_by_stage

Conversation

@gfcapalbo
Copy link
Copy Markdown
Contributor

reopening #1341 with original commits.
Added changes requested in that original MR-.

@gfcapalbo gfcapalbo force-pushed the 16.0-add-project_required_field_by_stage branch 2 times, most recently from f51f3e4 to 709ab1c Compare January 9, 2026 16:38
@gfcapalbo gfcapalbo force-pushed the 16.0-add-project_required_field_by_stage branch 3 times, most recently from d973054 to 60b2afd Compare January 12, 2026 16:05
@ddejong-therp
Copy link
Copy Markdown

Code LGTM.
I've tested it, and run the tests as well.
All looks good.

@UrzaBlock
Copy link
Copy Markdown

Tested its functionality on runboat, and it does what it's supposed to do: When you have added fields in the Required Field section of a stage, a task that gets put in that stage will have those fields highlighted as required.


@api.constrains("stage_id")
def _check_stage_id_(self):
for this in self:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I miss something, but I do not see why the inner loops are necessary. A project can only be in one stage, so why would this be wrong?

    @api.constrains("stage_id")
    def _check_stage_id_(self):
        for this in self:
            required_fields = this.stage_id.required_field_ids
            if not required_fields:
                continue
            for field in required_fields:
                if hasattr(this, field.name) and not getattr(this, field.name):
                    raise UserError(
                        _(  
                            "Field '%(field)s' is mandatory in stage '%(stage)s'."
                        )   
                        % ( 
                            {   
                                "field": field.display_name.split(" (")[0],
                                "stage": this.stage_id.display_name,
                            }   
                        )   
                    ) 

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes I agree, I see that stage_id is a Many2one field

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every record has one stage. we only need to check mandatory fields for that one stage, not all stages every time. thank you @NL66278 , i will simplify this , by removing the loop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed this. @NL66278

stages = this.stage_id.filtered(lambda x: x.required_field_ids) makes no sense. and some other filtering makes no sense either.

All we want in this costrains is to verify that on stage change all mandatory fields specified for that stage are there.

Added a fix commit, please re-check.

Copy link
Copy Markdown

@luisDIXMIT luisDIXMIT left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I remove required fields from a stage, it still requires those fields.

{
"name": "Project Required Field By Stage",
"summary": """
KMEE""",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a more detailed summary, please?

Copy link
Copy Markdown
Contributor Author

@gfcapalbo gfcapalbo Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. thank you for reviewing. if module is satisfactory please leave an approval.

@luisDIXMIT
Copy link
Copy Markdown

PR tittle must be "[16.0] [ADD] project_required_field_by_stage: New module"

@gfcapalbo gfcapalbo changed the title [ADD] Addon: project_required_field_by_stage [16.0] [ADD] project_required_field_by_stage: New module Feb 27, 2026
@gfcapalbo gfcapalbo force-pushed the 16.0-add-project_required_field_by_stage branch 3 times, most recently from 0631016 to a880627 Compare February 27, 2026 14:30
@@ -0,0 +1,3 @@
* KMEE (https://kmee.com.br/):

* Tiago Amaral
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enough enhancements to add yourself to contributors list

Comment on lines +28 to +39
if attrs:
if attrs.get("required"):
attrs["required"] = [
"|",
("stage_id", "in", stages_with_field.ids),
] + attrs["required"]
else:
attrs["required"] = [
("stage_id", "in", stages_with_field.ids)
]
else:
attrs["required"] = [("stage_id", "in", stages_with_field.ids)]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All this can be done much more concise (and without repeating three times the same condition added):
` ``
from odoo.osv.expression import OR # Obviously this at the top of the python file
...
required_domain = attrs.get("required", [])
attrs["required"] = OR([required_domain, [("stage_id", "in", stages_with_field.ids)]])

No `if` or `else` needed!

mandatory fields associated.""",
"version": "16.0.1.0.0",
"license": "AGPL-3",
"author": "KMEE,Odoo Community Association (OCA)",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is as yet no maintainer defined. Maybe you can add yourself as maintainer? This will give the possibility to merge stuff, even if you are not a PSC member for project and you will be notified of PR's. Of course this brings responsibility as well.

Copy link
Copy Markdown
Contributor

@alexey-pelykh alexey-pelykh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reviving this from #1341 and for the work on tests and the view cache key -- this is a useful module. A few things need attention before merge:

1. _get_view attrs logic -- apply NL66278's suggestion

As @NL66278 pointed out, the three-branch if/elif/else for building the required domain can be collapsed using odoo.osv.expression.OR:

from odoo.osv.expression import OR
...
required_domain = attrs.get("required", [])
attrs["required"] = OR([required_domain, [("stage_id", "in", stages_with_field.ids)]])

This removes all the branching and is the idiomatic Odoo way to combine domains.

2. _check_stage_id_ -- redundant search

this.stage_id.required_field_ids is already a recordset of ir.model.fields. The .sudo().search(["id", "in", ...ids]) re-queries the same records for no reason. Simplify to:

for field in this.stage_id.required_field_ids:

The .sudo() is also unnecessary here since you already have access to the stage through the task.

3. Falsy check fails on numeric zero

not getattr(this, field.name) treats 0 and 0.0 as empty. If someone marks an integer or float field as required and sets it to 0, the constraint will incorrectly reject it. Consider checking the field type: for relational fields check not value, for others check value is False (which is how Odoo represents "not set" for scalar fields).

4. View cache invalidation bug

@luisDIXMIT reported that removing required fields from a stage still enforces them. The _get_view_cache_key only includes field names but not which stages they belong to. If stage A requires user_ids and you remove it, but stage B also requires user_ids, the cache key is unchanged and the stale view is served. Include stage IDs in the cache key.

5. Minor items

  • import json as simplejson -- misleading alias, just use import json and json.dumps.
  • Method name _check_stage_id_ has a trailing underscore -- likely a typo. Rename to _check_required_fields_by_stage for clarity.
  • Missing "installable": True in manifest (OCA convention).

Overall the concept is solid and the test coverage is decent. Looking forward to the next iteration.

# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

import ast
import json as simplejson
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import json as simplejson is a misleading alias (simplejson was a different library). Just use import json and call json.dumps below.

)
for node in arch.xpath("//field[@name='%s']" % field.name):
attrs = ast.literal_eval(node.attrib.get("attrs", "{}"))
if attrs:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @NL66278 suggested -- this whole if/elif/else block can be replaced with:

from odoo.osv.expression import OR
...
required_domain = attrs.get("required", [])
attrs["required"] = OR([required_domain, [("stage_id", "in", stages_with_field.ids)]])

No branching needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes attrs , previously in node.attrib.get defaults to {} so it is safe to remove all branches.


@api.constrains("stage_id")
def _check_stage_id_(self):
for this in self:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing underscore on _check_stage_id_ looks like a typo. Consider _check_required_fields_by_stage which also better describes the intent.

def _check_stage_id_(self):
for this in self:
if this.stage_id.required_field_ids:
fields = (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This search is redundant -- this.stage_id.required_field_ids is already the ir.model.fields recordset you want. The .sudo().search(...) just re-fetches the same records. Replace the whole block with:

for field in this.stage_id.required_field_ids:

.search([("id", "in", this.stage_id.required_field_ids.ids)])
)
for field in fields:
if hasattr(this, field.name) and not getattr(this, field.name):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not getattr(this, field.name) is falsy for 0 and 0.0, so a numeric field set to zero would incorrectly fail validation. For scalar fields, Odoo uses False to mean "not set" -- check getattr(this, field.name) is False for non-relational types, or inspect field.ttype to pick the right emptiness check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Managed this in refactored is_empty function for clarity, where i check the ttype and do the appropriate check.

self.env["project.task.type"]
.search([("required_field_ids", "!=", False)])
.mapped("required_field_ids.name")
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache key only includes field names, not which stages they belong to. Removing a required field from one stage won't invalidate the cache if another stage still requires the same field name. Include stage IDs or write_date in the key to ensure proper invalidation on config changes.

@gfcapalbo gfcapalbo force-pushed the 16.0-add-project_required_field_by_stage branch 9 times, most recently from 3773f27 to 035ae86 Compare March 2, 2026 13:04
@gfcapalbo
Copy link
Copy Markdown
Contributor Author

gfcapalbo commented Mar 2, 2026

@alexey-pelykh @NL66278 all review comments and simplifications implemented specifically in commit 4f16056 .

Added a function that correctly evaluates what "not set" means for the different types of fields. Added tests to cover those code branches too and keep codeCov coverage percentage from dipping.
I made a choice: Adding booleans is not a noop operation, a boolean is always "set" ( true or false), so i semantically interpret booleanfield = false as "not set" and true as "set" i think is the right interpretation .

if i had made {boolean_field} = true or false = set that would had made adding booleans to restrictions on stages effectively useless.

@gfcapalbo gfcapalbo force-pushed the 16.0-add-project_required_field_by_stage branch 2 times, most recently from 067b7a3 to b67aa25 Compare March 2, 2026 13:23
@gfcapalbo gfcapalbo force-pushed the 16.0-add-project_required_field_by_stage branch from b67aa25 to 4f16056 Compare March 2, 2026 13:23
Copy link
Copy Markdown
Contributor

@alexey-pelykh alexey-pelykh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All review comments from the previous round have been addressed in commit 4f16056:

  1. import json as simplejson -- replaced with clean import json.
  2. _get_view attrs logic -- collapsed to OR([required_domain, ...]) as suggested by @NL66278.
  3. Redundant search removed -- constraint now iterates this.stage_id.required_field_ids directly.
  4. Method renamed -- _check_stage_id_ is now _check_required_fields_by_stage.
  5. Type-aware emptiness check -- new _is_empty() function correctly differentiates by field.ttype. The boolean interpretation (False = "not set") is reasonable for this use case.
  6. Cache key fix -- _get_view_cache_key now includes (stage.id, field.name) tuples, so config changes properly invalidate the cached view.
  7. "installable": True added to manifest.

Tests cover the constraint, view injection for multiple field types, and the OR domain merging. Looks good.

@OCA-git-bot
Copy link
Copy Markdown
Contributor

This PR has the approved label and has been created more than 5 days ago. It should therefore be ready to merge by a maintainer (or a PSC member if the concerned addon has no declared maintainer). 🤖

)
self.project_task_1.write(
{
"stage_id": self.project_task_type_2.id,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This write can be combined with the previous write, which actually would make the test stronger, as writing the required field for a stage together with the stage should succeed.

"name": "Project Stage 1",
"project_ids": [(4, self.project_project_1.id)],
}
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a suggestion for more concise code:

from odoo.fields import Command
...
required_field_names = [
    "planned_hours",  # integer
    "project_id",  # m20
    "user_ids",  # m2m
    "active",  # bool
]
....
"required_fields": [
    Command.link(self.env.ref("project.field_project_task__%s" % field_name).id)
    for field_name in required_field_names
]

@NL66278
Copy link
Copy Markdown

NL66278 commented Mar 4, 2026

@gfcapalbo Please squash your own commits into one.

@OCA-git-bot
Copy link
Copy Markdown
Contributor

On my way to merge this fine PR!
Prepared branch 16.0-ocabot-merge-pr-1650-by-leemannd-bump-nobump, awaiting test results.

@leemannd
Copy link
Copy Markdown
Contributor

leemannd commented Mar 5, 2026

@NL66278 I missed your comment before starting the merge of the module : /

@OCA-git-bot OCA-git-bot merged commit 9334bfc into OCA:16.0 Mar 5, 2026
7 checks passed
@OCA-git-bot
Copy link
Copy Markdown
Contributor

Congratulations, your PR was merged at 1486c9f. Thanks a lot for contributing to OCA. ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants