236 lines
9.1 KiB
Python
236 lines
9.1 KiB
Python
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()}" |