diff --git a/README.md b/README.md index 4e536b48..7c584fef 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ addon | version | maintainers | summary [g2p_program_registrant_info](g2p_program_registrant_info/) | 17.0.1.3.0 | | G2P Program: Registrant Info [g2p_program_reimbursement](g2p_program_reimbursement/) | 17.0.1.3.0 | | OpenG2P Programs: Reimbursement [g2p_programs](g2p_programs/) | 17.0.1.3.0 | | OpenG2P Programs +[g2p_programs_priority_list](g2p_programs_priority_list/) | 17.0.1.3.0 | | OpenG2P Programs Priority List [g2p_proxy_means_test](g2p_proxy_means_test/) | 17.0.1.3.0 | | G2P: Proxy Means Test -[g2p_reimbursement_portal](g2p_reimbursement_portal/) | 17.0.0.0.0 | | G2P Reimbursement Portal +[g2p_reimbursement_portal](g2p_reimbursement_portal/) | 17.0.1.3.0 | | G2P Reimbursement Portal [g2p_social_registry_importer](g2p_social_registry_importer/) | 17.0.1.3.0 | | Import records from Social Registry [g2p_theme](g2p_theme/) | 17.0.1.3.0 | | OpenG2P Theme diff --git a/g2p_programs_priority_list/README.md b/g2p_programs_priority_list/README.md new file mode 100644 index 00000000..3dccfde9 --- /dev/null +++ b/g2p_programs_priority_list/README.md @@ -0,0 +1,3 @@ +# OpenG2P Programs Priority List + +Refer to https://docs.openg2p.org. diff --git a/g2p_programs_priority_list/__init__.py b/g2p_programs_priority_list/__init__.py new file mode 100644 index 00000000..9b429614 --- /dev/null +++ b/g2p_programs_priority_list/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/g2p_programs_priority_list/__manifest__.py b/g2p_programs_priority_list/__manifest__.py new file mode 100644 index 00000000..890d1f90 --- /dev/null +++ b/g2p_programs_priority_list/__manifest__.py @@ -0,0 +1,28 @@ +{ + "name": "OpenG2P Programs Priority List", + "category": "G2P/G2P", + "version": "17.0.1.3.0", + "sequence": 1, + "author": "OpenG2P", + "website": "https://openg2p.org", + "license": "LGPL-3", + "depends": ["g2p_programs"], + "data": [ + "security/ir.model.access.csv", + "views/cycle_view.xml", + "views/program_manager_view.xml", + "views/cycle_manager_view.xml", + "views/cycle_membership_view.xml", + "wizard/create_cycle_wizard.xml", + ], + "assets": { + "web.assets_backend": [ + "/g2p_programs_priority_list/static/src/css/style.css", + ], + }, + "demo": [], + "images": [], + "application": True, + "installable": True, + "auto_install": False, +} diff --git a/g2p_programs_priority_list/models/__init__.py b/g2p_programs_priority_list/models/__init__.py new file mode 100644 index 00000000..648b8d3d --- /dev/null +++ b/g2p_programs_priority_list/models/__init__.py @@ -0,0 +1,6 @@ +from . import cycle +from . import cycle_manager +from . import programs +from . import program_manager +from . import cycle_membership +from . import sorting_criteria diff --git a/g2p_programs_priority_list/models/cycle.py b/g2p_programs_priority_list/models/cycle.py new file mode 100644 index 00000000..68520181 --- /dev/null +++ b/g2p_programs_priority_list/models/cycle.py @@ -0,0 +1,20 @@ +from odoo import api, fields, models + + +class G2PCycleInherited(models.Model): + _inherit = "g2p.cycle" + + inclusion_limit = fields.Integer(default=0) + eligibility_domain = fields.Text(string="Domain", default="[]") + is_not_disbursement = fields.Boolean(string="Not Disbursement", default=True) + sorting_criteria_ids = fields.One2many("g2p.sorting.criteria", "cycle_id", string="Sorting Order") + + @api.model + def create(self, vals): + if "program_id" in vals: + program = self.env["g2p.program"].browse(vals["program_id"]) + if program.program_managers.manager_ref_id: + vals[ + "is_not_disbursement" + ] = not program.program_managers.manager_ref_id.is_disbursement_through_priority_list + return super().create(vals) diff --git a/g2p_programs_priority_list/models/cycle_manager.py b/g2p_programs_priority_list/models/cycle_manager.py new file mode 100644 index 00000000..1ecb7325 --- /dev/null +++ b/g2p_programs_priority_list/models/cycle_manager.py @@ -0,0 +1,152 @@ +import logging + +from odoo import _, api, fields, models +from odoo.tools.safe_eval import safe_eval + +_logger = logging.getLogger(__name__) + + +class DefaultCycleManagerInherited(models.Model): + _inherit = "g2p.cycle.manager.default" + + eligibility_domain = fields.Text(string="Domain", default="[]") + inclusion_limit = fields.Integer(default=0) + + sorting_criteria_ids = fields.One2many("g2p.sorting.criteria", "manager_id", string="Sorting Order") + + @api.model + def create(self, vals): + if "program_id" in vals: + vals[ + "eligibility_domain" + ] = f"[('program_membership_ids.program_id', 'in', [{vals['program_id']}])]" + return super().create(vals) + + def new_cycle(self, name, new_start_date, sequence): + cycle = super().new_cycle(name, new_start_date, sequence) + + for rec in self: + is_disbursement = ( + self.program_id.program_managers.manager_ref_id.is_disbursement_through_priority_list + ) + if is_disbursement: + for sorting_criterion in self.sorting_criteria_ids: + self.env["g2p.sorting.criteria"].create( + { + "cycle_id": cycle.id, + "sequence": sorting_criterion.sequence, + "field_name": sorting_criterion.field_name.id, + "order": sorting_criterion.order, + } + ) + + cycle.write( + {"inclusion_limit": rec.inclusion_limit, "eligibility_domain": rec.eligibility_domain} + ) + return cycle + + def add_beneficiaries(self, cycle, beneficiaries, state="draft"): + self.ensure_one() + self._ensure_can_edit_cycle(cycle) + _logger.debug("Adding beneficiaries to the cycle %s", cycle.name) + _logger.debug("Beneficiaries: %s", len(beneficiaries)) + + # Only add beneficiaries not added yet + existing_ids = cycle.cycle_membership_ids.mapped("partner_id.id") + _logger.debug("Existing IDs: %s", len(existing_ids)) + beneficiaries = list(set(beneficiaries) - set(existing_ids)) + + is_disbursement = ( + self.program_id.program_managers.manager_ref_id.is_disbursement_through_priority_list + ) + if is_disbursement: + # Convert beneficiaries to recordset first + if beneficiaries: + if isinstance(beneficiaries, list): + ids = beneficiaries + else: + ids = beneficiaries.mapped("partner_id.id") + + domain = safe_eval(cycle.eligibility_domain) + + domain += [("id", "in", ids), ("disabled", "=", False)] + if self.program_id.target_type == "group": + domain += [("is_group", "=", True), ("is_registrant", "=", True)] + if self.program_id.target_type == "individual": + domain += [("is_group", "=", False), ("is_registrant", "=", True)] + + remaining_limit = max(0, cycle.inclusion_limit - len(existing_ids)) + + sorted_criteria = cycle.sorting_criteria_ids.sorted("sequence") + + order = [] + for criterion in sorted_criteria: + field_name = criterion.field_name.name + reverse_flag = criterion.order == "desc" + order_direction = "desc" if reverse_flag else "asc" + order.append(f"{field_name} {order_direction}") + + # Join the order list into a comma-separated string + order_str = ",".join(order) + + # Query partners with sorting applied + sorted_beneficiaries = self.env["res.partner"].search(domain, order=order_str) + + if len(sorted_beneficiaries) > remaining_limit: + beneficiaries = sorted_beneficiaries[:remaining_limit].ids + else: + beneficiaries = sorted_beneficiaries.ids + + if len(beneficiaries) == 0: + message = _("No beneficiaries to import.") + kind = "warning" + sticky = False + elif len(beneficiaries) < self.MIN_ROW_JOB_QUEUE: + self._add_beneficiaries(cycle, beneficiaries, state, do_count=True) + message = _("%s beneficiaries imported.", len(beneficiaries)) + kind = "success" + sticky = False + else: + self._add_beneficiaries_async(cycle, beneficiaries, state) + message = _("Import of %s beneficiaries started.", len(beneficiaries)) + kind = "warning" + sticky = True + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Enrollment"), + "message": message, + "sticky": sticky, + "type": kind, + "next": { + "type": "ir.actions.act_window_close", + }, + }, + } + + def _add_beneficiaries(self, cycle, beneficiaries, state="draft", do_count=False): + """Add Beneficiaries with Rank + + :param cycle: Recordset of cycle + :param beneficiaries: Recordset of beneficiaries + :param state: String state to be set to beneficiary + :return: None + """ + new_beneficiaries = [] + for index, r in enumerate(beneficiaries): + new_beneficiaries.append( + [ + 0, + 0, + { + "partner_id": r, + "enrollment_date": fields.Date.today(), + "state": state, + "rank": index + 1, + }, + ] + ) + cycle.update({"cycle_membership_ids": new_beneficiaries}) + cycle._compute_members_count() diff --git a/g2p_programs_priority_list/models/cycle_membership.py b/g2p_programs_priority_list/models/cycle_membership.py new file mode 100644 index 00000000..1447668b --- /dev/null +++ b/g2p_programs_priority_list/models/cycle_membership.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class G2PCycleMembershipInherited(models.Model): + _inherit = "g2p.cycle.membership" + + rank = fields.Integer(index=True) diff --git a/g2p_programs_priority_list/models/program_manager.py b/g2p_programs_priority_list/models/program_manager.py new file mode 100644 index 00000000..94ec21be --- /dev/null +++ b/g2p_programs_priority_list/models/program_manager.py @@ -0,0 +1,48 @@ +import logging +from datetime import datetime, timedelta + +from odoo import fields, models + +from odoo.addons.g2p_programs.models.programs import G2PProgram + +_logger = logging.getLogger(__name__) + + +class DefaultProgramManagerInherited(models.Model): + _inherit = "g2p.program.manager.default" + + is_disbursement_through_priority_list = fields.Boolean( + string="Disbursement Through Priority List", default=False + ) + + def new_cycle(self): + """ + Create the next cycle of the program. + If `is_disbursement_through_priority_list` is False, it copies enrolled beneficiaries. + """ + self.ensure_one() + + for rec in self: + cycles = self.env["g2p.cycle"].search([("program_id", "=", rec.program_id.id)]) + _logger.debug("Cycles found: %s", cycles) + + cm = rec.program_id.get_manager(G2PProgram.MANAGER_CYCLE) + + if not cycles: + _logger.debug("Creating first cycle with cycle manager: %s", cm) + new_cycle = cm.new_cycle("Cycle 1", datetime.now(), 1) + else: + last_cycle = rec.last_cycle() + new_sequence = last_cycle.sequence + 1 + start_date = last_cycle.end_date + timedelta(days=1) + new_cycle = cm.new_cycle(f"Cycle {new_sequence}", start_date, new_sequence) + + # Only copy beneficiaries if disbursement is NOT based on priority list + if not rec.is_disbursement_through_priority_list: + if new_cycle: + program_beneficiaries = rec.program_id.get_beneficiaries("enrolled").mapped( + "partner_id.id" + ) + cm.add_beneficiaries(new_cycle, program_beneficiaries, "enrolled") + + return new_cycle diff --git a/g2p_programs_priority_list/models/programs.py b/g2p_programs_priority_list/models/programs.py new file mode 100644 index 00000000..df6681b6 --- /dev/null +++ b/g2p_programs_priority_list/models/programs.py @@ -0,0 +1,76 @@ +import logging + +from odoo import _, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class G2PProgramInherit(models.Model): + _inherit = "g2p.program" + + def create_new_cycle(self): + if self.beneficiaries_count <= 0: + raise UserError( + _("No enrolled registrants. Enroll registrants to program to create a new cycle.") + ) + + for rec in self: + message = None + kind = "success" + cycle_manager = rec.get_manager(self.MANAGER_CYCLE) + program_manager = rec.get_manager(self.MANAGER_PROGRAM) + + if not cycle_manager: + raise UserError(_("No Cycle Manager defined.")) + if not program_manager: + raise UserError(_("No Program Manager defined.")) + + _logger.debug("-" * 80) + _logger.debug("pm: %s", program_manager) + + new_cycle = program_manager.new_cycle() + message = _("New cycle %s created.", new_cycle.name) + + if new_cycle.is_not_disbursement: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Cycle"), + "message": message, + "sticky": False, + "type": kind, + "next": { + "type": "ir.actions.act_window_close", + }, + }, + } + else: + wizard = self.env["cycle.creation.wizard"].create( + { + "cycle_id": new_cycle.id, + "name": new_cycle.name, + "program_id": new_cycle.program_id.id, + "eligibility_domain": new_cycle.eligibility_domain, + "inclusion_limit": new_cycle.inclusion_limit, + } + ) + + for criterion in new_cycle.sorting_criteria_ids: + self.env["cycle.creation.wizard.criteria"].create( + { + "wizard_id": wizard.id, + "field_name": criterion.field_name.id, + "order": criterion.order, + "sequence": criterion.sequence, + } + ) + return { + "name": _("Update Priority Configuration"), + "type": "ir.actions.act_window", + "res_model": "cycle.creation.wizard", + "view_mode": "form", + "res_id": wizard.id, + "target": "new", + } diff --git a/g2p_programs_priority_list/models/sorting_criteria.py b/g2p_programs_priority_list/models/sorting_criteria.py new file mode 100644 index 00000000..35ab6ec5 --- /dev/null +++ b/g2p_programs_priority_list/models/sorting_criteria.py @@ -0,0 +1,17 @@ +from odoo import fields, models + + +class SortingCriteria(models.Model): + _name = "g2p.sorting.criteria" + _description = "Sorting Criteria" + + cycle_id = fields.Many2one("g2p.cycle", string="Cycle") + manager_id = fields.Many2one("g2p.cycle.manager.default", string="Cycle Manager") + + sequence = fields.Integer() + field_name = fields.Many2one( + "ir.model.fields", + domain="[('model', '=', 'res.partner')]", + help="Select a field from res.partner for sorting", + ) + order = fields.Selection([("asc", "Ascending"), ("desc", "Descending")], required=True) diff --git a/g2p_programs_priority_list/pyproject.toml b/g2p_programs_priority_list/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/g2p_programs_priority_list/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/g2p_programs_priority_list/security/ir.model.access.csv b/g2p_programs_priority_list/security/ir.model.access.csv new file mode 100644 index 00000000..4924f56b --- /dev/null +++ b/g2p_programs_priority_list/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +g2p_cycle_creation_wizard_admin,Cycle Creation Wizard Admin Access,g2p_programs_priority_list.model_cycle_creation_wizard,g2p_registry_base.group_g2p_admin,1,1,1,1 +g2p_cycle_creation_wizard_program_manager,Cycle Creation Wizard Program Manager Access,g2p_programs_priority_list.model_cycle_creation_wizard,g2p_programs.g2p_program_manager,1,1,1,1 +g2p_cycle_creation_wizard_program_validator,Cycle Creation Wizard Program Validator Access,g2p_programs_priority_list.model_cycle_creation_wizard,g2p_programs.g2p_program_validator,1,1,1,0 + +g2p_sorting_criteria_admin,Sorting Criteria Admin Access,model_g2p_sorting_criteria,g2p_registry_base.group_g2p_admin,1,1,1,1 +g2p_sorting_criteria_program_manager,Sorting Criteria Program Manager Access,model_g2p_sorting_criteria,g2p_programs.g2p_program_manager,1,1,1,1 +g2p_sorting_criteria_program_validator,Sorting Criteria Program Validator Access,model_g2p_sorting_criteria,g2p_programs.g2p_program_validator,1,1,1,0 + +g2p_cycle_creation_wizard_criteria_admin,Cycle Creation Wizard Criteria Admin Access,g2p_programs_priority_list.model_cycle_creation_wizard_criteria,g2p_registry_base.group_g2p_admin,1,1,1,1 +g2p_cycle_creation_wizard_criteria_program_manager,Cycle Creation Wizard Criteria Program Manager Access,g2p_programs_priority_list.model_cycle_creation_wizard_criteria,g2p_programs.g2p_program_manager,1,1,1,1 +g2p_cycle_creation_wizard_criteria_program_validator,Cycle Creation Wizard Criteria Program Validator Access,g2p_programs_priority_list.model_cycle_creation_wizard_criteria,g2p_programs.g2p_program_validator,1,1,1,0 diff --git a/g2p_programs_priority_list/static/description/icon.png b/g2p_programs_priority_list/static/description/icon.png new file mode 100644 index 00000000..5ecb429e Binary files /dev/null and b/g2p_programs_priority_list/static/description/icon.png differ diff --git a/g2p_programs_priority_list/static/src/css/style.css b/g2p_programs_priority_list/static/src/css/style.css new file mode 100644 index 00000000..1cd1c5e9 --- /dev/null +++ b/g2p_programs_priority_list/static/src/css/style.css @@ -0,0 +1,25 @@ +.o_list_view .o_list_table thead > tr > th.o_list_number_th { + text-align: left !important; +} + +.o_list_view .o_list_table tbody > tr > td.o_list_number { + text-align: left !important; +} + +.o_list_view .o_list_table th.o_list_number_th .d-block { + text-align: left !important; +} + +.o_list_view .o_list_table thead > tr > th .d-flex { + flex-direction: row !important; +} + +.o_list_view .o_list_table thead > tr > th[data-name="sequence"] { + width: 119px !important; + min-width: 119px !important; + max-width: 119px !important; +} + +.o-checkbox.form-check.form-switch { + display: none; +} diff --git a/g2p_programs_priority_list/views/cycle_manager_view.xml b/g2p_programs_priority_list/views/cycle_manager_view.xml new file mode 100644 index 00000000..6359600f --- /dev/null +++ b/g2p_programs_priority_list/views/cycle_manager_view.xml @@ -0,0 +1,28 @@ + + + + + Cycle Manager Default Form + g2p.cycle.manager.default + + + + + + + + + + + + + + + + + + + + diff --git a/g2p_programs_priority_list/views/cycle_membership_view.xml b/g2p_programs_priority_list/views/cycle_membership_view.xml new file mode 100644 index 00000000..9f2c4ee5 --- /dev/null +++ b/g2p_programs_priority_list/views/cycle_membership_view.xml @@ -0,0 +1,16 @@ + + + + + view_cycle_membership_tree + g2p.cycle.membership + + + + + + + + diff --git a/g2p_programs_priority_list/views/cycle_view.xml b/g2p_programs_priority_list/views/cycle_view.xml new file mode 100644 index 00000000..ef932f91 --- /dev/null +++ b/g2p_programs_priority_list/views/cycle_view.xml @@ -0,0 +1,32 @@ + + + + + view_cycle_form_inherit + g2p.cycle + + + + + + + + + + + + + + + + + + + diff --git a/g2p_programs_priority_list/views/program_manager_view.xml b/g2p_programs_priority_list/views/program_manager_view.xml new file mode 100644 index 00000000..701d314b --- /dev/null +++ b/g2p_programs_priority_list/views/program_manager_view.xml @@ -0,0 +1,16 @@ + + + + + view_program_manager_default_form_inherit + g2p.program.manager.default + + + + + + + + diff --git a/g2p_programs_priority_list/wizard/__init__.py b/g2p_programs_priority_list/wizard/__init__.py new file mode 100644 index 00000000..4e629448 --- /dev/null +++ b/g2p_programs_priority_list/wizard/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenG2P. See LICENSE file for full copyright and licensing details. + +from . import create_cycle_wizard +from . import create_sorting_wizard diff --git a/g2p_programs_priority_list/wizard/create_cycle_wizard.py b/g2p_programs_priority_list/wizard/create_cycle_wizard.py new file mode 100644 index 00000000..6c5f2ff1 --- /dev/null +++ b/g2p_programs_priority_list/wizard/create_cycle_wizard.py @@ -0,0 +1,61 @@ +# Part of OpenG2P. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class CycleCreationWizard(models.TransientModel): + _name = "cycle.creation.wizard" + _description = "Cycle Creation Wizard" + + cycle_id = fields.Many2one("g2p.cycle", string="Cycle", readonly=True) + name = fields.Char(string="Cycle Name", required=True, readonly=True) + program_id = fields.Many2one("g2p.program", string="Program", required=True, readonly=True) + + inclusion_limit = fields.Integer(default=0) + eligibility_domain = fields.Text(string="Domain", default="[]") + + sorting_criteria_ids = fields.One2many( + "cycle.creation.wizard.criteria", "wizard_id", string="Sorting Order" + ) + + def action_confirm(self): + if self.cycle_id: + sorting_criteria = self.sorting_criteria_ids + cycle_sorting_criteria = self.cycle_id.sorting_criteria_ids + + # Store existing field names to check which ones are removed + new_field_names = sorting_criteria.mapped("field_name.id") + + for criterion in sorting_criteria: + existing_criteria = cycle_sorting_criteria.filtered( + lambda c: c.field_name.id == criterion.field_name.id + ) + + if existing_criteria: + existing_criteria.write( + { + "order": criterion.order, + "sequence": criterion.sequence, + } + ) + else: + self.env["g2p.sorting.criteria"].create( + { + "cycle_id": self.cycle_id.id, + "field_name": criterion.field_name.id, + "order": criterion.order, + "sequence": criterion.sequence, + } + ) + + # Remove criteria that are no longer in sorting_criteria + to_remove = cycle_sorting_criteria.filtered(lambda c: c.field_name.id not in new_field_names) + if to_remove: + to_remove.unlink() + + self.cycle_id.write( + {"inclusion_limit": self.inclusion_limit, "eligibility_domain": self.eligibility_domain} + ) + + self.cycle_id.copy_beneficiaries_from_program() + return {"type": "ir.actions.act_window_close"} diff --git a/g2p_programs_priority_list/wizard/create_cycle_wizard.xml b/g2p_programs_priority_list/wizard/create_cycle_wizard.xml new file mode 100644 index 00000000..f3ce1d45 --- /dev/null +++ b/g2p_programs_priority_list/wizard/create_cycle_wizard.xml @@ -0,0 +1,44 @@ + + + + + cycle.creation.wizard.form + cycle.creation.wizard + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Cycle Created + cycle.creation.wizard + form + + new + +
diff --git a/g2p_programs_priority_list/wizard/create_sorting_wizard.py b/g2p_programs_priority_list/wizard/create_sorting_wizard.py new file mode 100644 index 00000000..99bbc796 --- /dev/null +++ b/g2p_programs_priority_list/wizard/create_sorting_wizard.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class CycleCreationWizardCriteria(models.TransientModel): + _name = "cycle.creation.wizard.criteria" + _description = "Temporary Sorting Criteria for Cycle Creation Wizard" + + wizard_id = fields.Many2one("cycle.creation.wizard", string="Wizard", ondelete="cascade") + field_name = fields.Many2one( + "ir.model.fields", + domain="[('model', '=', 'res.partner')]", + help="Select a field from res.partner for sorting", + ) + order = fields.Selection([("asc", "Ascending"), ("desc", "Descending")], required=True) + sequence = fields.Integer()