11# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+ import logging
23
34from lxml import etree
45
78
89from . import constants
910
11+ _logger = logging .getLogger (__name__ )
12+
1013
1114class SPPProgramMembership (models .Model ):
1215 _inherit = [
@@ -345,26 +348,26 @@ def action_exit(self):
345348 }
346349 )
347350
348- @api .model_create_multi
349- def bulk_create_memberships (self , vals_list , chunk_size = 1000 ):
351+ @api .model
352+ def bulk_create_memberships (self , vals_list , chunk_size = 1000 , skip_duplicates = False ):
350353 """Create program memberships in bulk with optional chunking.
351354
352355 This helper is intended for large enrollment jobs (e.g. CEL-driven
353356 bulk enrollment) where thousands of memberships need to be created
354357 in a single operation.
355358
356- It preserves the normal create() semantics, including:
357- - standard ORM validations and constraints
358- - audit logging (via spp_audit rules)
359- - source tracking mixins
360-
361- The only optimisation is to:
362- - accept already-prepared value dicts
363- - optionally split very large batches into smaller chunks to keep
364- memory use and per-transaction work bounded.
359+ :param vals_list: List of dicts with membership values
360+ :param chunk_size: Number of records per batch (default 1000)
361+ :param skip_duplicates: When True, use INSERT ... ON CONFLICT DO NOTHING
362+ to silently skip duplicate (partner_id, program_id) pairs instead of
363+ raising IntegrityError. Returns the count of inserted rows.
364+ :return: Recordset (skip_duplicates=False) or int count (skip_duplicates=True)
365365 """
366366 if not vals_list :
367- return self .env ["spp.program.membership" ]
367+ return 0 if skip_duplicates else self .env ["spp.program.membership" ]
368+
369+ if skip_duplicates :
370+ return self ._bulk_insert_on_conflict (vals_list , chunk_size )
368371
369372 if chunk_size and chunk_size > 0 :
370373 all_memberships = self .env ["spp.program.membership" ]
@@ -386,3 +389,60 @@ def bulk_create_memberships(self, vals_list, chunk_size=1000):
386389 SPPProgramMembership ,
387390 self .sudo (), # nosemgrep: odoo-sudo-without-context
388391 ).create (vals_list )
392+
393+ def _bulk_insert_on_conflict (self , vals_list , chunk_size = 1000 ):
394+ """Insert memberships using raw SQL with ON CONFLICT DO NOTHING.
395+
396+ Bypasses ORM for maximum throughput during bulk enrollment. Duplicates
397+ (matching the UNIQUE constraint on partner_id, program_id) are silently
398+ skipped.
399+
400+ :param vals_list: List of dicts with at least partner_id, program_id, state
401+ :param chunk_size: Number of records per SQL INSERT batch
402+ :return: Total number of rows actually inserted
403+ """
404+ cr = self .env .cr
405+ uid = self .env .uid
406+ total_inserted = 0
407+
408+ now = fields .Datetime .now ()
409+
410+ for i in range (0 , len (vals_list ), chunk_size ):
411+ batch = vals_list [i : i + chunk_size ]
412+ values = []
413+ params = []
414+ for v in batch :
415+ state = v .get ("state" , "draft" )
416+ enrollment_date = now if state == "enrolled" else None
417+ values .append ("(%s, %s, %s, %s, %s, %s, %s, now(), now())" )
418+ params .extend (
419+ [
420+ v ["partner_id" ],
421+ v ["program_id" ],
422+ state ,
423+ enrollment_date ,
424+ v .get ("deduplication_status" , "new" ),
425+ uid ,
426+ uid ,
427+ ]
428+ )
429+
430+ sql = """
431+ INSERT INTO spp_program_membership
432+ (partner_id, program_id, state, enrollment_date,
433+ deduplication_status,
434+ create_uid, write_uid, create_date, write_date)
435+ VALUES {}
436+ ON CONFLICT (partner_id, program_id) DO NOTHING
437+ """ .format ( # noqa: S608 # nosec B608
438+ ", " .join (values )
439+ )
440+ cr .execute (sql , params )
441+ total_inserted += cr .rowcount
442+
443+ _logger .info (
444+ "Bulk inserted %d program memberships (%d skipped as duplicates)" ,
445+ total_inserted ,
446+ len (vals_list ) - total_inserted ,
447+ )
448+ return total_inserted
0 commit comments