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 = [ ('admin', 'Admin'), ('campaign_manager', 'Campaign Manager'), ('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 EventType(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_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 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): SUPPORT_CHOICES = [ ('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting'), ] YARD_SIGN_CHOICES = [ ('none', 'None'), ('wants', 'Wants a yard sign'), ('has', 'Has a yard sign'), ] WINDOW_STICKER_CHOICES = [ ('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker'), ] tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='voters') voter_id = models.CharField(max_length=50, blank=True) first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) nickname = models.CharField(max_length=100, blank=True) birthdate = models.DateField(null=True, blank=True) address = models.TextField(blank=True) address_street = models.CharField(max_length=255, blank=True) city = models.CharField(max_length=100, blank=True) state = models.CharField(max_length=2, blank=True) prior_state = models.CharField(max_length=2, blank=True) zip_code = models.CharField(max_length=20, blank=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) email = models.EmailField(blank=True) district = models.CharField(max_length=100, blank=True) precinct = models.CharField(max_length=100, blank=True) registration_date = models.DateField(null=True, blank=True) is_targeted = models.BooleanField(default=False) candidate_support = models.CharField(max_length=20, choices=SUPPORT_CHOICES, default='unknown') yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none') window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status') created_at = models.DateTimeField(auto_now_add=True) 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: self.latitude = lat # Truncate longitude to 12 characters as requested self.longitude = Decimal(str(lon)[:12]) logger.info(f"Geocoding success: {lat}, {self.longitude}") return True, None logger.warning(f"Geocoding failed for {self.address}: {err}") return False, err def save(self, *args, **kwargs): # Auto-format phone number self.phone = format_phone_number(self.phone) # Ensure longitude is truncated to 12 characters before saving 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 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 = Voter.objects.get(pk=self.pk) # 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) # Detect if coordinates were changed in this transaction (e.g., from a form) coords_provided = (self.latitude != orig.latitude or self.longitude != orig.longitude) # 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 if 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, blank=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) description = models.TextField(blank=True) def __str__(self): if self.name: return f"{self.name} ({self.date})" return f"{self.event_type} on {self.date}" class Volunteer(models.Model): 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') def save(self, *args, **kwargs): # Auto-format phone number self.phone = format_phone_number(self.phone) 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 = models.CharField(max_length=100) def __str__(self): return f"{self.volunteer} at {self.event} as {self.role}" 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.DateField() 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 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) class Meta: verbose_name = 'Campaign Settings' verbose_name_plural = 'Campaign Settings' def __str__(self): return f'Settings for {self.tenant.name}'