Skip to content

Commit 9bc1eed

Browse files
authored
Custom fields improvement (#862)
* [IMP] initial improvements * [FIX] UI * [IMP] UI and fields * [IMP] UI design output * [IMP] UI design output * [FIX] pre-commit * [ADD] tests and improve * [IMP] tests * [IMP] tests * [FIX] target type to readonly * [FIX] pre-commit
1 parent d66f6d7 commit 9bc1eed

File tree

15 files changed

+988
-133
lines changed

15 files changed

+988
-133
lines changed

spp_custom_field/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22

33
from . import models
4+
from . import tests

spp_custom_field/__manifest__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
"development_status": "Production/Stable",
1212
"maintainers": ["jeremi", "gonzalesedwin1123"],
1313
"depends": ["base", "g2p_registry_base"],
14-
"data": [],
14+
"data": [
15+
"security/ir.model.access.csv",
16+
"views/field_group_views.xml",
17+
],
1518
"assets": {},
1619
"demo": [],
1720
"images": [],
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22

3+
from . import field_group
34
from . import cus_partner
5+
from . import ir_model_fields

spp_custom_field/models/cus_partner.py

Lines changed: 145 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,55 @@ def create_field_element(self, div_element, model_field_id, is_ind=False):
5555
modifiers = {"readonly": True}
5656
new_field.set("modifiers", json.dumps(modifiers))
5757

58-
def _get_view(self, view_id=None, view_type="form", **options):
58+
def _group_fields_by_group(self, fields_list):
59+
"""
60+
Group fields by their field_group_id and sort by sequence.
61+
Returns a list of tuples (group_record, fields_in_group).
62+
Fields without a group are returned with group_record=None.
63+
"""
64+
from collections import defaultdict
65+
66+
grouped = defaultdict(list)
67+
68+
# Check if field_group_id exists on the model
69+
has_group_field = hasattr(fields_list[0], "field_group_id") if fields_list else False
70+
has_sequence_field = hasattr(fields_list[0], "sequence") if fields_list else False
71+
72+
for field in fields_list:
73+
group_id = None
74+
if has_group_field and field.field_group_id:
75+
group_id = field.field_group_id.id
76+
grouped[group_id].append(field)
77+
78+
# Sort fields within each group by sequence
79+
for group_id in grouped:
80+
if has_sequence_field:
81+
grouped[group_id] = sorted(grouped[group_id], key=lambda f: (f.sequence, f.field_description))
82+
else:
83+
grouped[group_id] = sorted(grouped[group_id], key=lambda f: f.field_description)
84+
85+
# Get group records and sort groups by sequence
86+
result = []
87+
group_ids = [gid for gid in grouped.keys() if gid is not None]
88+
if group_ids and "spp.custom.field.group" in self.env:
89+
try:
90+
group_records = self.env["spp.custom.field.group"].browse(group_ids)
91+
group_records = group_records.sorted(key=lambda g: g.sequence)
92+
for group in group_records:
93+
result.append((group, grouped[group.id]))
94+
except KeyError:
95+
# Model doesn't exist, treat all fields as ungrouped
96+
_logger.warning("spp.custom.field.group model not found, ignoring field groups")
97+
if None in grouped:
98+
result.append((None, grouped[None]))
99+
100+
# Add fields without a group at the end
101+
if None in grouped:
102+
result.append((None, grouped[None]))
103+
104+
return result
105+
106+
def _get_view(self, view_id=None, view_type="form", **options): # noqa: C901
59107
arch, view = super()._get_view(view_id, view_type, **options)
60108

61109
if view_type == "form":
@@ -69,7 +117,7 @@ def _get_view(self, view_id=None, view_type="form", **options):
69117

70118
model_fields_id = self.env["ir.model.fields"].search(
71119
[("model_id", "=", "res.partner")],
72-
order="ttype, field_description",
120+
order="sequence, ttype, field_description",
73121
)
74122
if basic_info_page:
75123
if action_id.context:
@@ -79,22 +127,109 @@ def _get_view(self, view_id=None, view_type="form", **options):
79127
custom_page = etree.Element("page", {"string": "Additional Details", "name": "additional_details"})
80128
indicators_page = etree.Element("page", {"string": "Indicators", "name": "indicators"})
81129

82-
custom_div = etree.SubElement(custom_page, "div", {"class": "row mt16 o_settings_container"})
83-
indicators_div = etree.SubElement(indicators_page, "div", {"class": "row mt16 o_settings_container"})
130+
# Separate custom and indicator fields
131+
custom_fields = []
132+
indicator_fields = []
133+
84134
for rec in model_fields_id:
85135
els = rec.name.split("_")
86136
if len(els) >= 3 and (els[2] == "grp" and not is_group or els[2] == "indv" and is_group):
87137
continue
88138

89139
if len(els) >= 2 and els[1] == "cst":
90-
self.create_field_element(custom_div, rec)
91-
140+
custom_fields.append(rec)
92141
elif len(els) >= 2 and els[1] == "ind":
93-
self.create_field_element(indicators_div, rec, is_ind=True)
94-
95-
if custom_div.getchildren():
142+
indicator_fields.append(rec)
143+
144+
# Process custom fields with grouping
145+
if custom_fields:
146+
grouped_custom_fields = self._group_fields_by_group(custom_fields)
147+
148+
# Create main container row for side-by-side layout
149+
main_row = etree.SubElement(custom_page, "div", {"class": "row"})
150+
151+
for group_record, fields_in_group in grouped_custom_fields:
152+
if group_record:
153+
# Create a half-width column for each group
154+
group_col = etree.SubElement(
155+
main_row,
156+
"div",
157+
{"class": "col-12 col-lg-6"},
158+
)
159+
# Add group label/title
160+
group_label_div = etree.SubElement(
161+
group_col,
162+
"div",
163+
{"class": "o_horizontal_separator mt-2 mb-3"},
164+
)
165+
group_label = etree.SubElement(
166+
group_label_div,
167+
"strong",
168+
)
169+
group_label.text = group_record.name
170+
# Create fields container
171+
group_div = etree.SubElement(group_col, "div", {"class": "row mt16 o_settings_container"})
172+
for field in fields_in_group:
173+
self.create_field_element(group_div, field)
174+
else:
175+
# Fields without a group go in full width at the bottom
176+
if not custom_page.xpath(".//div[@class='row mt16 o_settings_container o_no_group']"):
177+
custom_div = etree.SubElement(
178+
custom_page, "div", {"class": "row mt16 o_settings_container o_no_group"}
179+
)
180+
else:
181+
custom_div = custom_page.xpath(
182+
".//div[@class='row mt16 o_settings_container o_no_group']"
183+
)[0]
184+
for field in fields_in_group:
185+
self.create_field_element(custom_div, field)
186+
187+
# Process indicator fields with grouping
188+
if indicator_fields:
189+
grouped_indicator_fields = self._group_fields_by_group(indicator_fields)
190+
191+
# Create main container row for side-by-side layout
192+
main_row = etree.SubElement(indicators_page, "div", {"class": "row"})
193+
194+
for group_record, fields_in_group in grouped_indicator_fields:
195+
if group_record:
196+
# Create a half-width column for each group
197+
group_col = etree.SubElement(
198+
main_row,
199+
"div",
200+
{"class": "col-12 col-lg-6"},
201+
)
202+
# Add group label/title
203+
group_label_div = etree.SubElement(
204+
group_col,
205+
"div",
206+
{"class": "o_horizontal_separator mt-2 mb-3"},
207+
)
208+
group_label = etree.SubElement(
209+
group_label_div,
210+
"strong",
211+
)
212+
group_label.text = group_record.name
213+
# Create fields container
214+
group_div = etree.SubElement(group_col, "div", {"class": "row mt16 o_settings_container"})
215+
for field in fields_in_group:
216+
self.create_field_element(group_div, field, is_ind=True)
217+
else:
218+
# Fields without a group go in full width at the bottom
219+
if not indicators_page.xpath(".//div[@class='row mt16 o_settings_container o_no_group']"):
220+
indicators_div = etree.SubElement(
221+
indicators_page, "div", {"class": "row mt16 o_settings_container o_no_group"}
222+
)
223+
else:
224+
indicators_div = indicators_page.xpath(
225+
".//div[@class='row mt16 o_settings_container o_no_group']"
226+
)[0]
227+
for field in fields_in_group:
228+
self.create_field_element(indicators_div, field, is_ind=True)
229+
230+
if custom_page.getchildren():
96231
basic_info_page[0].addnext(custom_page)
97-
if indicators_div.getchildren():
232+
if indicators_page.getchildren():
98233
basic_info_page[0].addnext(indicators_page)
99234

100235
arch = doc
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo import fields, models
4+
5+
6+
class CustomFieldGroup(models.Model):
7+
_name = "spp.custom.field.group"
8+
_description = "Custom Field Group"
9+
_order = "sequence, name"
10+
11+
name = fields.Char(string="Group Name", required=True, translate=True)
12+
target_type = fields.Selection(
13+
selection=[("grp", "Group"), ("indv", "Individual")],
14+
string="Target Type",
15+
required=True,
16+
default="grp",
17+
help="Specify whether this group is for Group or Individual fields",
18+
)
19+
sequence = fields.Integer(string="Sequence", default=10)
20+
description = fields.Text(string="Description", translate=True)
21+
active = fields.Boolean(string="Active", default=True)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo import api, fields, models
4+
5+
6+
class IrModelFields(models.Model):
7+
_inherit = "ir.model.fields"
8+
9+
field_group_id = fields.Many2one(
10+
"spp.custom.field.group",
11+
string="Field Group",
12+
help="Group this field belongs to for UI organization",
13+
domain="[('target_type', '=', target_type)]",
14+
)
15+
sequence = fields.Integer(
16+
string="Sequence",
17+
default=10,
18+
help="Order of the field within its group or section",
19+
)
20+
21+
@api.onchange("target_type")
22+
def _onchange_target_type(self):
23+
"""Clear field_group_id if target_type changes and doesn't match"""
24+
if self.field_group_id and self.field_group_id.target_type != self.target_type:
25+
self.field_group_id = False
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
2+
access_spp_custom_field_group_user,spp.custom.field.group.user,model_spp_custom_field_group,base.group_user,1,0,0,0
3+
access_spp_custom_field_group_admin,spp.custom.field.group.admin,model_spp_custom_field_group,base.group_system,1,1,1,1

spp_custom_field/tests/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
3+
from . import test_field_group
4+
from . import test_view_generation

0 commit comments

Comments
 (0)