37769-vm/core/models.py
2026-01-25 05:02:55 +00:00

308 lines
12 KiB
Python

from decimal import Decimal
from django.db import models
from django.utils.text import slugify
from django.contrib.auth.models import User
from django.conf import settings
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'),
]
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):
# 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')
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()}"
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}'