import zoneinfo from django.db.models.signals import pre_save, post_save, post_delete from django.dispatch import receiver from django.db import models from django.contrib.auth.models import User import json import urllib.parse import urllib.request import logging from decimal import Decimal from django.conf import settings import re logger = logging.getLogger(__name__) def format_phone_number(phone): """Formats a phone number to (xxx) xxx-xxxx if it has 10 digits or 11 starting with 1.""" if not phone: return phone digits = re.sub(r'\D', '', str(phone)) if len(digits) == 10: return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}" elif len(digits) == 11 and digits.startswith('1'): return f"({digits[1:4]}) {digits[4:7]}-{digits[7:]}" return phone class Tenant(models.Model): name = models.CharField(max_length=100) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.name class TenantUserRole(models.Model): ROLE_CHOICES = [ ('system_admin', 'System Administrator'), ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff'), ] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tenant_roles') tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='user_roles') role = models.CharField(max_length=20, choices=ROLE_CHOICES) class Meta: unique_together = ('user', 'tenant', 'role') def __str__(self): return f"{self.user.username} - {self.tenant.name} ({self.role})" class InteractionType(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='interaction_types') name = models.CharField(max_length=100) is_active = models.BooleanField(default=True) class Meta: unique_together = ('tenant', 'name') def __str__(self): return self.name class DonationMethod(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='donation_methods') name = models.CharField(max_length=100) is_active = models.BooleanField(default=True) class Meta: unique_together = ('tenant', 'name') def __str__(self): return self.name class ElectionType(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='election_types') name = models.CharField(max_length=100) is_active = models.BooleanField(default=True) class Meta: unique_together = ('tenant', 'name') def __str__(self): return self.name class ParticipationStatus(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='participation_statuses') name = models.CharField(max_length=100) is_active = models.BooleanField(default=True) class Meta: unique_together = ('tenant', 'name') verbose_name_plural = 'Participation Statuses' def __str__(self): return self.name class VolunteerRole(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteer_roles') name = models.CharField(max_length=100) is_active = models.BooleanField(default=True) class Meta: unique_together = ('tenant', 'name') def __str__(self): return self.name class EventType(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types') name = models.CharField(max_length=100) available_roles = models.ManyToManyField(VolunteerRole, blank=True, related_name='event_types') default_volunteer_role = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_event_types") is_active = models.BooleanField(default=True) class Meta: unique_together = ('tenant', 'name') def __str__(self): return self.name class Interest(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='interests') name = models.CharField(max_length=100) class Meta: unique_together = ('tenant', 'name') def __str__(self): return self.name class Voter(models.Model): CANDIDATE_SUPPORT_CHOICES = [ ('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting'), ] YARD_SIGN_CHOICES = [ ('none', 'None'), ('wants', 'Wants a yard sign'), ('wants_large', 'Wants a Large Sign'), ('has', 'Has a yard sign'), ('has_large', 'Has a Large Sign'), ] WINDOW_STICKER_CHOICES = [ ('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker'), ] PHONE_TYPE_CHOICES = [ ('home', 'Home Phone'), ('cell', 'Cell Phone'), ('work', 'Work Phone'), ] CALL_QUEUE_STATUS_CHOICES = [ ('no_call_required', 'No Call Required'), ('to_be_called', 'To Be Called'), ('in_call_queue', 'In Call Queue'), ('called', 'Called'), ] tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='voters') voter_id = models.CharField(max_length=50, blank=True, db_index=True) first_name = models.CharField(max_length=100, db_index=True) last_name = models.CharField(max_length=100, db_index=True) nickname = models.CharField(max_length=100, blank=True) birthdate = models.DateField(null=True, blank=True, db_index=True) address = models.TextField(blank=True) address_street = models.CharField(max_length=255, blank=True, db_index=True) city = models.CharField(max_length=100, blank=True, db_index=True) state = models.CharField(max_length=2, blank=True, db_index=True) prior_state = models.CharField(max_length=2, blank=True) zip_code = models.CharField(max_length=20, blank=True, db_index=True) county = models.CharField(max_length=100, blank=True) latitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) longitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) phone = models.CharField(max_length=20, blank=True) phone_type = models.CharField(max_length=10, choices=PHONE_TYPE_CHOICES, default='cell') secondary_phone = models.CharField(max_length=20, blank=True) secondary_phone_type = models.CharField(max_length=10, choices=PHONE_TYPE_CHOICES, default="cell") email = models.EmailField(blank=True) district = models.CharField(max_length=100, blank=True, db_index=True) precinct = models.CharField(max_length=100, blank=True, db_index=True) registration_date = models.DateField(null=True, blank=True) is_targeted = models.BooleanField(default=False, db_index=True) target_door_visit = models.BooleanField(default=False, db_index=True) candidate_support = models.CharField(max_length=20, choices=CANDIDATE_SUPPORT_CHOICES, default='unknown', db_index=True) yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none', db_index=True) ever_had_yard_sign = models.BooleanField(default=False, db_index=True) ever_had_large_sign = models.BooleanField(default=False, db_index=True) window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status', db_index=True) notes = models.TextField(blank=True) door_visit = models.BooleanField(default=False, db_index=True) neighborhood = models.CharField(max_length=100, blank=True, db_index=True) is_inactive = models.BooleanField(default=False, db_index=True) call_queue_status = models.CharField(max_length=20, choices=CALL_QUEUE_STATUS_CHOICES, default='no_call_required', db_index=True) voted = models.BooleanField(default=False, db_index=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ models.Index(fields=['tenant', 'address_street', 'city', 'state', 'zip_code']), models.Index(fields=['tenant', 'is_inactive', 'door_visit', 'target_door_visit']), models.Index(fields=['tenant', 'last_name', 'first_name']), ] def geocode_address(self, use_fallback=True): """ Attempts to geocode the voter's address using Google Maps API. Returns (success, error_message). """ if not self.address: return False, "No address provided." api_key = getattr(settings, 'GOOGLE_MAPS_API_KEY', None) if not api_key: return False, "Google Maps API Key not configured." def _fetch(addr): try: query = urllib.parse.quote(addr) url = f"https://maps.googleapis.com/maps/api/geocode/json?address={query}&key={api_key}" req = urllib.request.Request(url) with urllib.request.urlopen(req, timeout=10) as response: data = json.loads(response.read().decode()) if data.get('status') == 'OK': result = data['results'][0] return result['geometry']['location']['lat'], result['geometry']['location']['lng'], None elif data.get('status') == 'ZERO_RESULTS': return None, None, "No results found." elif data.get('status') == 'OVER_QUERY_LIMIT': return None, None, "Query limit exceeded." elif data.get('status') == 'REQUEST_DENIED': return None, None, f"Request denied: {data.get('error_message', 'No message')}" elif data.get('status') == 'INVALID_REQUEST': return None, None, "Invalid request." else: return None, None, f"Google Maps Error: {data.get('status')}" except Exception as e: return None, None, str(e) logger.info(f"Geocoding with Google Maps: {self.address}") lat, lon, err = _fetch(self.address) if not lat and use_fallback: # Try fallback: City, State, Zip fallback_parts = [self.city, self.state, self.zip_code] fallback_addr = ", ".join([p for p in fallback_parts if p]) if fallback_addr and fallback_addr != self.address: logger.info(f"Geocoding fallback: {fallback_addr}") lat, lon, fallback_err = _fetch(fallback_addr) if lat: err = None # Clear previous error if fallback works if lat and lon: # Truncate coordinates to 12 characters as requested self.latitude = Decimal(str(lat)[:12]) self.longitude = Decimal(str(lon)[:12]) logger.info(f"Geocoding success: {self.latitude}, {self.longitude}") return True, None logger.warning(f"Geocoding failed for {self.address}: {err}") return False, err def save(self, *args, **kwargs): if self.yard_sign in ['has', 'wants']: self.ever_had_yard_sign = True elif self.yard_sign in ['has_large', 'wants_large']: self.ever_had_large_sign = True skip_geocode = kwargs.pop("skip_geocode", False) or getattr(self, "_skip_geocode", False) update_fields = kwargs.get('update_fields') # Auto-format phone number self.phone = format_phone_number(self.phone) self.secondary_phone = format_phone_number(self.secondary_phone) # Ensure coordinates are truncated to 12 characters before saving if self.latitude: self.latitude = Decimal(str(self.latitude)[:12]) if self.longitude: self.longitude = Decimal(str(self.longitude)[:12]) # Auto concatenation: address street, city, state, zip parts = [self.address_street, self.city, self.state, self.zip_code] self.address = ", ".join([p for p in parts if p]) # Change detection should_geocode = False # Detect manual change of target_door_visit if self.pk: orig = getattr(self, "_orig_obj", None) if not orig: try: orig = Voter.objects.get(pk=self.pk) except Voter.DoesNotExist: orig = None if orig: self._orig_obj = orig # Cache it for geocoding check and signals if not orig.target_door_visit and self.target_door_visit: # User manually checked the box (or changed it to True) self._target_door_visit_manually_set = True # If update_fields is set and doesn't include address components, skip geocode if update_fields: addr_fields = {'address_street', 'city', 'state', 'zip_code', 'latitude', 'longitude'} if not addr_fields.intersection(update_fields): skip_geocode = True if not skip_geocode: if not self.pk: # New record # Only auto-geocode if coordinates were not already provided if self.latitude is None or self.longitude is None: should_geocode = True else: orig = getattr(self, "_orig_obj", None) # Already set above but being safe if orig: # Detect if address components changed address_changed = (self.address_street != orig.address_street or self.city != orig.city or self.state != orig.state or self.zip_code != orig.zip_code) coords_provided = (self.latitude != orig.latitude or self.longitude != orig.longitude) # If specifically provided in import, treat as provided even if same as DB if getattr(self, "_coords_provided_in_import", False): coords_provided = True # Auto-geocode if address changed AND coordinates were NOT manually updated if address_changed and not coords_provided: should_geocode = True # Auto-geocode if coordinates are still missing and were not just provided if (self.latitude is None or self.longitude is None) and not coords_provided: should_geocode = True else: should_geocode = True if not skip_geocode and should_geocode and self.address: # We don't want to block save if geocoding fails, so we just call it self.geocode_address() super().save(*args, **kwargs) def __str__(self): return f"{self.first_name} {self.last_name}" class VotingRecord(models.Model): voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='voting_records') election_date = models.DateField() election_description = models.CharField(max_length=255) primary_party = models.CharField(max_length=100, blank=True) def __str__(self): return f"{self.voter} - {self.election_description}" class Event(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='events') name = models.CharField(max_length=255, db_index=True) date = models.DateField() start_time = models.TimeField(null=True, blank=True) end_time = models.TimeField(null=True, blank=True) event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True) default_volunteer_role = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name='default_for_events') description = models.TextField(blank=True) location_name = models.CharField(max_length=255, blank=True) address = models.CharField(max_length=255, blank=True) city = models.CharField(max_length=100, blank=True) state = models.CharField(max_length=2, blank=True) zip_code = models.CharField(max_length=20, blank=True) latitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) longitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) class Meta: unique_together = ('tenant', 'name') def save(self, *args, **kwargs): skip_geocode = kwargs.pop("skip_geocode", False) or getattr(self, "_skip_geocode", False) # Ensure coordinates are truncated to 12 characters before saving if self.latitude: self.latitude = Decimal(str(self.latitude)[:12]) if self.longitude: self.longitude = Decimal(str(self.longitude)[:12]) super().save(*args, **kwargs) def __str__(self): if self.name: return f"{self.name} ({self.date})" return f"{self.event_type} on {self.date}" class Volunteer(models.Model): class Meta: ordering = ("last_name", "first_name") tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteers') user = models.OneToOneField(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='volunteer_profile') first_name = models.CharField(max_length=100, blank=True) last_name = models.CharField(max_length=100, blank=True) email = models.EmailField() phone = models.CharField(max_length=20, blank=True) interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers') assigned_events = models.ManyToManyField(Event, through='VolunteerEvent', related_name='assigned_volunteers') is_default_caller = models.BooleanField(default=False) notes = models.TextField(blank=True) def save(self, *args, **kwargs): skip_geocode = kwargs.pop("skip_geocode", False) or getattr(self, "_skip_geocode", False) # Auto-format phone number self.phone = format_phone_number(self.phone) if self.is_default_caller: # Only one default caller per tenant Volunteer.objects.filter(tenant=self.tenant, is_default_caller=True).exclude(pk=self.pk).update(is_default_caller=False) super().save(*args, **kwargs) def __str__(self): return f"{self.first_name} {self.last_name}".strip() or self.email class VolunteerEvent(models.Model): volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name="event_assignments") event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="volunteer_assignments") role_type = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name="volunteer_assignments") def __str__(self): return f"{self.volunteer} at {self.event} as {self.role_type or 'Assigned'}" class EventParticipation(models.Model): event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='participations') voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='event_participations') participation_status = models.ForeignKey(ParticipationStatus, on_delete=models.PROTECT, null=True) def __str__(self): return f"{self.voter} at {self.event} ({self.participation_status})" class Donation(models.Model): voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='donations') date = models.DateField() method = models.ForeignKey(DonationMethod, on_delete=models.SET_NULL, null=True) amount = models.DecimalField(max_digits=10, decimal_places=2) def __str__(self): return f"{self.voter} - {self.amount} on {self.date}" class Interaction(models.Model): voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='interactions') volunteer = models.ForeignKey(Volunteer, on_delete=models.SET_NULL, null=True, blank=True, related_name='interactions') type = models.ForeignKey(InteractionType, on_delete=models.SET_NULL, null=True) date = models.DateTimeField() description = models.CharField(max_length=255) notes = models.TextField(blank=True) def __str__(self): return f"{self.voter} - {self.type} on {self.date}" class VoterLikelihood(models.Model): LIKELIHOOD_CHOICES = [ ('not_likely', 'Not Likely'), ('somewhat_likely', 'Somewhat Likely'), ('very_likely', 'Very Likely'), ] voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='likelihoods') election_type = models.ForeignKey(ElectionType, on_delete=models.CASCADE) likelihood = models.CharField(max_length=20, choices=LIKELIHOOD_CHOICES) class Meta: unique_together = ('voter', 'election_type') def __str__(self): return f"{self.voter} - {self.election_type}: {self.get_likelihood_display()}" class ScheduledCall(models.Model): STATUS_CHOICES = [ ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled'), ] tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='scheduled_calls') voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='scheduled_calls') volunteer = models.ForeignKey(Volunteer, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_calls') comments = models.TextField(blank=True) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): return f"Call for {self.voter} assigned to {self.volunteer}" class BulkTask(models.Model): TASK_TYPE_CHOICES = [ ('sms', 'SMS'), ('email', 'Email'), ] STATUS_CHOICES = [ ('pending', 'Pending'), ('processing', 'In Progress'), ('completed', 'Completed'), ('failed', 'Failed'), ] tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='bulk_tasks') task_type = models.CharField(max_length=10, choices=TASK_TYPE_CHOICES) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') total_count = models.IntegerField(default=0) success_count = models.IntegerField(default=0) fail_count = models.IntegerField(default=0) error_message = models.TextField(blank=True) message_body = models.TextField(blank=True) subject = models.CharField(max_length=255, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): return f"{self.get_task_type_display()} Task - {self.status} ({self.created_at})" class CampaignSettings(models.Model): tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, related_name='settings') donation_goal = models.DecimalField(max_digits=12, decimal_places=2, default=170000.00) twilio_account_sid = models.CharField(max_length=100, blank=True, default='ACcd11acb5095cec6477245d385a2bf127') twilio_auth_token = models.CharField(max_length=100, blank=True, default='89ec830d0fa02ab0afa6c76084865713') twilio_from_number = models.CharField(max_length=20, blank=True, default='+18556945903') timezone = models.CharField(max_length=100, default="America/Chicago", choices=[(tz, tz) for tz in sorted(zoneinfo.available_timezones())]) smtp_host = models.CharField(max_length=255, blank=True) smtp_port = models.IntegerField(default=587) smtp_username = models.CharField(max_length=255, blank=True) smtp_password = models.CharField(max_length=255, blank=True) smtp_use_tls = models.BooleanField(default=True) smtp_use_ssl = models.BooleanField(default=False) email_from_address = models.EmailField(blank=True) email_from_name = models.CharField(max_length=255, blank=True) call_script = models.TextField(blank=True) class Meta: verbose_name = 'Campaign Settings' verbose_name_plural = 'Campaign Settings' def clean(self): from django.core.exceptions import ValidationError if self.smtp_use_tls and self.smtp_use_ssl: raise ValidationError('SMTP Use TLS and SMTP Use SSL are mutually exclusive. Please choose only one.') def save(self, *args, **kwargs): skip_geocode = kwargs.pop("skip_geocode", False) or getattr(self, "_skip_geocode", False) self.full_clean() super().save(*args, **kwargs) def __str__(self): return f'Settings for {self.tenant.name}' @receiver(post_save, sender=Donation) def update_voter_support_on_donation(sender, instance, **kwargs): """ Automatically set candidate_support to 'supporting' if a voter has a donation > 0. """ if instance.amount > 0: voter = instance.voter if voter.candidate_support != 'supporting': voter.candidate_support = 'supporting' voter.save(update_fields=['candidate_support']) @receiver(pre_save, sender=Voter) def handle_voter_status_on_voted_pre_save(sender, instance, **kwargs): """ If a voter has voted, ensure they are not targets for door visits or calls. """ if instance.voted: instance.target_door_visit = False instance.call_queue_status = 'no_call_required' @receiver(post_save, sender=Voter) def update_voter_support_on_yard_sign(sender, instance, **kwargs): """ Automatically set candidate_support to "supporting" if: - Voter is older than 30 (birthdate <= 30 years ago) - Someone in their household (including themselves) has a yard sign ("wants" or "has") """ if getattr(instance, "_skip_signals", False): return orig = getattr(instance, "_orig_obj", None) # Detection of manual changes or irrelevant updates update_fields = kwargs.get("update_fields") support_manually_changed = orig and instance.candidate_support != orig.candidate_support relevant_fields = {"yard_sign", "birthdate", "address_street", "city", "state", "zip_code"} if update_fields: if not relevant_fields.intersection(update_fields): return elif orig and not kwargs.get("created"): # If no update_fields, manually check if anything relevant changed changed = False for field in relevant_fields: if getattr(instance, field) != getattr(orig, field): changed = True break if not changed: return from datetime import date today = date.today() try: thirty_years_ago = today.replace(year=today.year - 30) except ValueError: # Leap year case thirty_years_ago = today.replace(year=today.year - 30, day=today.day - 1) # 1. If this voter now has a yard sign, update everyone in the household who is > 30 # ONLY update those whose support is currently "unknown" to avoid overwriting intentional choices. if instance.yard_sign in ["wants", "has"]: queryset = Voter.objects.filter( address_street=instance.address_street, city=instance.city, state=instance.state, zip_code=instance.zip_code, tenant=instance.tenant, birthdate__lte=thirty_years_ago, candidate_support="unknown" ) # If support was manually changed in THIS save, exclude this instance from auto-revert if support_manually_changed: queryset = queryset.exclude(pk=instance.pk) queryset.update(candidate_support="supporting") # 2. If this voter itself is > 30, check if anyone in the household has a yard sign elif instance.birthdate and instance.birthdate <= thirty_years_ago: # Only auto-set if support is currently unknown and wasn"t just manually changed. if not support_manually_changed and instance.candidate_support == "unknown": household_has_sign = Voter.objects.filter( address_street=instance.address_street, city=instance.city, state=instance.state, zip_code=instance.zip_code, tenant=instance.tenant, yard_sign__in=["wants", "has"] ).exists() if household_has_sign: Voter.objects.filter(pk=instance.pk).update(candidate_support="supporting") elif instance.birthdate and instance.birthdate <= thirty_years_ago: household_has_sign = Voter.objects.filter( address_street=instance.address_street, city=instance.city, state=instance.state, zip_code=instance.zip_code, tenant=instance.tenant, yard_sign__in=['wants', 'has'] ).exists() if household_has_sign and instance.candidate_support != 'supporting': Voter.objects.filter(pk=instance.pk).update(candidate_support='supporting') @receiver(post_save, sender=Voter) def update_target_door_visit_logic(sender, instance, **kwargs): """ Set target_door_visit = False if door_visit = False and any voter record in the household: 1. Has a candidate support = 'Supporting' or 'Not Supporting' 2. Has attended an event (EventParticipation status = 'Attended') 3. NO ONE in the household is marked as is_targeted = True """ if getattr(instance, '_skip_signals', False): return # Manual override check: if target_door_visit was explicitly set to True in this save, # skip the auto-reset logic for THIS voter. is_manual_override = getattr(instance, '_target_door_visit_manually_set', False) update_fields = kwargs.get('update_fields') if update_fields: relevant = {'candidate_support', 'is_targeted', 'door_visit', 'address_street', 'city', 'state', 'zip_code', 'voted'} if not relevant.intersection(update_fields): return # 0. If this voter has voted, they are no longer a target for door visits. if instance.voted: if instance.target_door_visit: Voter.objects.filter(pk=instance.pk).update(target_door_visit=False) # 1. If this voter was just updated to Supporting or Not Supporting, # remove everyone in the household who hasn't been visited from the target list. if instance.candidate_support in ['supporting', 'not_supporting']: queryset = Voter.objects.filter( address_street=instance.address_street, city=instance.city, state=instance.state, zip_code=instance.zip_code, tenant=instance.tenant, door_visit=False ) if is_manual_override: queryset = queryset.exclude(pk=instance.pk) queryset.update(target_door_visit=False) # 2. If this voter was just updated to is_targeted = False, # and NO ONE in the household is targeted, set target_door_visit = False # for everyone in the household who hasn't been visited. elif not instance.is_targeted: household_has_targeted = Voter.objects.filter( address_street=instance.address_street, city=instance.city, state=instance.state, zip_code=instance.zip_code, tenant=instance.tenant, is_targeted=True ).exists() if not household_has_targeted: queryset = Voter.objects.filter( address_street=instance.address_street, city=instance.city, state=instance.state, zip_code=instance.zip_code, tenant=instance.tenant, door_visit=False ) if is_manual_override: queryset = queryset.exclude(pk=instance.pk) queryset.update(target_door_visit=False) # 3. If this voter was just saved with door_visit=False, # check if anyone in the household (including themselves) has known support, # attended an event, or if NO ONE is targeted. elif not instance.door_visit and not is_manual_override: household_voters = Voter.objects.filter( address_street=instance.address_street, city=instance.city, state=instance.state, zip_code=instance.zip_code, tenant=instance.tenant ) household_has_known_support = household_voters.filter( candidate_support__in=['supporting', 'not_supporting'] ).exists() household_has_attended = EventParticipation.objects.filter( voter__in=household_voters, participation_status__name='Attended' ).exists() household_has_targeted = household_voters.filter(is_targeted=True).exists() if (household_has_known_support or household_has_attended or not household_has_targeted) and instance.target_door_visit: Voter.objects.filter(pk=instance.pk).update(target_door_visit=False) @receiver(post_save, sender=EventParticipation) def update_target_door_visit_on_participation(sender, instance, **kwargs): """ Set target_door_visit = False for all household members who haven't been visited if someone in the household attended an event. """ if instance.participation_status and instance.participation_status.name == 'Attended': voter = instance.voter Voter.objects.filter( address_street=voter.address_street, city=voter.city, state=voter.state, zip_code=voter.zip_code, tenant=voter.tenant, door_visit=False ).update(target_door_visit=False) @receiver(post_save, sender=Voter) def update_voter_call_queue_status_on_voter_save(sender, instance, **kwargs): """ Sync call_queue_status when is_targeted, candidate_support or voted changes. """ if getattr(instance, '_skip_signals', False): return orig = getattr(instance, '_orig_obj', None) if orig and instance.call_queue_status != orig.call_queue_status: # If call_queue_status was manually changed, don't auto-override in this save return update_fields = kwargs.get('update_fields') if update_fields: relevant = {'is_targeted', 'candidate_support', 'voted'} if not relevant.intersection(update_fields): return # PRIORITY 1: If they voted, no call required and cancel pending calls if instance.voted: # Cancel any pending calls ScheduledCall.objects.filter(voter=instance, status='pending').update(status='cancelled') if instance.call_queue_status != 'no_call_required': Voter.objects.filter(pk=instance.pk).update(call_queue_status='no_call_required') return # PRIORITY 2: Check if in queue (pending scheduled call) if ScheduledCall.objects.filter(voter=instance, status='pending').exists(): if instance.call_queue_status != 'in_call_queue': Voter.objects.filter(pk=instance.pk).update(call_queue_status='in_call_queue') return # PRIORITY 3: If support is 'supporting', then 'no_call_required' if instance.candidate_support == 'supporting': if instance.call_queue_status != 'no_call_required': Voter.objects.filter(pk=instance.pk).update(call_queue_status='no_call_required') return # PRIORITY 4: If un-targeted, set to no_call_required if not instance.is_targeted: if instance.call_queue_status != 'no_call_required': Voter.objects.filter(pk=instance.pk).update(call_queue_status='no_call_required') else: # If targeted, and currently no_call_required, set to to_be_called if instance.call_queue_status == 'no_call_required': Voter.objects.filter(pk=instance.pk).update(call_queue_status='to_be_called') @receiver(post_save, sender=ScheduledCall) def update_voter_call_queue_status_on_call_save(sender, instance, **kwargs): """ Sync Voter.call_queue_status when a ScheduledCall is saved. """ voter = instance.voter # PRIORITY 0: If they voted, always no_call_required if voter.voted: if voter.call_queue_status != 'no_call_required': voter.call_queue_status = 'no_call_required' voter.save(update_fields=['call_queue_status']) return # PRIORITY 1: If there is ANY pending call for this voter, ALWAYS in_call_queue if ScheduledCall.objects.filter(voter=voter, status='pending').exists(): if voter.call_queue_status != 'in_call_queue': voter.call_queue_status = 'in_call_queue' voter.save(update_fields=['call_queue_status']) return # PRIORITY 2: If no pending calls, follow normal rules if voter.candidate_support == 'supporting': if voter.call_queue_status != 'no_call_required': voter.call_queue_status = 'no_call_required' voter.save(update_fields=['call_queue_status']) return if instance.status == 'completed': if voter.call_queue_status != 'called': voter.call_queue_status = 'called' voter.save(update_fields=['call_queue_status']) elif instance.status == 'cancelled': if voter.is_targeted: # Check if they were already called if ScheduledCall.objects.filter(voter=voter, status='completed').exists(): voter.call_queue_status = 'called' else: voter.call_queue_status = 'to_be_called' voter.save(update_fields=['call_queue_status']) @receiver(post_delete, sender=ScheduledCall) def update_voter_call_queue_status_on_call_delete(sender, instance, **kwargs): """ Sync Voter.call_queue_status when a ScheduledCall is deleted. """ voter = instance.voter # PRIORITY 1: Check if there are other pending calls if ScheduledCall.objects.filter(voter=voter, status='pending').exists(): if voter.call_queue_status != 'in_call_queue': voter.call_queue_status = 'in_call_queue' voter.save(update_fields=['call_queue_status']) return # PRIORITY 2: If no pending calls, follow normal rules if voter.candidate_support == 'supporting': if voter.call_queue_status != 'no_call_required': voter.call_queue_status = 'no_call_required' voter.save(update_fields=['call_queue_status']) return if voter.is_targeted: # If no pending calls left, set back to called or to_be_called if ScheduledCall.objects.filter(voter=voter, status='completed').exists(): voter.call_queue_status = 'called' else: voter.call_queue_status = 'to_be_called' voter.save(update_fields=['call_queue_status'])