from django.db import models from django.utils.text import slugify from django.contrib.auth.models import User import urllib.request import urllib.parse import json import logging logger = logging.getLogger(__name__) class Tenant(models.Model): name = models.CharField(max_length=255) slug = models.SlugField(unique=True, blank=True) description = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) 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 f"{self.name} ({self.tenant.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 f"{self.name} ({self.tenant.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 f"{self.name} ({self.tenant.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 f"{self.name} ({self.tenant.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'), ] 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) 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=100, blank=True) zip_code = models.CharField(max_length=20, blank=True) county = models.CharField(max_length=100, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) longitude = models.DecimalField(max_digits=9, decimal_places=6, 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') created_at = models.DateTimeField(auto_now_add=True) def geocode_address(self): if not self.address: return logger.info(f"Geocoding address: {self.address}") try: query = urllib.parse.quote(self.address) url = f"https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=1" req = urllib.request.Request(url, headers={'User-Agent': 'FlatlogicVoterApp/1.1 (Campaign Management System; contact: info@example.com)'}) with urllib.request.urlopen(req, timeout=10) as response: data = json.loads(response.read().decode()) if data: self.latitude = data[0]['lat'] self.longitude = data[0]['lon'] logger.info(f"Geocoding success: {self.latitude}, {self.longitude}") else: logger.warning(f"Geocoding returned no results for: {self.address}") except Exception as e: logger.error(f"Geocoding error for {self.address}: {e}") def save(self, *args, **kwargs): # 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 should_geocode = True else: orig = Voter.objects.get(pk=self.pk) # If any address component changed if (self.address_street != orig.address_street or self.city != orig.city or self.state != orig.state or self.zip_code != orig.zip_code): should_geocode = True # If coordinates are missing if self.latitude is None or self.longitude is None: should_geocode = True if should_geocode and self.address: 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') date = models.DateField() event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True) description = models.TextField(blank=True) def __str__(self): return f"{self.event_type} on {self.date}" class EventParticipation(models.Model): PARTICIPATION_TYPE_CHOICES = [ ("invited", "Invited"), ("invited_not_attended", "Invited but didn't attend"), ("attended", "Attended"), ] event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='participations') voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='event_participations') participation_type = models.CharField(max_length=50, choices=PARTICIPATION_TYPE_CHOICES, default='invited') def __str__(self): return f"{self.voter} at {self.event} ({self.get_participation_type_display()})" 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') 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()}"