=e-1){var s=u[n];return s.x0=i,s.y0=o,s.x1=a,void(s.y1=c)}var l=f[n],h=r/2+l,d=n+1,p=e-1;for(;d>>1;f[g]c-o){var _=r?(i*v+a*y)/r:a;t(n,d,y,i,o,_,c),t(d,e,v,_,o,a,c)}else{var b=r?(o*v+c*y)/r:c;t(n,d,y,i,o,a,b),t(d,e,v,i,b,a,c)}}(0,c,t.value,n,e,r,i)},t.treemapDice=Ap,t.treemapResquarify=Lp,t.treemapSlice=Ip,t.treemapSliceDice=function(t,n,e,r,i){(1&t.depth?Ip:Ap)(t,n,e,r,i)},t.treemapSquarify=Yp,t.tsv=Mc,t.tsvFormat=lc,t.tsvFormatBody=hc,t.tsvFormatRow=pc,t.tsvFormatRows=dc,t.tsvFormatValue=gc,t.tsvParse=fc,t.tsvParseRows=sc,t.union=function(...t){const n=new InternSet;for(const e of t)for(const t of e)n.add(t);return n},t.unixDay=_y,t.unixDays=by,t.utcDay=yy,t.utcDays=vy,t.utcFriday=By,t.utcFridays=Vy,t.utcHour=hy,t.utcHours=dy,t.utcMillisecond=Wg,t.utcMilliseconds=Zg,t.utcMinute=cy,t.utcMinutes=fy,t.utcMonday=qy,t.utcMondays=jy,t.utcMonth=Qy,t.utcMonths=Jy,t.utcSaturday=Yy,t.utcSaturdays=Wy,t.utcSecond=iy,t.utcSeconds=oy,t.utcSunday=Fy,t.utcSundays=Ly,t.utcThursday=Oy,t.utcThursdays=Gy,t.utcTickInterval=av,t.utcTicks=ov,t.utcTuesday=Uy,t.utcTuesdays=Hy,t.utcWednesday=Iy,t.utcWednesdays=Xy,t.utcWeek=Fy,t.utcWeeks=Ly,t.utcYear=ev,t.utcYears=rv,t.variance=x,t.version="7.9.0",t.window=pn,t.xml=Sc,t.zip=function(){return gt(arguments)},t.zoom=function(){var t,n,e,r=Ew,i=Nw,o=zw,a=Cw,u=Pw,c=[0,1/0],f=[[-1/0,-1/0],[1/0,1/0]],s=250,l=ri,h=$t("start","zoom","end"),d=500,p=150,g=0,y=10;function v(t){t.property("__zoom",kw).on("wheel.zoom",T,{passive:!1}).on("mousedown.zoom",A).on("dblclick.zoom",S).filter(u).on("touchstart.zoom",E).on("touchmove.zoom",N).on("touchend.zoom touchcancel.zoom",k).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function _(t,n){return(n=Math.max(c[0],Math.min(c[1],n)))===t.k?t:new ww(n,t.x,t.y)}function b(t,n,e){var r=n[0]-e[0]*t.k,i=n[1]-e[1]*t.k;return r===t.x&&i===t.y?t:new ww(t.k,r,i)}function m(t){return[(+t[0][0]+ +t[1][0])/2,(+t[0][1]+ +t[1][1])/2]}function x(t,n,e,r){t.on("start.zoom",(function(){w(this,arguments).event(r).start()})).on("interrupt.zoom end.zoom",(function(){w(this,arguments).event(r).end()})).tween("zoom",(function(){var t=this,o=arguments,a=w(t,o).event(r),u=i.apply(t,o),c=null==e?m(u):"function"==typeof e?e.apply(t,o):e,f=Math.max(u[1][0]-u[0][0],u[1][1]-u[0][1]),s=t.__zoom,h="function"==typeof n?n.apply(t,o):n,d=l(s.invert(c).concat(f/s.k),h.invert(c).concat(f/h.k));return function(t){if(1===t)t=h;else{var n=d(t),e=f/n[2];t=new ww(e,c[0]-n[0]*e,c[1]-n[1]*e)}a.zoom(null,t)}}))}function w(t,n,e){return!e&&t.__zooming||new M(t,n)}function M(t,n){this.that=t,this.args=n,this.active=0,this.sourceEvent=null,this.extent=i.apply(t,n),this.taps=0}function T(t,...n){if(r.apply(this,arguments)){var e=w(this,n).event(t),i=this.__zoom,u=Math.max(c[0],Math.min(c[1],i.k*Math.pow(2,a.apply(this,arguments)))),s=ne(t);if(e.wheel)e.mouse[0][0]===s[0]&&e.mouse[0][1]===s[1]||(e.mouse[1]=i.invert(e.mouse[0]=s)),clearTimeout(e.wheel);else{if(i.k===u)return;e.mouse=[s,i.invert(s)],Gi(this),e.start()}Sw(t),e.wheel=setTimeout((function(){e.wheel=null,e.end()}),p),e.zoom("mouse",o(b(_(i,u),e.mouse[0],e.mouse[1]),e.extent,f))}}function A(t,...n){if(!e&&r.apply(this,arguments)){var i=t.currentTarget,a=w(this,n,!0).event(t),u=Zn(t.view).on("mousemove.zoom",(function(t){if(Sw(t),!a.moved){var n=t.clientX-s,e=t.clientY-l;a.moved=n*n+e*e>g}a.event(t).zoom("mouse",o(b(a.that.__zoom,a.mouse[0]=ne(t,i),a.mouse[1]),a.extent,f))}),!0).on("mouseup.zoom",(function(t){u.on("mousemove.zoom mouseup.zoom",null),ue(t.view,a.moved),Sw(t),a.event(t).end()}),!0),c=ne(t,i),s=t.clientX,l=t.clientY;ae(t.view),Aw(t),a.mouse=[c,this.__zoom.invert(c)],Gi(this),a.start()}}function S(t,...n){if(r.apply(this,arguments)){var e=this.__zoom,a=ne(t.changedTouches?t.changedTouches[0]:t,this),u=e.invert(a),c=e.k*(t.shiftKey?.5:2),l=o(b(_(e,c),a,u),i.apply(this,n),f);Sw(t),s>0?Zn(this).transition().duration(s).call(x,l,a,t):Zn(this).call(v.transform,l,a,t)}}function E(e,...i){if(r.apply(this,arguments)){var o,a,u,c,f=e.touches,s=f.length,l=w(this,i,e.changedTouches.length===s).event(e);for(Aw(e),a=0;a
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hr_governance/static/src/components/color_picker_field.esm.js b/hr_governance/static/src/components/color_picker_field.esm.js
new file mode 100644
index 00000000000..329090c4848
--- /dev/null
+++ b/hr_governance/static/src/components/color_picker_field.esm.js
@@ -0,0 +1,22 @@
+/** @odoo-module **/
+
+import {CirclePackingColorList} from "./circlepack_colorlist.esm";
+import {ColorPickerField} from "@web/views/fields/color_picker/color_picker_field";
+import {registry} from "@web/core/registry";
+
+class CirclePackColorPickerField extends ColorPickerField {
+ static template = "hr_governance.CirclePackColorPickerField";
+ static components = {CirclePackingColorList};
+}
+
+export const circlecepackcolorPickerField = {
+ component: CirclePackColorPickerField,
+ supportedTypes: ["integer"],
+ extractProps: ({viewType}) => ({
+ canToggle: viewType !== "list",
+ }),
+};
+
+registry
+ .category("fields")
+ .add("circlepack_color_picker", circlecepackcolorPickerField);
diff --git a/hr_governance/static/src/components/color_picker_field.xml b/hr_governance/static/src/components/color_picker_field.xml
new file mode 100644
index 00000000000..bc7ae0e12e3
--- /dev/null
+++ b/hr_governance/static/src/components/color_picker_field.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/hr_governance/static/src/components/delete_roles_confirmation_dialog.esm.js b/hr_governance/static/src/components/delete_roles_confirmation_dialog.esm.js
new file mode 100644
index 00000000000..e9b02f90128
--- /dev/null
+++ b/hr_governance/static/src/components/delete_roles_confirmation_dialog.esm.js
@@ -0,0 +1,16 @@
+/** @odoo-module **/
+
+import {ConfirmationDialog} from "@web/core/confirmation_dialog/confirmation_dialog";
+import {_t} from "@web/core/l10n/translation";
+
+export class DeleteRolesConfirmationDialog extends ConfirmationDialog {}
+
+DeleteRolesConfirmationDialog.props = {
+ ...ConfirmationDialog.props,
+ body: {String, optional: true},
+};
+
+DeleteRolesConfirmationDialog.defaultProps = {
+ ...ConfirmationDialog.defaultProps,
+ confirmLabel: _t("Delete"),
+};
diff --git a/hr_governance/static/src/components/form_wrapper.esm.js b/hr_governance/static/src/components/form_wrapper.esm.js
new file mode 100644
index 00000000000..1c6d868dd43
--- /dev/null
+++ b/hr_governance/static/src/components/form_wrapper.esm.js
@@ -0,0 +1,122 @@
+/** @odoo-module **/
+
+import {markup, onMounted} from "@odoo/owl";
+import {DeleteRolesConfirmationDialog} from "./delete_roles_confirmation_dialog.esm";
+import {FormController} from "@web/views/form/form_controller";
+import {_t} from "@web/core/l10n/translation";
+import {executeButtonCallback} from "@web/views/view_button/view_button_hook";
+import {formView} from "@web/views/form/form_view";
+import {isEmpty} from "../utils/helpers.esm";
+import {registry} from "@web/core/registry";
+
+export class FormWrapperController extends FormController {
+ setup() {
+ super.setup();
+ onMounted(() => {
+ if (!this.model.root.data.is_circle) {
+ // Hide create buttons when role is selected
+ $("button.o_form_button_create").css("display", "none");
+ }
+ });
+ }
+ async save(params) {
+ const res = await super.save(params);
+ this.ui.bus.trigger("governance:form_saved_record");
+ return res;
+ }
+
+ get deleteConfirmationDialogProps() {
+ const res = super.deleteConfirmationDialogProps;
+ const originalConfirm = res.confirm;
+
+ res.confirm = async () => {
+ await originalConfirm();
+ this.ui.bus.trigger("governance:form_deleted_record", {
+ deletedResId: this.props.resId,
+ });
+ };
+ return res;
+ }
+
+ async deleteRecord() {
+ if (!this.model.root.data.child_ids?.count) {
+ return super.deleteRecord();
+ }
+ const data = await this.model.orm.call(
+ "governance.circle",
+ "js_get_deleted_circle_info",
+ [this.model.root.evalContext.active_id]
+ );
+ this.dialogService.add(DeleteRolesConfirmationDialog, {
+ body: markup(
+ _t(
+ `Deleting this Circle will also delete its associated roles (Impact ${data.subcircles} sub-circles, ${data.roles} roles, ${data.employees} employees).
+If you wish to preserve the roles, make sure to unlink them from their circle beforehand.
+
+Are you sure you want to proceed?`
+ )
+ ),
+ confirm: async () => {
+ const typing = prompt("Please type DELETE");
+ if (typing === "DELETE") {
+ await this.model.root.delete();
+ this.ui.bus.trigger("governance:form_deleted_record", {
+ deletedResId: this.props.resId,
+ });
+ }
+ if (!this.model.root.resId) {
+ this.env.config.historyBack();
+ }
+ },
+ });
+ }
+
+ async create(ev) {
+ const additionalContext = {};
+
+ // Prefil circle/role
+ const buttonType = ev.target.dataset.type;
+ const method = buttonType === "circle" ? `_create_circle` : `_create_role`;
+ await this[method](additionalContext);
+
+ // Prefil parent_id
+ additionalContext.default_parent_id = this.props.resId || false;
+ if (isEmpty(additionalContext) === false) {
+ await executeButtonCallback(this.ui.activeElement, () =>
+ this.model.load({
+ resId: false,
+ context: {
+ ...this.props.context,
+ ...additionalContext,
+ },
+ })
+ );
+ // TODO: is this needed?
+ } else return super.create(ev);
+ }
+
+ _create_circle(context = {}) {
+ context.default_is_circle = true;
+ }
+
+ async _create_role(context = {}) {
+ context.default_is_circle = false;
+ }
+
+ async discard() {
+ if (this.model.root.isNew) {
+ this.ui.bus.trigger("governance:form_discard_record");
+ return;
+ }
+ return super.discard();
+ }
+}
+
+FormWrapperController.template = `hr_governance.FormWrapperView`;
+
+export const formwrapperView = {
+ ...formView,
+ Controller: FormWrapperController,
+};
+
+registry.category("views").add("formwrapper", formwrapperView);
diff --git a/hr_governance/static/src/components/form_wrapper.xml b/hr_governance/static/src/components/form_wrapper.xml
new file mode 100644
index 00000000000..70d6a2d6f99
--- /dev/null
+++ b/hr_governance/static/src/components/form_wrapper.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hr_governance/static/src/components/many2many_avatar_user_field.esm.js b/hr_governance/static/src/components/many2many_avatar_user_field.esm.js
new file mode 100644
index 00000000000..60788418c39
--- /dev/null
+++ b/hr_governance/static/src/components/many2many_avatar_user_field.esm.js
@@ -0,0 +1,44 @@
+/* @odoo-module */
+
+import {
+ Many2ManyTagsAvatarUserField,
+ many2ManyTagsAvatarUserField,
+} from "@mail/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field";
+import {registry} from "@web/core/registry";
+import {useService} from "@web/core/utils/hooks";
+
+export class ClickableMany2ManyTagsAvatarUserField extends Many2ManyTagsAvatarUserField {
+ setup() {
+ super.setup();
+ this.action = useService("action");
+ }
+ getTagProps(record) {
+ return {
+ ...super.getTagProps(...arguments),
+ onImageClicked: () => {
+ if (record) {
+ this.action.doAction({
+ type: "ir.actions.act_window",
+ name: "Roles of this member",
+ res_model: "governance.circle",
+ view_mode: "list,form",
+ views: [
+ [false, "list"],
+ [false, "form"],
+ ],
+ domain: [["member_rel_ids", "ilike", record.data.display_name]],
+ });
+ }
+ },
+ };
+ }
+}
+
+export const clickablemany2ManyTagsAvatarUserField = {
+ ...many2ManyTagsAvatarUserField,
+ component: ClickableMany2ManyTagsAvatarUserField,
+};
+
+registry
+ .category("fields")
+ .add("clickable_many2many_avatar_user", clickablemany2ManyTagsAvatarUserField);
diff --git a/hr_governance/static/src/components/search_bar.esm.js b/hr_governance/static/src/components/search_bar.esm.js
new file mode 100644
index 00000000000..e26f7cb8fd4
--- /dev/null
+++ b/hr_governance/static/src/components/search_bar.esm.js
@@ -0,0 +1,11 @@
+/** @odoo-module **/
+
+import {SearchBar} from "@web/search/search_bar/search_bar";
+
+export class Many2ManySearchBar extends SearchBar {
+ getFieldType(searchItem) {
+ const type = super.getFieldType(searchItem);
+ const fieldType = type === "many2many" ? "many2one" : type;
+ return fieldType;
+ }
+}
diff --git a/hr_governance/static/src/utils/color.esm.js b/hr_governance/static/src/utils/color.esm.js
new file mode 100644
index 00000000000..270dfc896fb
--- /dev/null
+++ b/hr_governance/static/src/utils/color.esm.js
@@ -0,0 +1,29 @@
+/** @odoo-module **/
+
+const COLORS = [
+ "#FFFFFF",
+ "#D0D1D3",
+ "#b3ffc5",
+ "#b9f0ff",
+ "#c9d9cb",
+ "#e0f9ff",
+ "#e9ccff",
+ "#eaecc1",
+ "#f5d9d9",
+ "#fcffbe",
+ "#ffe1a5",
+ "#ffccca",
+ "#a5d6ff",
+];
+
+/**
+ * @param {Object} node
+ * @returns {String}
+ */
+export function getColor(node) {
+ const color = node.data?.color;
+ if (color) {
+ return COLORS[color - 1];
+ }
+ return COLORS[Math.floor(Math.random() * COLORS.length)];
+}
diff --git a/hr_governance/static/src/utils/helpers.esm.js b/hr_governance/static/src/utils/helpers.esm.js
new file mode 100644
index 00000000000..44f46409187
--- /dev/null
+++ b/hr_governance/static/src/utils/helpers.esm.js
@@ -0,0 +1,52 @@
+/** @odoo-module **/
+
+// NOTE: add mechanism to handle circular references when traversing
+export function isEquals(o1, o2, o1Refs, o2Refs) {
+ const _o1Refs = o1Refs || [];
+ const _o2Refs = o2Refs || [];
+
+ if (o1 === o2) return true;
+ if ((o1 && !o2) || (o2 && !o1)) return false;
+ if (typeof o1 !== typeof o2) return false;
+ if (typeof o1 !== "object") return false;
+
+ // Keep track of the references
+ // make sure both circular references point to the same part of the history
+ const o1RefIndex = _o1Refs.indexOf(o1);
+ const o2RefIndex = _o2Refs.indexOf(o2);
+
+ if (o1RefIndex === o2RefIndex && o1RefIndex >= 0) return true;
+ _o1Refs.push(o1);
+ _o2Refs.push(o2);
+
+ // Objects can have different keys if the values are undefined
+ for (const key in o2) {
+ if (!(key in o1) && o2[key] !== undefined) {
+ return false;
+ }
+ }
+ for (const key in o1) {
+ if (typeof o1[key] !== typeof o2[key]) return false;
+ if (typeof o1[key] === "object") {
+ if (!isEquals(o1[key], o2[key], _o1Refs.slice(), _o2Refs.slice()))
+ return false;
+ } else if (o1[key] !== o2[key]) return false;
+ }
+ return true;
+}
+
+// From @spreadsheet/helpers/helpers
+export function isEmpty(item) {
+ if (!item) {
+ return true;
+ }
+ if (typeof item === "object") {
+ if (
+ Object.values(item).length === 0 ||
+ Object.values(item).every((val) => val === undefined)
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/hr_governance/static/src/views/circlepack.scss b/hr_governance/static/src/views/circlepack.scss
new file mode 100644
index 00000000000..206867af47d
--- /dev/null
+++ b/hr_governance/static/src/views/circlepack.scss
@@ -0,0 +1,154 @@
+.o_circle_pack_view {
+ img.o_avatar {
+ height: 36px;
+ }
+
+ .o_last_breadcrumb_item span.text-truncate {
+ display: none;
+ }
+ .o_content {
+ overflow: hidden;
+
+ .o_circle_packing_container {
+ display: flex;
+ flex-flow: row nowrap;
+ width: 100%;
+ height: 100%;
+ align-items: stretch;
+
+ .o_circle_packing_svg_container {
+ padding-top: 10px;
+ height: 85vh;
+
+ .matched {
+ stroke: #000;
+ stroke-opacity: 1;
+ stroke-width: 4px;
+ opacity: 1;
+ }
+
+ .tooltip {
+ opacity: 1;
+ max-width: 320px;
+ white-space: nowrap;
+
+ font-size: 18px;
+ font-family: Arial;
+ font-weight: bold;
+ text-align: center;
+ }
+
+ polygon {
+ cursor: pointer;
+ stroke: #000000;
+ stroke-opacity: 1;
+ transition-property: stroke-opacity, opacity;
+ transition-duration: 0.4s;
+ }
+
+ circle {
+ cursor: pointer;
+ opacity: 0.85;
+ transition-property: stroke-opacity, opacity;
+ transition-duration: 0.4s;
+ }
+
+ circle:hover.clicked {
+ stroke: #000;
+ stroke-opacity: 1;
+ opacity: 1;
+ transition-duration: 0.05s;
+ }
+
+ circle:hover {
+ stroke: #a9aaac;
+ stroke-opacity: 1;
+ opacity: 1;
+ transition-duration: 0.05s;
+ }
+
+ .clicked {
+ stroke: #000;
+ stroke-opacity: 1;
+ opacity: 1;
+ transition-duration: 0.05s;
+ }
+
+ .text-container div {
+ color: #374151;
+ font-size: 22px;
+ font-family: Arial;
+ font-weight: bold;
+
+ word-wrap: anywhere;
+ text-align: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ }
+ }
+
+ .chart-splitter {
+ flex: 0 0 auto;
+
+ position: relative;
+ box-sizing: border-box;
+ width: 3px;
+ height: 100%;
+ border-left-width: 1px;
+ border-left-style: solid;
+ border-left-color: black;
+ border-right-width: 1px;
+ border-right-style: solid;
+ border-right-color: black;
+ cursor: ew-resize;
+
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+
+ &:before {
+ content: "";
+ position: absolute;
+ z-index: 1;
+ top: 50%;
+ right: 100%;
+ height: 18px;
+ width: 2px;
+ margin-top: -18px / 2;
+ border-left-color: black;
+ border-left-width: 1px;
+ border-left-style: solid;
+ }
+
+ &:after {
+ content: "";
+ position: absolute;
+ z-index: 1;
+ top: 50%;
+ left: 100%;
+ height: 18px;
+ width: 2px;
+ margin-top: -18px / 2;
+ border-right-color: black;
+ border-right-width: 1px;
+ border-right-style: solid;
+ }
+ }
+
+ .form_wrapper {
+ width: 100%;
+ height: 100%;
+ overflow-x: auto;
+
+ .o_form_sheet_bg {
+ padding: 0;
+ }
+ }
+ }
+ }
+}
diff --git a/hr_governance/static/src/views/circlepack_controller.esm.js b/hr_governance/static/src/views/circlepack_controller.esm.js
new file mode 100644
index 00000000000..0c2d01eee18
--- /dev/null
+++ b/hr_governance/static/src/views/circlepack_controller.esm.js
@@ -0,0 +1,31 @@
+/** @odoo-module */
+
+import {Component, useSubEnv} from "@odoo/owl";
+import {Layout} from "@web/search/layout";
+import {Many2ManySearchBar} from "../components/search_bar.esm";
+import {standardViewProps} from "@web/views/standard_view_props";
+import {useBus} from "@web/core/utils/hooks";
+import {useModel} from "@web/model/model";
+export class CirclePackController extends Component {
+ static components = {
+ Layout,
+ Many2ManySearchBar,
+ };
+ static props = {
+ ...standardViewProps,
+ Model: Function,
+ Renderer: Function,
+ };
+ static template = "hr_governance.CirclePackView";
+
+ setup() {
+ this.model = useModel(this.props.Model, {
+ resModel: this.props.model,
+ domain: this.props.domain,
+ });
+ useSubEnv({model: this.model});
+ useBus(this.model.bus, "update", () => {
+ this.render(true);
+ });
+ }
+}
diff --git a/hr_governance/static/src/views/circlepack_controller.xml b/hr_governance/static/src/views/circlepack_controller.xml
new file mode 100644
index 00000000000..604f1d19a3e
--- /dev/null
+++ b/hr_governance/static/src/views/circlepack_controller.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hr_governance/static/src/views/circlepack_model.esm.js b/hr_governance/static/src/views/circlepack_model.esm.js
new file mode 100644
index 00000000000..ea780e7bf5f
--- /dev/null
+++ b/hr_governance/static/src/views/circlepack_model.esm.js
@@ -0,0 +1,58 @@
+/** @odoo-module **/
+
+import {KeepLast} from "@web/core/utils/concurrency";
+import {Model} from "@web/model/model";
+
+export class CirclePackModel extends Model {
+ setup() {
+ // Concurrency management
+ this.keepLast = new KeepLast();
+ this.data = null;
+ }
+
+ async load(params = {}) {
+ this.searchParams = params;
+ const data = await this.keepLast.add(this._loadData(params));
+ this.data = data;
+ this.notify();
+ }
+
+ /**
+ * Load data for hierarchy view
+ *
+ * @param {Object} config model config
+ * @returns {Object[]} main data for hierarchy view
+ */
+ async _loadData(config) {
+ const result = await this.orm.call(
+ "governance.circle",
+ "get_hierarchy_data",
+ [config.domain || []],
+ {
+ context: config.context,
+ }
+ );
+
+ const data = new Map(result.map((item) => [item.id, {...item, children: []}]));
+
+ // Build hierarchy data
+ let root = null;
+ const orphans = [];
+ result.forEach((item) => {
+ if (item.parent_id === false) {
+ root = data.get(item.id);
+ } else {
+ const parent = data.get(item.parent_id[0]);
+ if (parent) {
+ parent.children.push(data.get(item.id));
+ } else {
+ orphans.push(item);
+ }
+ }
+ });
+ if (root && orphans.length > 0) {
+ root.children.push(...orphans);
+ }
+ return root;
+ }
+}
diff --git a/hr_governance/static/src/views/circlepack_renderer.esm.js b/hr_governance/static/src/views/circlepack_renderer.esm.js
new file mode 100644
index 00000000000..79cef907840
--- /dev/null
+++ b/hr_governance/static/src/views/circlepack_renderer.esm.js
@@ -0,0 +1,549 @@
+/** @odoo-module **/
+
+import {
+ Component,
+ onMounted,
+ onWillStart,
+ onWillUpdateProps,
+ useEffect,
+ useExternalListener,
+ useRef,
+ useState,
+} from "@odoo/owl";
+import {useBus, useService} from "@web/core/utils/hooks";
+import {View} from "@web/views/view";
+import {getColor} from "../utils/color.esm";
+import {isEquals} from "../utils/helpers.esm";
+import {loadBundle} from "@web/core/assets";
+
+export class CirclePackRenderer extends Component {
+ static template = "hr_governance.CirclePackRenderer";
+ static components = {View};
+
+ setup() {
+ this.user = useService("user");
+ this.orm = useService("orm");
+ this.ui = useService("ui");
+
+ this.state = useState({
+ activeResId: false,
+ allowed_edit_governance_ids: [],
+ });
+
+ this.containerRef = useRef("container");
+ this.chartRef = useRef("chart");
+
+ onWillStart(async () => {
+ await loadBundle("hr_governance.chart_libs");
+ // Set default activeID for FormView
+ const defaultResId = this.props.records?.id;
+ this.state.activeResId = defaultResId;
+ this.is_grayscale_on = await this.orm.call(
+ "governance.circle",
+ "get_greyscale_mode_param"
+ );
+ this.is_stripe_all_roles = parseInt(
+ await this.orm.call("governance.circle", "get_stripe_param"),
+ 10
+ );
+ this.isGovernanceAdmin = await this.user.hasGroup(
+ "hr_governance.governance_group_manager"
+ );
+
+ this.state.allowed_edit_governance_ids =
+ this.user.context.allowed_edit_governance_ids;
+ });
+
+ onMounted(() => {
+ this.firstload = true;
+ this.$container = $(this.containerRef.el);
+ this.$formview = $(this.containerRef.el?.querySelector(".form_wrapper"));
+ this.$splitter = $(this.containerRef.el?.querySelector(".chart-splitter"));
+ this.$chart = $(this.chartRef.el);
+ this.searchResults = undefined;
+ this.data = this.props.records;
+ if (this.data) {
+ this.renderChart(this.$chart.width(), this.$chart.height());
+ }
+ });
+
+ onWillUpdateProps((nextProps) => {
+ const parsedData = this._parseData(
+ nextProps.records,
+ this.$chart.width(),
+ this.$chart.height()
+ )?.data;
+ this.searchResults = isEquals(parsedData, this.data)
+ ? undefined
+ : parsedData.children;
+ });
+
+ useEffect(
+ () => {
+ if (this.props.records) {
+ // Assign 1st record to FormView, otherwise assign root record
+ this.state.activeResId =
+ this.searchResults?.length > 0
+ ? this.searchResults[0].id
+ : this.props.records.id;
+
+ this.renderChart(this.$chart.width(), this.$chart.height());
+
+ if (this.searchResults?.length > 0) {
+ this._applySearchResult();
+ }
+ }
+ },
+ // Only re-draw chart to update this.searchResults
+ () => [this.props.records]
+ );
+
+ useBus(this.ui.bus, "governance:form_saved_record", async () => {
+ await this.updateChart();
+ const context = await this.env.services.rpc(
+ "/web/session/get_session_info"
+ );
+ this.state.allowed_edit_governance_ids =
+ context.user_context.allowed_edit_governance_ids;
+ });
+
+ useBus(this.ui.bus, "governance:form_deleted_record", async (ev) => {
+ // Find parent of the deleted
+ const deletedResId = ev.detail.deletedResId;
+ // eslint-disable-next-line no-undef
+ const deletedNode = d3
+ .hierarchy(this.data)
+ .descendants()
+ .find((d) => d.data.id === deletedResId);
+ const parentNode = deletedNode ? deletedNode.ancestors()[1] : null;
+
+ await this.updateChart(parentNode);
+
+ // Assign parent of the deleted to FormView
+ this.state.activeResId = parentNode?.data.id || this.data.id;
+ });
+ useExternalListener(window, "resize", this.onWindowResized);
+
+ useBus(this.ui.bus, "governance:form_discard_record", async () => {
+ this.render(true);
+ });
+ }
+
+ async updateChart(focusNode) {
+ const data = await this.env.model.keepLast.add(this.env.model._loadData({}));
+ this.data = data;
+ this.renderChart(this.$chart.width(), this.$chart.height());
+ // Maintain current zoom
+ if (focusNode) {
+ this.zoomToNode(focusNode.data, this.$chart.width(), this.$chart.height());
+ } else {
+ this.zoomToNode(this.focus, this.$chart.width(), this.$chart.height());
+ }
+
+ if (this.searchResults?.length > 0) {
+ this._applySearchResult();
+ }
+ }
+
+ _parseData(data, width, height) {
+ if (data) {
+ // eslint-disable-next-line no-undef
+ const hierarchyData = d3
+ .hierarchy(data)
+ .sum((d) => (d.member_count ? d.member_count : 1))
+ .sort((a, b) => {
+ const memA = a.member_count ? a.member_count : 1;
+ const memB = b.member_count ? b.member_count : 1;
+ return memB - memA;
+ });
+
+ // eslint-disable-next-line no-undef
+ d3.pack().size([width, height]).padding(2)(hierarchyData);
+
+ hierarchyData.descendants().forEach((d, i) => {
+ // Mark each node with a unique ID
+ d.id = i;
+ // Dual-link data nodes
+ d.data.__dataNode = d;
+ });
+ return hierarchyData;
+ }
+ }
+
+ zoomToNode(d, width, height) {
+ this.clickedNode = d;
+ const node = d.__dataNode;
+ if (node) {
+ // Zooming to clicked circle
+ const ZOOM_REL_PADDING = 0.12;
+ const k = Math.max(
+ 1,
+ (Math.min(width, height) / (node.r * 2)) * (1 - ZOOM_REL_PADDING)
+ );
+ const tr = {
+ k,
+ // Don't pan out of chart boundaries
+ x: -Math.max(0, Math.min(width * (1 - 1 / k), node.x - width / k / 2)),
+ y: -Math.max(
+ 0,
+ Math.min(height * (1 - 1 / k), node.y - height / k / 2)
+ ),
+ // Center circle in view
+ };
+ this.zoom.zoomTo(tr);
+
+ // Label scaling
+ const currentZoom = this.zoom.current();
+ this.groupLabels.attr("transform", (dataNode) => {
+ const x = dataNode.x * currentZoom.k + currentZoom.x;
+ const y = dataNode.y * currentZoom.k + currentZoom.y;
+ return `translate(${x},${y})`;
+ });
+ }
+ return this;
+ }
+
+ // TODO: improve
+ renderChart(width, height) {
+ if (!this.data) return;
+
+ if (this.svg) {
+ this.svg.remove();
+ }
+
+ const hierarchyData = this._parseData(this.data, width, height);
+ // eslint-disable-next-line no-undef
+ this.svg = d3.create("svg");
+ this.svg.style("width", width + "px").style("height", "inherit");
+
+ // Pattern injection for striped shapes used by circle without members
+ // eslint-disable-next-line no-unused-vars
+ const pattern = this.svg
+ .append("defs")
+ .append("pattern")
+ .attr("id", "stripes")
+ .attr("width", "10")
+ .attr("height", "8")
+ .attr("patternUnits", "userSpaceOnUse")
+ .attr("patternTransform", "rotate(45)")
+ .append("rect")
+ .attr("width", "5")
+ .attr("height", "8")
+ .attr("transform", "translate(0, 0)")
+ .attr("fill", "grey");
+ this.groupCircles = this.svg.append("g").attr("id", "groupCircles");
+
+ // eslint-disable-next-line no-undef
+ this.zoom = zoomable();
+ // By default reset zoom when clicking on svg
+ this.svg.on("click", () => this.zoom.zoomReset());
+
+ this.zoom(this.svg)
+ .svgEl(this.groupCircles)
+ // Takes effect when zooming with mousewheel
+ .onChange((tr) => {
+ this.transition = tr;
+ this.groupLabels.attr("transform", (d) => {
+ const translateX = d.x * tr.k + tr.x;
+ const translateY = d.y * tr.k + tr.y;
+ return `translate(${translateX},${translateY})`;
+ });
+
+ // Text wrapping dynamically change
+ this.groupLabels
+ .selectAll("foreignObject")
+ .attr("width", (d) => d.r * 1.8 * tr.k)
+ .attr("height", (d) => d.r * 1.8 * tr.k)
+ .attr("x", (d) => -d.r * 0.9 * tr.k)
+ .attr("y", (d) => -d.r * 0.9 * tr.k);
+
+ // Label visibility
+ const scale = Math.round(tr.k);
+ // eslint-disable-next-line no-undef
+ const maxDepth = d3.max(this.groupLabels.data(), (d) => d.depth);
+
+ this.groupLabels.selectAll("span").style("opacity", 0);
+ this.groupLabels.selectAll("span").each(function (d) {
+ const r = d.r * tr.k * 1.2;
+ const newHeight = this.getBoundingClientRect().height;
+ if (
+ newHeight < r &&
+ (d.depth === scale ||
+ (scale > maxDepth && d.depth === maxDepth) ||
+ (d.depth < scale && d.children === undefined))
+ )
+ // eslint-disable-next-line no-undef
+ d3.select(this).style("opacity", 1);
+ });
+ });
+
+ this.zoom.translateExtent([
+ [0, 0],
+ [width, height],
+ ]);
+
+ if (!hierarchyData) return;
+
+ this.chartRef.el.append(this.svg.node());
+
+ const cell = this.groupCircles
+ .selectAll(".node")
+ .data(hierarchyData.descendants());
+
+ // Exiting
+ cell.exit().transition().remove();
+
+ // Entering
+ const newCell = cell
+ .enter()
+ .append("g")
+ .attr("id", (d) => `node-${d.data.id}`)
+ .attr("transform", (d) => `translate(${d.x},${d.y})`)
+ .attr("data-tooltip", (d) => d.data.name);
+
+ this._addNodeShape(newCell);
+
+ // Entering + Updating
+ const allCells = cell.merge(newCell);
+
+ allCells.attr("class", "node");
+
+ // Append the text labels.
+ const labels = this.svg
+ .append("g")
+ .attr("pointer-events", "none")
+ .attr("text-anchor", "middle");
+
+ this.groupLabels = labels
+ .selectAll("g.node")
+ .data(hierarchyData.descendants())
+ .enter()
+ .append("g")
+ .attr("class", "label-container")
+ .attr("transform", (d) => `translate(${d.x},${d.y})`);
+
+ this.groupLabels
+ .append("foreignObject")
+ .attr("class", "text-container")
+ .attr("width", (d) => d.r * 1.8)
+ .attr("height", (d) => d.r * 1.8)
+ // Center in the circle
+ .attr("x", (d) => -d.r * 0.9)
+ .attr("y", (d) => -d.r * 0.9)
+ .append("xhtml:div")
+ .attr("class", "path-label")
+ .append("span")
+ .text((d) => d.data.name);
+
+ this.groupLabels.selectAll("span").each(function (d) {
+ const radius = d.r * 1.5;
+ const newHeight = this.getBoundingClientRect().height;
+ // eslint-disable-next-line no-undef
+ d3.select(this).style(
+ "opacity",
+ newHeight > radius || d.depth !== 1 ? 0 : 1
+ );
+ });
+
+ allCells.select("circle.striped").style("fill", "url(#stripes)");
+ allCells.select("polygon.striped").style("fill", "url(#stripes)");
+ }
+
+ _getNodeColor(node) {
+ if (!this.is_grayscale_on) {
+ return getColor(node);
+ }
+
+ // GrayScale mode
+ return node.data.is_circle === false
+ ? getColor(node)
+ : // eslint-disable-next-line no-undef
+ d3.schemeGreys[5][node.depth + 1];
+ }
+
+ startResizing() {
+ this.isResizing = true;
+ this.firstload = false;
+ document.addEventListener("mousemove", this.onMouseMove);
+ document.addEventListener("mouseup", this.stopResizing);
+ }
+
+ onMouseMove = (e) => {
+ if (!this.isResizing) return;
+ const offsetRight =
+ this.$container.width() - (e.clientX - this.$container.offset().left);
+ const chartWidth = Math.max(this.$container.width() - offsetRight, 1);
+ this.$chart.css({
+ width: chartWidth,
+ flex: "",
+ });
+ this.$formview.css({
+ width: offsetRight,
+ flex: "",
+ });
+
+ // Re-render
+ this.renderChart(this.$chart.width(), this.$chart.height());
+ if (this.searchResults?.length > 0) {
+ this._applySearchResult();
+ }
+ };
+
+ stopResizing = () => {
+ this.isResizing = false;
+ document.removeEventListener("mousemove", this.onMouseMove);
+ document.removeEventListener("mouseup", this.stopResizing);
+ };
+
+ _applySearchResult() {
+ // eslint-disable-next-line no-undef
+ const groupCircles = d3.select("g#groupCircles").selectAll("g");
+
+ groupCircles.classed("matched", (d) => {
+ return this.searchResults?.some((result) => result.id === d.data.id);
+ });
+ if (this.is_grayscale_on) {
+ groupCircles.style("opacity", (d) => {
+ if (!this.searchResults) return 1;
+ return this.searchResults.some((result) => result.id === d.data.id)
+ ? 1
+ : 0.1;
+ });
+ } else {
+ groupCircles.style("filter", (_, i, nodes) => {
+ // eslint-disable-next-line no-undef
+ return d3.select(nodes[i]).classed("matched") ? null : "grayscale(1)";
+ });
+ }
+ }
+
+ onWindowResized(e) {
+ if (this.firstload) {
+ this.renderChart(this.$container.width(), this.$chart.height());
+ } else {
+ const offsetRight =
+ this.$container.width() - (e.clientX - this.$container.offset().left);
+ const chartWidth = Math.max(this.$container.width() - offsetRight, 1);
+ this.$chart.css({
+ width: chartWidth,
+ flex: "",
+ });
+ this.$formview.css({
+ width: offsetRight,
+ flex: "",
+ });
+ // Re-render
+ this.renderChart(this.$chart.width(), this.$chart.height());
+ }
+ if (this.clickedNode) {
+ this.zoomToNode(
+ this.clickedNode,
+ this.$chart.width(),
+ this.$chart.height()
+ );
+ }
+ }
+
+ get preventCreate() {
+ return this.shouldPreventAction();
+ }
+
+ get preventEdit() {
+ return this.shouldPreventAction();
+ }
+
+ shouldPreventAction() {
+ return this.isGovernanceAdmin
+ ? false
+ : !this.state.allowed_edit_governance_ids.includes(this.state.activeResId);
+ }
+
+ _addNodeShape(node) {
+ node.each((d, i, nodes) => {
+ // eslint-disable-next-line no-undef
+ const currentNode = d3.select(nodes[i]);
+ const isHexagon = d.data.shape_type === "hexagon";
+
+ const shape = this._createShape(currentNode, d, isHexagon);
+ const striped_shape = this._createStripedShape(currentNode, d, isHexagon);
+ this._attachCommonAttrs(shape);
+ if (striped_shape) {
+ this._attachCommonAttrs(striped_shape);
+ }
+ });
+ }
+
+ _createShape(currentNode, d, isHexagon) {
+ if (isHexagon) {
+ return currentNode
+ .append("polygon")
+ .attr("points", this._getPolygonPoints(d));
+ }
+ return currentNode.append("circle");
+ }
+
+ _createStripedShape(currentNode, d, isHexagon) {
+ const is_striped =
+ !d.data.is_circle &&
+ !d.data.member_rel_ids?.length > 0 &&
+ (this.is_stripe_all_roles || d.data.type_name === "structure");
+ if (is_striped) {
+ const shape = isHexagon
+ ? currentNode
+ .append("polygon")
+ .attr("points", this._getPolygonPoints(d))
+ : currentNode.append("circle");
+ return shape.attr("class", "striped");
+ }
+ return null;
+ }
+
+ _getPolygonPoints(d) {
+ return Array.from({length: 6}, (_, i) => {
+ const angle = (Math.PI / 3) * i;
+ const x = d.r * Math.cos(angle);
+ const y = d.r * Math.sin(angle);
+ return `${x},${y}`;
+ }).join(" ");
+ }
+
+ _attachCommonAttrs(shape) {
+ if (shape) {
+ shape
+ .attr("r", (d) => d.r)
+ .style("fill", (d) => this._getNodeColor(d))
+ .on("click", (ev, d) => {
+ ev.stopPropagation();
+ if (this.firstload) {
+ this.firstload = false;
+ this.$chart.css({width: "50%", flex: ""});
+ this.$formview.css({width: "50%", flex: ""});
+ this.renderChart(this.$chart.width(), this.$chart.height());
+ }
+ this.focus = d.data;
+ this.zoomToNode(
+ d.children ? d.data : d.parent.data,
+ this.$chart.width(),
+ this.$chart.height()
+ );
+ this.state.activeResId = d.data.id;
+
+ // Stroke handling
+ this.svg
+ .selectAll(".node")
+ .selectAll(`${shape._groups[0][0].nodeName}`)
+ .classed("clicked", false);
+ this.svg
+ .select("g#node-" + d.data.id)
+ .filter((nodeData) => nodeData.data.id !== 0)
+ .selectAll(`${shape._groups[0][0].nodeName}`)
+ .classed("clicked", true);
+
+ // Search handling
+ if (this.searchResults?.length > 0) {
+ this._applySearchResult();
+ }
+ });
+ }
+ }
+}
diff --git a/hr_governance/static/src/views/circlepack_renderer.xml b/hr_governance/static/src/views/circlepack_renderer.xml
new file mode 100644
index 00000000000..8b59fdd8977
--- /dev/null
+++ b/hr_governance/static/src/views/circlepack_renderer.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
diff --git a/hr_governance/static/src/views/circlepack_view.esm.js b/hr_governance/static/src/views/circlepack_view.esm.js
new file mode 100644
index 00000000000..b59ce684578
--- /dev/null
+++ b/hr_governance/static/src/views/circlepack_view.esm.js
@@ -0,0 +1,28 @@
+/** @odoo-module **/
+
+import {CirclePackController} from "./circlepack_controller.esm";
+import {CirclePackModel} from "./circlepack_model.esm";
+import {CirclePackRenderer} from "./circlepack_renderer.esm";
+import {_t} from "@web/core/l10n/translation";
+import {registry} from "@web/core/registry";
+
+export const circlepackView = {
+ type: "circle_pack",
+ display_name: _t("Circle Pack"),
+ icon: "fa fa-share-alt o_hierarchy_icon",
+ isMobileFriendly: false,
+ Controller: CirclePackController,
+ Model: CirclePackModel,
+ Renderer: CirclePackRenderer,
+ searchMenuTypes: ["filter"],
+
+ props(genericProps, view) {
+ return {
+ ...genericProps,
+ Model: view.Model,
+ Renderer: view.Renderer,
+ };
+ },
+};
+
+registry.category("views").add("circle_pack", circlepackView);
diff --git a/hr_governance/tests/__init__.py b/hr_governance/tests/__init__.py
new file mode 100644
index 00000000000..2461debad18
--- /dev/null
+++ b/hr_governance/tests/__init__.py
@@ -0,0 +1,2 @@
+from . import test_governance_circle
+from . import test_permission
diff --git a/hr_governance/tests/governance_circle_data.xml b/hr_governance/tests/governance_circle_data.xml
new file mode 100644
index 00000000000..35dee4df4a2
--- /dev/null
+++ b/hr_governance/tests/governance_circle_data.xml
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+ Internal Projects
+
+
+
+ Business Solutions
+
+
+
+ Geospatial Solutions
+
+
+
+ Infrastructure Solutions
+
+
+
+ Marketing & Communication
+
+
+
+ People & Culture
+
+
+
+ Finance
+
+
+
+ Security
+ Make sure the circle runs smoothly
+
+
+
+ Subscriptions Solutions
+
+
+
+
+
+ The Way We Work
+
+
+
+ RSE
+
+
+
+
+
+ Grammont
+
+
+
+ Montblanc
+
+
+
+ Weissenstein
+
+
+
+
+
+
+
+
+
diff --git a/hr_governance/tests/test_governance_circle.py b/hr_governance/tests/test_governance_circle.py
new file mode 100644
index 00000000000..1c6c02c55f8
--- /dev/null
+++ b/hr_governance/tests/test_governance_circle.py
@@ -0,0 +1,59 @@
+from odoo.tests.common import (
+ TransactionCase,
+)
+from odoo.tools import convert_file
+
+DATA_FILES = ["tests/governance_circle_data.xml"]
+
+
+class TestGovernanceCircle(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.load_data()
+
+ @classmethod
+ def load_data(cls):
+ for filename in DATA_FILES:
+ convert_file(
+ cls.env,
+ module="hr_governance",
+ filename=filename,
+ idref={},
+ mode="init",
+ noupdate=False,
+ kind="test",
+ )
+
+ def test_circle_automation(self):
+ """
+ When creating a circle, there should be structuring roles created automatically
+ and assigned to the circle
+ """
+ circle_1 = (
+ self.env["governance.circle"]
+ .with_context(default_is_circle=True)
+ .create(
+ {
+ "name": "Test Circle 1",
+ "parent_id": self.env.ref(
+ "hr_governance.root", raise_if_not_found=False
+ ).id,
+ }
+ )
+ )
+ structuring_roles = circle_1.child_ids.filtered(
+ lambda x: x.type_id.type == "structure"
+ )
+ self.assertTrue(
+ all(role.purpose == role.type_id.purpose for role in structuring_roles)
+ )
+ self.assertTrue(
+ all(role.authority == role.type_id.authority for role in structuring_roles)
+ )
+ self.assertTrue(
+ all(
+ role.expectation == role.type_id.expectation
+ for role in structuring_roles
+ )
+ )
diff --git a/hr_governance/tests/test_permission.py b/hr_governance/tests/test_permission.py
new file mode 100644
index 00000000000..bb1b34c84d7
--- /dev/null
+++ b/hr_governance/tests/test_permission.py
@@ -0,0 +1,262 @@
+from odoo import Command
+from odoo.tests.common import (
+ new_test_user,
+)
+
+from .test_governance_circle import TestGovernanceCircle
+
+
+class TestPermission(TestGovernanceCircle):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.users = cls._create_test_users(
+ [
+ {"login": "james", "name": "James"},
+ {"login": "another_kev", "name": "Kevin"},
+ {"login": "bob", "name": "Bob"},
+ ]
+ )
+ cls.james = cls.users["james"]
+ cls.kevin = cls.users["another_kev"]
+ cls.bob = cls.users["bob"]
+
+ cls.memory_role = cls.env.ref(
+ "hr_governance.memory_gct", raise_if_not_found=False
+ )
+ cls.memory_role.enable_edit_circle = True
+ cls.steering_role = cls.env.ref(
+ "hr_governance.steering_gct", raise_if_not_found=False
+ )
+ cls.facilitation_role = cls.env.ref(
+ "hr_governance.facilitation_gct", raise_if_not_found=False
+ )
+
+ @classmethod
+ def _create_test_users(cls, users_data):
+ """Create test users and their linked employees."""
+ res = {}
+ for user_data in users_data:
+ user = new_test_user(
+ cls.env,
+ login=user_data["login"],
+ groups="base.group_user,hr_governance.governance_group_user",
+ name=user_data["name"],
+ )
+ cls.env["hr.employee"].create(
+ {"name": user_data["name"], "user_id": user.id}
+ )
+ res[user_data["login"]] = user.with_user(user)
+ return res
+
+ def _assign_role(self, circle, role, user):
+ target_role = circle.child_ids.filtered_domain([("type_id", "=", role.id)])
+ target_role.write(
+ {"member_rel_ids": [Command.create({"member_id": user.employee_id.id})]}
+ )
+
+ def _assert_edit_access(self, circles, user, expected=True):
+ accessible_ids = set(user.allowed_edit_governance_ids.ids)
+ if len(circles) == 1:
+ circle_id = circles.id
+ if expected:
+ self.assertIn(circle_id, accessible_ids)
+ else:
+ self.assertNotIn(circle_id, accessible_ids)
+ else:
+ circle_ids = [circle.id for circle in circles]
+ if expected:
+ self.assertTrue(set(circle_ids).issubset(accessible_ids))
+ else:
+ self.assertFalse(any(id in accessible_ids for id in circle_ids))
+
+ def _assert_children_edit_access(self, circle, user, all_expected=True):
+ child_ids = circle.child_ids.ids
+ accessible_ids = set(user.allowed_edit_governance_ids.ids)
+ if all_expected:
+ self.assertTrue(set(child_ids).issubset(accessible_ids))
+ else:
+ self.assertFalse(set(child_ids).issubset(accessible_ids))
+
+ def test_01(self):
+ """The "Memory"/Steering Role is Assigned Within the Circle"""
+ test_circle = (
+ self.env["governance.circle"]
+ .with_context(default_is_circle=True)
+ .create(
+ {
+ "name": "Test Circle",
+ "parent_id": self.env.ref("hr_governance.root").id,
+ }
+ )
+ )
+ self._assign_role(test_circle, self.memory_role, self.james)
+ self._assign_role(test_circle, self.facilitation_role, self.bob)
+
+ self.env.invalidate_all()
+ self._assert_edit_access(test_circle, self.james)
+ self._assert_children_edit_access(test_circle, self.james)
+
+ self._assert_edit_access(test_circle, self.bob, False)
+ self._assert_children_edit_access(test_circle, self.bob, False)
+
+ # Steering is assigned
+ self._assign_role(test_circle, self.steering_role, self.kevin)
+ self.env.invalidate_all()
+
+ # as Memory is still assigned, steering is not allowed
+ self._assert_edit_access(test_circle, self.kevin, False)
+ self._assert_children_edit_access(test_circle, self.kevin, False)
+
+ # Un-assign Memory
+ test_circle.child_ids.filtered_domain(
+ [("type_id", "=", self.memory_role.id)]
+ ).member_rel_ids.unlink()
+ self.env.invalidate_all()
+
+ # as Memory is un-assigned, steering is allowed_edit_governance_ids
+ self._assert_edit_access(test_circle, self.kevin)
+ self._assert_children_edit_access(test_circle, self.kevin)
+
+ self._assert_edit_access(test_circle, self.james, False)
+ self.assertFalse(self.james.allowed_edit_governance_ids)
+
+ def test_02(self):
+ """Neither "Memory" nor "Steering" Roles Are Assigned in the Circle,
+ but the Parent Circle Has a "Memory"/Steering Role"""
+ circle = (
+ self.env["governance.circle"]
+ .with_context(default_is_circle=True)
+ .create(
+ {
+ "name": "Circle",
+ "parent_id": self.env.ref("hr_governance.root").id,
+ }
+ )
+ )
+ self._assign_role(circle, self.memory_role, self.james)
+ subcircle = (
+ self.env["governance.circle"]
+ .with_context(default_is_circle=True)
+ .create(
+ {
+ "name": "SubCircle",
+ "parent_id": circle.id,
+ }
+ )
+ )
+ self._assign_role(subcircle, self.facilitation_role, self.bob)
+ self.env.invalidate_all()
+
+ self._assert_edit_access(circle | subcircle, self.james)
+ self._assert_children_edit_access(circle, self.james)
+
+ self._assert_edit_access(circle | subcircle, self.bob, False)
+ self._assert_children_edit_access(subcircle, self.bob, False)
+
+ # circle has both Memory and Steering assigned,
+ # only Memory is allowed to update subcircle
+ self._assign_role(circle, self.steering_role, self.kevin)
+ self.env.invalidate_all()
+ self._assert_edit_access(circle | subcircle, self.james)
+ self._assert_children_edit_access(circle, self.james)
+
+ self._assert_edit_access(circle | subcircle, self.kevin, False)
+
+ def test_03(self):
+ """Neither "Memory" nor "Steering" Roles Are Assigned in the Circle
+ or in the Parent Circle"""
+ circle = (
+ self.env["governance.circle"]
+ .with_context(default_is_circle=True)
+ .create(
+ {
+ "name": "Circle",
+ "parent_id": self.env.ref("hr_governance.root").id,
+ }
+ )
+ )
+ self._assign_role(circle, self.facilitation_role, self.james)
+
+ subcircle = (
+ self.env["governance.circle"]
+ .with_context(default_is_circle=True)
+ .create(
+ {
+ "name": "SubCircle",
+ "parent_id": circle.id,
+ }
+ )
+ )
+ self._assign_role(subcircle, self.facilitation_role, self.bob)
+ self.env.invalidate_all()
+
+ self._assert_edit_access(circle | subcircle, self.james, False)
+ self._assert_children_edit_access(circle, self.james, False)
+
+ self._assert_edit_access(circle | subcircle, self.bob, False)
+ self._assert_children_edit_access(subcircle, self.bob, False)
+
+ def test_04(self):
+ """Complex Inheritance Chain Across Multiple Levels"""
+ circle_level_1 = (
+ self.env["governance.circle"]
+ .with_context(default_is_circle=True)
+ .create(
+ {
+ "name": "Circle level 1",
+ "parent_id": self.env.ref("hr_governance.root").id,
+ }
+ )
+ )
+ self._assign_role(circle_level_1, self.memory_role, self.james)
+
+ circle_level_2 = (
+ self.env["governance.circle"]
+ .with_context(default_is_circle=True)
+ .create(
+ {
+ "name": "Circle level 2",
+ "parent_id": circle_level_1.id,
+ }
+ )
+ )
+ self._assign_role(circle_level_2, self.steering_role, self.kevin)
+
+ circle_level_3 = (
+ self.env["governance.circle"]
+ .with_context(default_is_circle=True)
+ .create(
+ {
+ "name": "Circle level 3",
+ "parent_id": circle_level_2.id,
+ }
+ )
+ )
+ self._assign_role(circle_level_3, self.facilitation_role, self.bob)
+ self.env.invalidate_all()
+
+ # For Circle level 1
+ self._assert_edit_access(circle_level_1, self.james)
+ self.assertNotIn(
+ circle_level_1.id,
+ self.kevin.allowed_edit_governance_ids.ids
+ + self.bob.allowed_edit_governance_ids.ids,
+ )
+
+ # For Circle level 2
+ self._assert_edit_access(circle_level_2, self.kevin)
+
+ # as circle_level_2 is assigned, james cannot touch it and its subcircles
+ self._assert_edit_access(circle_level_2, self.james, False)
+ self.assertNotIn(
+ circle_level_2.child_ids.ids, self.james.allowed_edit_governance_ids.ids
+ )
+
+ # For Circle level 3
+ self._assert_edit_access(circle_level_3, self.kevin)
+ self._assert_edit_access(
+ circle_level_3 | circle_level_3.child_ids, self.james, False
+ )
+ self._assert_edit_access(circle_level_3, self.bob, False)
+ self._assert_children_edit_access(circle_level_3, self.bob, False)
diff --git a/hr_governance/views/governance_circle_member_views.xml b/hr_governance/views/governance_circle_member_views.xml
new file mode 100644
index 00000000000..d06637272ac
--- /dev/null
+++ b/hr_governance/views/governance_circle_member_views.xml
@@ -0,0 +1,29 @@
+
+
+
+ governance.circle.member.rel
+
+
+
+
+
+
+
+
+
+ governance.circle.member.rel
+
+
+
+
+
+
+
+
+
+
+ Roles - Members
+ governance.circle.member.rel
+ tree
+
+
diff --git a/hr_governance/views/governance_circle_views.xml b/hr_governance/views/governance_circle_views.xml
new file mode 100644
index 00000000000..97e96ea2181
--- /dev/null
+++ b/hr_governance/views/governance_circle_views.xml
@@ -0,0 +1,155 @@
+
+
+
+ governance.circle
+
+
+
+
+
+
+
+
+
+
+
+
+
+ governance.circle
+
+
+
+
+
+
+
+
+
+
+ governance.circle
+
+
+
+
+
+ governance.circle
+
+
+
+
+
+
+
+ governance.circle
+
+
+
+
+
+ Governance
+ governance.circle
+ circle_pack,form
+
+
+ Governance
+ governance.circle
+
+ tree,form
+
+
diff --git a/hr_governance/views/governance_role_type_views.xml b/hr_governance/views/governance_role_type_views.xml
new file mode 100644
index 00000000000..8daa7a27a4d
--- /dev/null
+++ b/hr_governance/views/governance_role_type_views.xml
@@ -0,0 +1,53 @@
+
+
+
+
+ governance.role.type
+
+
+
+
+
+
+
+ governance.role.type
+
+
+
+
+
+ Governance Role Type
+ governance.role.type
+ tree,form
+
+
diff --git a/hr_governance/views/mail_activity_views.xml b/hr_governance/views/mail_activity_views.xml
new file mode 100644
index 00000000000..496fc4b09f0
--- /dev/null
+++ b/hr_governance/views/mail_activity_views.xml
@@ -0,0 +1,45 @@
+
+
+
+ mail.activity
+
+
+
+
+
+
+
+
+
+
+
+
+ mail.activity
+
+
+
+ Circle/Role
+
+
+
+
+
+
+
+ Reorganization
+ mail.activity
+ tree
+ [('res_model', '=', "governance.circle")]
+ {'tree_view_ref': 'hr_governance.reorganization_view_list','search_view_ref':'hr_governance.reorganization_mail_activity_view_search'}
+
+
diff --git a/hr_governance/views/menu_views.xml b/hr_governance/views/menu_views.xml
new file mode 100644
index 00000000000..865f0e7ba3c
--- /dev/null
+++ b/hr_governance/views/menu_views.xml
@@ -0,0 +1,40 @@
+
+
+
+
diff --git a/hr_governance/views/res_config_settings_views.xml b/hr_governance/views/res_config_settings_views.xml
new file mode 100644
index 00000000000..2bb0dcfd968
--- /dev/null
+++ b/hr_governance/views/res_config_settings_views.xml
@@ -0,0 +1,43 @@
+
+
+
+ res.config.settings.view.form.inherit.hr.governance
+ res.config.settings
+
+
+
+
+
+
+
+
+
+
Change Chart's Color Mode
+
+
+
+
+
+
+
+
Single Member per Role
+
+
+
+
+
+
+
+
diff --git a/hr_governance/wizard/__init__.py b/hr_governance/wizard/__init__.py
new file mode 100644
index 00000000000..6c7dc5222af
--- /dev/null
+++ b/hr_governance/wizard/__init__.py
@@ -0,0 +1 @@
+from . import mail_activity_schedule
diff --git a/hr_governance/wizard/mail_activity_schedule.py b/hr_governance/wizard/mail_activity_schedule.py
new file mode 100644
index 00000000000..870c05e59d8
--- /dev/null
+++ b/hr_governance/wizard/mail_activity_schedule.py
@@ -0,0 +1,38 @@
+# Copyright 2025 Camptocamp
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import api, models
+
+
+class MailActivitySchedule(models.TransientModel):
+ _inherit = "mail.activity.schedule"
+
+ @api.depends("activity_type_id")
+ def _compute_activity_user_id(self):
+ res = super()._compute_activity_user_id()
+ reorganization_proposal = self.env.ref(
+ "hr_governance.reorganization_proposal", raise_if_not_found=False
+ )
+ if not reorganization_proposal:
+ return res
+ for scheduler in self.filtered(
+ lambda rec: rec.activity_type_id == reorganization_proposal
+ ):
+ governance_obj = self.env["governance.circle"]
+ active_id = self.env.context.get("active_id")
+ record = governance_obj.browse(active_id)
+ user_ids = record._get_steering_role_user_ids()
+ scheduler.activity_user_id = user_ids[0] if user_ids else False
+
+ @api.depends("res_model")
+ def _compute_activity_type_id(self):
+ res = super()._compute_activity_type_id()
+ reorganization_proposal = self.env.ref(
+ "hr_governance.reorganization_proposal", raise_if_not_found=False
+ )
+ if not reorganization_proposal:
+ return res
+ for scheduler in self.filtered(
+ lambda rec: rec.res_model == "governance.circle"
+ ):
+ scheduler.activity_type_id = reorganization_proposal.id
diff --git a/test-requirements.txt b/test-requirements.txt
new file mode 100644
index 00000000000..03df56c4417
--- /dev/null
+++ b/test-requirements.txt
@@ -0,0 +1,2 @@
+# For hr_governance tests
+odoo-addon-mail_activity_default_assignee @ git+https://github.com/OCA/social.git@refs/pull/1509/head#subdirectory=mail_activity_default_assignee