Recover VM workspace state 20260530T075327Z
This commit is contained in:
commit
7fa97bf38c
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.sqlite3
|
||||
.env
|
||||
.env.*
|
||||
.perm_test_*
|
||||
staticfiles/
|
||||
.DS_Store
|
||||
159
ERD.md
Normal file
159
ERD.md
Normal file
@ -0,0 +1,159 @@
|
||||
# Entity Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
Tenant ||--o{ TenantUserRole : has
|
||||
Tenant ||--o{ InteractionType : defines
|
||||
Tenant ||--o{ DonationMethod : defines
|
||||
Tenant ||--o{ ElectionType : defines
|
||||
Tenant ||--o{ EventType : defines
|
||||
Tenant ||--o{ ParticipationStatus : defines
|
||||
Tenant ||--o{ Voter : belongs_to
|
||||
Tenant ||--o{ Event : organizes
|
||||
|
||||
User ||--o{ TenantUserRole : assigned_to
|
||||
|
||||
Voter ||--o{ VotingRecord : has
|
||||
Voter ||--o{ EventParticipation : participates
|
||||
Voter ||--o{ Donation : makes
|
||||
Voter ||--o{ Interaction : receives
|
||||
Voter ||--o{ VoterLikelihood : has
|
||||
|
||||
Event ||--o{ EventParticipation : includes
|
||||
EventType ||--o{ Event : categorizes
|
||||
ParticipationStatus ||--o{ EventParticipation : defines_status
|
||||
|
||||
InteractionType ||--o{ Interaction : categorizes
|
||||
DonationMethod ||--o{ Donation : categorizes
|
||||
ElectionType ||--o{ VoterLikelihood : categorizes
|
||||
|
||||
Tenant {
|
||||
int id PK
|
||||
string name
|
||||
string slug
|
||||
text description
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
User {
|
||||
int id PK
|
||||
string username
|
||||
string email
|
||||
string first_name
|
||||
string last_name
|
||||
}
|
||||
|
||||
TenantUserRole {
|
||||
int id PK
|
||||
int user_id FK
|
||||
int tenant_id FK
|
||||
string role
|
||||
}
|
||||
|
||||
InteractionType {
|
||||
int id PK
|
||||
int tenant_id FK
|
||||
string name
|
||||
boolean is_active
|
||||
}
|
||||
|
||||
DonationMethod {
|
||||
int id PK
|
||||
int tenant_id FK
|
||||
string name
|
||||
boolean is_active
|
||||
}
|
||||
|
||||
ElectionType {
|
||||
int id PK
|
||||
int tenant_id FK
|
||||
string name
|
||||
boolean is_active
|
||||
}
|
||||
|
||||
EventType {
|
||||
int id PK
|
||||
int tenant_id FK
|
||||
string name
|
||||
boolean is_active
|
||||
}
|
||||
|
||||
ParticipationStatus {
|
||||
int id PK
|
||||
int tenant_id FK
|
||||
string name
|
||||
boolean is_active
|
||||
}
|
||||
|
||||
Voter {
|
||||
int id PK
|
||||
int tenant_id FK
|
||||
string voter_id
|
||||
string first_name
|
||||
string last_name
|
||||
text address
|
||||
string address_street
|
||||
string city
|
||||
string state
|
||||
string zip_code
|
||||
string county
|
||||
decimal latitude
|
||||
decimal longitude
|
||||
string phone
|
||||
string email
|
||||
string district
|
||||
string precinct
|
||||
date registration_date
|
||||
boolean is_targeted
|
||||
string candidate_support
|
||||
string yard_sign
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
VotingRecord {
|
||||
int id PK
|
||||
int voter_id FK
|
||||
date election_date
|
||||
string election_description
|
||||
string primary_party
|
||||
}
|
||||
|
||||
Event {
|
||||
int id PK
|
||||
int tenant_id FK
|
||||
date date
|
||||
int event_type_id FK
|
||||
text description
|
||||
}
|
||||
|
||||
EventParticipation {
|
||||
int id PK
|
||||
int event_id FK
|
||||
int voter_id FK
|
||||
int participation_status_id FK
|
||||
}
|
||||
|
||||
Donation {
|
||||
int id PK
|
||||
int voter_id FK
|
||||
date date
|
||||
int method_id FK
|
||||
decimal amount
|
||||
}
|
||||
|
||||
Interaction {
|
||||
int id PK
|
||||
int voter_id FK
|
||||
int type_id FK
|
||||
date date
|
||||
string description
|
||||
text notes
|
||||
}
|
||||
|
||||
VoterLikelihood {
|
||||
int id PK
|
||||
int voter_id FK
|
||||
int election_type_id FK
|
||||
string likelihood
|
||||
}
|
||||
```
|
||||
31
README.md
Normal file
31
README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Flatlogic Python Template Workspace
|
||||
|
||||
This workspace houses the Django application scaffold used for Python-based templates.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+
|
||||
- MariaDB (or MySQL-compatible server) with the credentials prepared by `setup_mariadb_project.sh`
|
||||
- System packages: `pkg-config`, `libmariadb-dev` (already installed on golden images)
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
python3 -m pip install --break-system-packages -r requirements.txt
|
||||
python3 manage.py migrate
|
||||
python3 manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
Environment variables are loaded from `../.env` (the executor root). See `.env.example` if you need to populate values manually.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `config/` – Django project settings, URLs, WSGI entrypoint.
|
||||
- `core/` – Default app with a basic health-check route.
|
||||
- `manage.py` – Django management entrypoint.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Create additional apps and views according to the generated project requirements.
|
||||
- Configure serving via Apache + mod_wsgi or gunicorn (instructions to be added).
|
||||
- Run `python3 manage.py collectstatic` before serving through Apache.
|
||||
3
ai/__init__.py
Normal file
3
ai/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Helpers for interacting with the Flatlogic AI proxy from Django code."""
|
||||
|
||||
from .local_ai_api import LocalAIApi, create_response, request, decode_json_from_response # noqa: F401
|
||||
420
ai/local_ai_api.py
Normal file
420
ai/local_ai_api.py
Normal file
@ -0,0 +1,420 @@
|
||||
"""
|
||||
LocalAIApi — lightweight Python client for the Flatlogic AI proxy.
|
||||
|
||||
Usage (inside the Django workspace):
|
||||
|
||||
from ai.local_ai_api import LocalAIApi
|
||||
|
||||
response = LocalAIApi.create_response({
|
||||
"input": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Summarise this text in two sentences."},
|
||||
],
|
||||
"text": {"format": {"type": "json_object"}},
|
||||
})
|
||||
|
||||
if response.get("success"):
|
||||
data = LocalAIApi.decode_json_from_response(response)
|
||||
# ...
|
||||
|
||||
# Typical successful payload (truncated):
|
||||
# {
|
||||
# "id": "resp_xxx",
|
||||
# "status": "completed",
|
||||
# "output": [
|
||||
# {"type": "reasoning", "summary": []},
|
||||
# {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]}
|
||||
# ],
|
||||
# "usage": { "input_tokens": 123, "output_tokens": 456 }
|
||||
# }
|
||||
|
||||
The helper automatically injects the project UUID header and falls back to
|
||||
reading executor/.env if environment variables are missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import ssl
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
from urllib import error as urlerror
|
||||
from urllib import request as urlrequest
|
||||
|
||||
__all__ = [
|
||||
"LocalAIApi",
|
||||
"create_response",
|
||||
"request",
|
||||
"fetch_status",
|
||||
"await_response",
|
||||
"extract_text",
|
||||
"decode_json_from_response",
|
||||
]
|
||||
|
||||
|
||||
_CONFIG_CACHE: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class LocalAIApi:
|
||||
"""Static helpers mirroring the PHP implementation."""
|
||||
|
||||
@staticmethod
|
||||
def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
return create_response(params, options or {})
|
||||
|
||||
@staticmethod
|
||||
def request(path: Optional[str] = None, payload: Optional[Dict[str, Any]] = None,
|
||||
options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
return request(path, payload or {}, options or {})
|
||||
|
||||
@staticmethod
|
||||
def extract_text(response: Dict[str, Any]) -> str:
|
||||
return extract_text(response)
|
||||
|
||||
@staticmethod
|
||||
def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return decode_json_from_response(response)
|
||||
|
||||
|
||||
def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Signature compatible with the OpenAI Responses API."""
|
||||
options = options or {}
|
||||
payload = dict(params)
|
||||
|
||||
if not isinstance(payload.get("input"), list) or not payload["input"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "input_missing",
|
||||
"message": 'Parameter "input" is required and must be a non-empty list.',
|
||||
}
|
||||
|
||||
cfg = _config()
|
||||
if not payload.get("model"):
|
||||
payload["model"] = cfg["default_model"]
|
||||
|
||||
initial = request(options.get("path"), payload, options)
|
||||
if not initial.get("success"):
|
||||
return initial
|
||||
|
||||
data = initial.get("data")
|
||||
if isinstance(data, dict) and "ai_request_id" in data:
|
||||
ai_request_id = data["ai_request_id"]
|
||||
poll_timeout = int(options.get("poll_timeout", 300))
|
||||
poll_interval = int(options.get("poll_interval", 5))
|
||||
return await_response(ai_request_id, {
|
||||
"interval": poll_interval,
|
||||
"timeout": poll_timeout,
|
||||
"headers": options.get("headers"),
|
||||
"timeout_per_call": options.get("timeout"),
|
||||
})
|
||||
|
||||
return initial
|
||||
|
||||
|
||||
def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Perform a raw request to the AI proxy."""
|
||||
cfg = _config()
|
||||
options = options or {}
|
||||
|
||||
resolved_path = path or options.get("path") or cfg["responses_path"]
|
||||
if not resolved_path:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "project_id_missing",
|
||||
"message": "PROJECT_ID is not defined; cannot resolve AI proxy endpoint.",
|
||||
}
|
||||
|
||||
project_uuid = cfg["project_uuid"]
|
||||
if not project_uuid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "project_uuid_missing",
|
||||
"message": "PROJECT_UUID is not defined; aborting AI request.",
|
||||
}
|
||||
|
||||
if "project_uuid" not in payload and project_uuid:
|
||||
payload["project_uuid"] = project_uuid
|
||||
|
||||
url = _build_url(resolved_path, cfg["base_url"])
|
||||
opt_timeout = options.get("timeout")
|
||||
timeout = int(cfg["timeout"] if opt_timeout is None else opt_timeout)
|
||||
verify_tls = options.get("verify_tls", cfg["verify_tls"])
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
cfg["project_header"]: project_uuid,
|
||||
}
|
||||
extra_headers = options.get("headers")
|
||||
if isinstance(extra_headers, Iterable):
|
||||
for header in extra_headers:
|
||||
if isinstance(header, str) and ":" in header:
|
||||
name, value = header.split(":", 1)
|
||||
headers[name.strip()] = value.strip()
|
||||
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
return _http_request(url, "POST", body, headers, timeout, verify_tls)
|
||||
|
||||
|
||||
def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Fetch status for a queued AI request."""
|
||||
cfg = _config()
|
||||
options = options or {}
|
||||
|
||||
project_uuid = cfg["project_uuid"]
|
||||
if not project_uuid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "project_uuid_missing",
|
||||
"message": "PROJECT_UUID is not defined; aborting status check.",
|
||||
}
|
||||
|
||||
status_path = _resolve_status_path(ai_request_id, cfg)
|
||||
url = _build_url(status_path, cfg["base_url"])
|
||||
|
||||
opt_timeout = options.get("timeout")
|
||||
timeout = int(cfg["timeout"] if opt_timeout is None else opt_timeout)
|
||||
verify_tls = options.get("verify_tls", cfg["verify_tls"])
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
cfg["project_header"]: project_uuid,
|
||||
}
|
||||
extra_headers = options.get("headers")
|
||||
if isinstance(extra_headers, Iterable):
|
||||
for header in extra_headers:
|
||||
if isinstance(header, str) and ":" in header:
|
||||
name, value = header.split(":", 1)
|
||||
headers[name.strip()] = value.strip()
|
||||
|
||||
return _http_request(url, "GET", None, headers, timeout, verify_tls)
|
||||
|
||||
|
||||
def await_response(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Poll status endpoint until the request is complete or timed out."""
|
||||
options = options or {}
|
||||
timeout = int(options.get("timeout", 300))
|
||||
interval = int(options.get("interval", 5))
|
||||
if interval <= 0:
|
||||
interval = 5
|
||||
per_call_timeout = options.get("timeout_per_call")
|
||||
|
||||
deadline = time.time() + max(timeout, interval)
|
||||
|
||||
while True:
|
||||
status_resp = fetch_status(ai_request_id, {
|
||||
"headers": options.get("headers"),
|
||||
"timeout": per_call_timeout,
|
||||
"verify_tls": options.get("verify_tls"),
|
||||
})
|
||||
if status_resp.get("success"):
|
||||
data = status_resp.get("data") or {}
|
||||
if isinstance(data, dict):
|
||||
status_value = data.get("status")
|
||||
if status_value == "success":
|
||||
return {
|
||||
"success": True,
|
||||
"status": 200,
|
||||
"data": data.get("response", data),
|
||||
}
|
||||
if status_value == "failed":
|
||||
return {
|
||||
"success": False,
|
||||
"status": 500,
|
||||
"error": str(data.get("error") or "AI request failed"),
|
||||
"data": data,
|
||||
}
|
||||
else:
|
||||
return status_resp
|
||||
|
||||
if time.time() >= deadline:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "timeout",
|
||||
"message": "Timed out waiting for AI response.",
|
||||
}
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def extract_text(response: Dict[str, Any]) -> str:
|
||||
"""Public helper to extract plain text from a Responses payload."""
|
||||
return _extract_text(response)
|
||||
|
||||
|
||||
def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Attempt to decode JSON emitted by the model (handles markdown fences)."""
|
||||
text = _extract_text(response)
|
||||
if text == "":
|
||||
return None
|
||||
|
||||
try:
|
||||
decoded = json.loads(text)
|
||||
if isinstance(decoded, dict):
|
||||
return decoded
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
stripped = text.strip()
|
||||
if stripped.startswith("```json"):
|
||||
stripped = stripped[7:]
|
||||
if stripped.endswith("```"):
|
||||
stripped = stripped[:-3]
|
||||
stripped = stripped.strip()
|
||||
if stripped and stripped != text:
|
||||
try:
|
||||
decoded = json.loads(stripped)
|
||||
if isinstance(decoded, dict):
|
||||
return decoded
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(response: Dict[str, Any]) -> str:
|
||||
payload = response.get("data") if response.get("success") else response.get("response")
|
||||
if isinstance(payload, dict):
|
||||
output = payload.get("output")
|
||||
if isinstance(output, list):
|
||||
combined = ""
|
||||
for item in output:
|
||||
content = item.get("content") if isinstance(item, dict) else None
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "output_text" and block.get("text"):
|
||||
combined += str(block["text"])
|
||||
if combined:
|
||||
return combined
|
||||
choices = payload.get("choices")
|
||||
if isinstance(choices, list) and choices:
|
||||
message = choices[0].get("message")
|
||||
if isinstance(message, dict) and message.get("content"):
|
||||
return str(message["content"])
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
return ""
|
||||
|
||||
|
||||
def _config() -> Dict[str, Any]:
|
||||
global _CONFIG_CACHE # noqa: PLW0603
|
||||
if _CONFIG_CACHE is not None:
|
||||
return _CONFIG_CACHE
|
||||
|
||||
_ensure_env_loaded()
|
||||
|
||||
base_url = os.getenv("AI_PROXY_BASE_URL", "https://flatlogic.com")
|
||||
project_id = os.getenv("PROJECT_ID") or None
|
||||
responses_path = os.getenv("AI_RESPONSES_PATH")
|
||||
if not responses_path and project_id:
|
||||
responses_path = f"/projects/{project_id}/ai-request"
|
||||
|
||||
_CONFIG_CACHE = {
|
||||
"base_url": base_url,
|
||||
"responses_path": responses_path,
|
||||
"project_id": project_id,
|
||||
"project_uuid": os.getenv("PROJECT_UUID"),
|
||||
"project_header": os.getenv("AI_PROJECT_HEADER", "project-uuid"),
|
||||
"default_model": os.getenv("AI_DEFAULT_MODEL", "gpt-5-mini"),
|
||||
"timeout": int(os.getenv("AI_TIMEOUT", "30")),
|
||||
"verify_tls": os.getenv("AI_VERIFY_TLS", "true").lower() not in {"0", "false", "no"},
|
||||
}
|
||||
return _CONFIG_CACHE
|
||||
|
||||
|
||||
def _build_url(path: str, base_url: str) -> str:
|
||||
trimmed = path.strip()
|
||||
if trimmed.startswith("http://") or trimmed.startswith("https://"):
|
||||
return trimmed
|
||||
if trimmed.startswith("/"):
|
||||
return f"{base_url}{trimmed}"
|
||||
return f"{base_url}/{trimmed}"
|
||||
|
||||
|
||||
def _resolve_status_path(ai_request_id: Any, cfg: Dict[str, Any]) -> str:
|
||||
base_path = (cfg.get("responses_path") or "").rstrip("/")
|
||||
if not base_path:
|
||||
return f"/ai-request/{ai_request_id}/status"
|
||||
if not base_path.endswith("/ai-request"):
|
||||
base_path = f"{base_path}/ai-request"
|
||||
return f"{base_path}/{ai_request_id}/status"
|
||||
|
||||
|
||||
def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[str, str],
|
||||
timeout: int, verify_tls: bool) -> Dict[str, Any]:
|
||||
"""
|
||||
Shared HTTP helper for GET/POST requests.
|
||||
"""
|
||||
req = urlrequest.Request(url, data=body, method=method.upper())
|
||||
for name, value in headers.items():
|
||||
req.add_header(name, value)
|
||||
|
||||
context = None
|
||||
if not verify_tls:
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
with urlrequest.urlopen(req, timeout=timeout, context=context) as resp:
|
||||
status = resp.getcode()
|
||||
response_body = resp.read().decode("utf-8", errors="replace")
|
||||
except urlerror.HTTPError as exc:
|
||||
status = exc.getcode()
|
||||
response_body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
return {
|
||||
"success": False,
|
||||
"error": "request_failed",
|
||||
"message": str(exc),
|
||||
}
|
||||
|
||||
decoded = None
|
||||
if response_body:
|
||||
try:
|
||||
decoded = json.loads(response_body)
|
||||
except json.JSONDecodeError:
|
||||
decoded = None
|
||||
|
||||
if 200 <= status < 300:
|
||||
return {
|
||||
"success": True,
|
||||
"status": status,
|
||||
"data": decoded if decoded is not None else response_body,
|
||||
}
|
||||
|
||||
error_message = "AI proxy request failed"
|
||||
if isinstance(decoded, dict):
|
||||
error_message = decoded.get("error") or decoded.get("message") or error_message
|
||||
elif response_body:
|
||||
error_message = response_body
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"status": status,
|
||||
"error": error_message,
|
||||
"response": decoded if decoded is not None else response_body,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_env_loaded() -> None:
|
||||
"""Populate os.environ from executor/.env if variables are missing."""
|
||||
if os.getenv("PROJECT_UUID") and os.getenv("PROJECT_ID"):
|
||||
return
|
||||
|
||||
env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env"))
|
||||
if not os.path.exists(env_path):
|
||||
return
|
||||
|
||||
try:
|
||||
with open(env_path, "r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('\'"')
|
||||
if key and not os.getenv(key):
|
||||
os.environ[key] = value
|
||||
except OSError:
|
||||
pass
|
||||
0
assets/.gitkeep
Normal file
0
assets/.gitkeep
Normal file
17
check_none.py
Normal file
17
check_none.py
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
django.setup()
|
||||
|
||||
from core.models import Voter, Tenant
|
||||
|
||||
def check_none():
|
||||
print(f"Voters with NULL address_street: {Voter.objects.filter(address_street__isnull=True).count()}")
|
||||
print(f"Voters with empty address_street: {Voter.objects.filter(address_street='').count()}")
|
||||
print(f"Voters with NULL neighborhood: {Voter.objects.filter(neighborhood__isnull=True).count()}")
|
||||
print(f"Voters with empty neighborhood: {Voter.objects.filter(neighborhood='').count()}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_none()
|
||||
36
check_tenants_neighborhoods.py
Normal file
36
check_tenants_neighborhoods.py
Normal file
@ -0,0 +1,36 @@
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
django.setup()
|
||||
|
||||
from core.models import Voter, Tenant
|
||||
|
||||
def check_neighborhoods():
|
||||
tenants = Tenant.objects.all()
|
||||
for tenant in tenants:
|
||||
print(f"Tenant: {tenant.name}")
|
||||
voters = Voter.objects.filter(tenant=tenant, is_inactive=False, yard_sign='wants')
|
||||
|
||||
households_dict = {}
|
||||
for voter in voters:
|
||||
key = (voter.address_street, voter.city, voter.state, voter.zip_code)
|
||||
if key not in households_dict:
|
||||
households_dict[key] = voter.neighborhood
|
||||
else:
|
||||
if not households_dict[key] and voter.neighborhood:
|
||||
households_dict[key] = voter.neighborhood
|
||||
|
||||
total_households = len(households_dict)
|
||||
households_with_nb = [nb for nb in households_dict.values() if nb]
|
||||
households_without_nb = [nb for nb in households_dict.values() if not nb]
|
||||
|
||||
print(f" Total Households: {total_households}")
|
||||
print(f" Households with Neighborhood: {len(households_with_nb)}")
|
||||
print(f" Households without Neighborhood: {len(households_without_nb)}")
|
||||
|
||||
if len(households_without_nb) > 0:
|
||||
print(f" First 10 neighborhoods (sorted): {sorted([nb or '' for nb in households_dict.values()])[:10]}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_neighborhoods()
|
||||
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
16
config/asgi.py
Normal file
16
config/asgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for config project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
192
config/settings.py
Normal file
192
config/settings.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""
|
||||
Django settings for config project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.2.7.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(BASE_DIR.parent / ".env")
|
||||
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"grassrootscrm.flatlogic.app",
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
]
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"https://grassrootscrm.flatlogic.app",
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS += [
|
||||
origin for origin in [
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
||||
] if origin
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
f"https://{host}" if not host.startswith(("http://", "https://")) else host
|
||||
for host in CSRF_TRUSTED_ORIGINS
|
||||
]
|
||||
|
||||
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SAMESITE = "None"
|
||||
CSRF_COOKIE_SAMESITE = "None"
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'core',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'core.middleware.LoginRequiredMiddleware',
|
||||
'core.middleware.TimezoneMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
|
||||
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
X_FRAME_OPTIONS = 'ALLOWALL'
|
||||
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
# IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
|
||||
'core.context_processors.project_context',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.mysql',
|
||||
'NAME': os.getenv('DB_NAME', ''),
|
||||
'USER': os.getenv('DB_USER', ''),
|
||||
'PASSWORD': os.getenv('DB_PASS', ''),
|
||||
'HOST': os.getenv('DB_HOST', '127.0.0.1'),
|
||||
'PORT': os.getenv('DB_PORT', '3306'),
|
||||
'OPTIONS': {
|
||||
'charset': 'utf8mb4',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
BASE_DIR / 'assets',
|
||||
BASE_DIR / 'node_modules',
|
||||
]
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND",
|
||||
"django.core.mail.backends.smtp.EmailBackend"
|
||||
)
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
|
||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com")
|
||||
CONTACT_EMAIL_TO = [
|
||||
item.strip()
|
||||
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
|
||||
if item.strip()
|
||||
]
|
||||
|
||||
# When both TLS and SSL flags are enabled, prefer SSL explicitly
|
||||
if EMAIL_USE_SSL:
|
||||
EMAIL_USE_TLS = False
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "AIzaSyAluZTEjH-RSiGJUHnfrSqWbcAXCGzGOq4")
|
||||
LOGIN_URL = 'login'
|
||||
LOGIN_REDIRECT_URL = 'index'
|
||||
LOGOUT_REDIRECT_URL = 'login'
|
||||
33
config/urls.py
Normal file
33
config/urls.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""
|
||||
URL configuration for config project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
from django.views.i18n import JavaScriptCatalog
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from django.views.i18n import JavaScriptCatalog
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("jsi18n/", JavaScriptCatalog.as_view(), name="jsi18n"),
|
||||
path("", include("core.urls")),
|
||||
path("accounts/", include("django.contrib.auth.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
16
config/wsgi.py
Normal file
16
config/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for config project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
620
core/admin.py
Normal file
620
core/admin.py
Normal file
@ -0,0 +1,620 @@
|
||||
from django import forms
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import datetime, date
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import tempfile
|
||||
import os
|
||||
import zoneinfo
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.dateparse import parse_date, parse_datetime
|
||||
from django.utils import timezone as django_timezone
|
||||
from django.contrib import admin, messages
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import render, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
|
||||
from .models import (
|
||||
format_phone_number,
|
||||
Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,
|
||||
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,
|
||||
Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole, ScheduledCall
|
||||
)
|
||||
from .forms import (
|
||||
VoterImportForm, EventImportForm, EventParticipationImportForm,
|
||||
DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,
|
||||
VolunteerImportForm, VotingRecordImportForm
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def parse_any_date(date_str, tz_name=None):
|
||||
if not date_str or not isinstance(date_str, str): return None
|
||||
date_str = date_str.strip()
|
||||
if not date_str: return None
|
||||
dt = parse_datetime(date_str)
|
||||
if dt:
|
||||
if django_timezone.is_naive(dt) and tz_name:
|
||||
try: dt = django_timezone.make_aware(dt, zoneinfo.ZoneInfo(tz_name))
|
||||
except: pass
|
||||
return dt
|
||||
d = parse_date(date_str)
|
||||
if d: return d
|
||||
formats = ["%m/%d/%Y", "%m/%d/%y", "%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d", "%m-%d-%Y", "%d-%m-%Y", "%Y/%m/%d", "%m/%d/%Y %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%m/%d/%Y %I:%M %p", "%m/%d/%Y %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"]
|
||||
for fmt in formats:
|
||||
try:
|
||||
dt = datetime.strptime(date_str, fmt)
|
||||
if any(x in fmt for x in ["%H", "%I", "T"]):
|
||||
if django_timezone.is_naive(dt) and tz_name:
|
||||
try: dt = django_timezone.make_aware(dt, zoneinfo.ZoneInfo(tz_name))
|
||||
except: pass
|
||||
return dt
|
||||
return dt.date()
|
||||
except ValueError: continue
|
||||
return None
|
||||
|
||||
def _robust_decode(content):
|
||||
if not content: return ""
|
||||
for enc in ["utf-8-sig", "utf-8", "iso-8859-1", "windows-1252"]:
|
||||
try: return content.decode(enc)
|
||||
except UnicodeDecodeError: continue
|
||||
return content.decode("utf-8", errors="replace")
|
||||
|
||||
def _read_csv_robust(file_path):
|
||||
"""
|
||||
Optimized version: Read and decode the file into memory once,
|
||||
but return a StringIO for stream-like processing.
|
||||
"""
|
||||
with open(file_path, "rb") as f:
|
||||
content = _robust_decode(f.read())
|
||||
return io.StringIO(content)
|
||||
|
||||
class BaseImportAdminMixin:
|
||||
actions = ["export_as_csv"]
|
||||
|
||||
def export_as_csv(self, request, queryset):
|
||||
meta = self.model._meta
|
||||
field_names = [field.name for field in meta.fields]
|
||||
include_voter_id = "voter" in field_names and self.model != Voter
|
||||
response = HttpResponse(content_type="text/csv")
|
||||
response["Content-Disposition"] = f"attachment; filename={meta.model_name}_export.csv"
|
||||
writer = csv.writer(response)
|
||||
headers = []
|
||||
for name in field_names:
|
||||
headers.append(name)
|
||||
if name == "voter" and include_voter_id: headers.append("voter_id")
|
||||
writer.writerow(headers)
|
||||
for obj in queryset:
|
||||
row = []
|
||||
for field in field_names:
|
||||
value = getattr(obj, field)
|
||||
if isinstance(value, (datetime, date)): value = value.strftime("%Y-%m-%d %H:%M:%S") if isinstance(value, datetime) else value.strftime("%Y-%m-%d")
|
||||
elif hasattr(value, "id"): value = str(value)
|
||||
row.append(value)
|
||||
if field == "voter" and include_voter_id: row.append(obj.voter.voter_id if obj.voter else "")
|
||||
writer.writerow(row)
|
||||
return response
|
||||
export_as_csv.short_description = "Export Selected as CSV"
|
||||
|
||||
def download_errors(self, request):
|
||||
failed_rows = request.session.get(f"{self.model._meta.model_name}_import_errors", [])
|
||||
if not failed_rows:
|
||||
self.message_user(request, "No errors found.", level=messages.WARNING)
|
||||
return redirect("../")
|
||||
output = io.StringIO()
|
||||
if failed_rows:
|
||||
writer = csv.DictWriter(output, fieldnames=failed_rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(failed_rows)
|
||||
response = HttpResponse(output.getvalue(), content_type='text/csv')
|
||||
response['Content-Disposition'] = f'attachment; filename="{self.model._meta.model_name}_import_errors.csv"'
|
||||
return response
|
||||
|
||||
VOTER_MAPPABLE_FIELDS = [
|
||||
('voter_id', 'Voter ID'),
|
||||
('first_name', 'First Name'),
|
||||
('last_name', 'Last Name'),
|
||||
('nickname', 'Nickname'),
|
||||
('birthdate', 'Birthdate'),
|
||||
('address_street', 'Street Address'),
|
||||
('city', 'City'),
|
||||
('state', 'State'),
|
||||
('zip_code', 'Zip Code'),
|
||||
('phone', 'Phone'),
|
||||
('email', 'Email'),
|
||||
('is_targeted', 'Is Targeted'),
|
||||
('target_door_visit', 'Target Door Visit'),
|
||||
('candidate_support', 'Candidate Support'),
|
||||
('yard_sign', 'Yard Sign'),
|
||||
('ever_had_yard_sign', 'Ever Had Yard Sign'),
|
||||
('ever_had_large_sign', 'Ever Had Large Sign'),
|
||||
('is_inactive', 'Is Inactive'),
|
||||
('door_visit', 'Door Visit'),
|
||||
('voted', 'Voted'),
|
||||
('neighborhood', 'Neighborhood'),
|
||||
('district', 'District'),
|
||||
('precinct', 'Precinct'),
|
||||
('registration_date', 'Registration Date'),
|
||||
('call_queue_status', 'Call Queue Status'),
|
||||
]
|
||||
|
||||
INTERACTION_MAPPABLE_FIELDS = [('voter_id', 'Voter ID'), ('volunteer_email', 'Volunteer Email'), ('date', 'Date'), ('type', 'Type'), ('description', 'Description'), ('notes', 'Notes')]
|
||||
VOLUNTEER_MAPPABLE_FIELDS = [('first_name', 'First Name'), ('last_name', 'Last Name'), ('email', 'Email'), ('phone', 'Phone')]
|
||||
VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [('voter_id', 'Voter ID'), ('election_type', 'Election Type'), ('likelihood', 'Likelihood')]
|
||||
VOTING_RECORD_MAPPABLE_FIELDS = [('voter_id', 'Voter ID'), ('election_date', 'Election Date'), ('election_description', 'Description'), ('primary_party', 'Primary Party')]
|
||||
EVENT_MAPPABLE_FIELDS = [('name', 'Name'), ('date', 'Date'), ('event_type', 'Event Type'), ('location_name', 'Location'), ('address', 'Address'), ('city', 'City'), ('state', 'State'), ('zip_code', 'Zip Code'), ('start_time', 'Start Time'), ('end_time', 'End Time')]
|
||||
|
||||
@admin.register(Voter)
|
||||
class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('voter_id', 'first_name', 'last_name', 'city', 'state', 'is_inactive', 'target_door_visit', 'ever_had_yard_sign', 'ever_had_large_sign', 'tenant')
|
||||
list_filter = ('tenant', 'is_inactive', 'ever_had_yard_sign', 'ever_had_large_sign', 'target_door_visit', 'candidate_support', 'call_queue_status')
|
||||
search_fields = ('voter_id', 'first_name', 'last_name', 'email', 'phone')
|
||||
change_list_template = "admin/voter_change_list.html"
|
||||
|
||||
def get_urls(self):
|
||||
return [
|
||||
path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voter-download-errors'),
|
||||
path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters')
|
||||
] + super().get_urls()
|
||||
|
||||
def import_voters(self, request):
|
||||
if request.method == "POST":
|
||||
if "_preview" in request.POST:
|
||||
file_path, tenant_id = request.POST.get("file_path"), request.POST.get("tenant")
|
||||
tenant, mapping = Tenant.objects.get(id=tenant_id), {fn: request.POST.get(f"map_{fn}") for fn, _ in VOTER_MAPPABLE_FIELDS}
|
||||
try:
|
||||
with _read_csv_robust(file_path) as f:
|
||||
total_count = sum(1 for line in f) - 1
|
||||
f.seek(0)
|
||||
reader = csv.DictReader(f)
|
||||
preview_rows, v_ids = [], []
|
||||
for i, row in enumerate(reader):
|
||||
if i < 10:
|
||||
preview_rows.append(row)
|
||||
vid = row.get(mapping.get("voter_id"))
|
||||
if vid: v_ids.append(vid.strip())
|
||||
else: break
|
||||
existing = set(Voter.objects.filter(tenant=tenant, voter_id__in=v_ids).values_list("voter_id", flat=True))
|
||||
preview_data = [{
|
||||
"action": "update" if r.get(mapping.get("voter_id"), "").strip() in existing else "create",
|
||||
"identifier": f"Voter ID: {r.get(mapping.get('voter_id'))}",
|
||||
"details": f"Name: {r.get(mapping.get('first_name', ''))} {r.get(mapping.get('last_name', ''))}"
|
||||
} for r in preview_rows]
|
||||
context = self.admin_site.each_context(request)
|
||||
context.update({
|
||||
"title": "Import Preview",
|
||||
"total_count": total_count,
|
||||
"create_count": sum(1 for d in preview_data if d['action'] == 'create'),
|
||||
"update_count": sum(1 for d in preview_data if d['action'] == 'update'),
|
||||
"preview_data": preview_data,
|
||||
"mapping": mapping,
|
||||
"file_path": file_path,
|
||||
"tenant_id": tenant_id,
|
||||
"action_url": request.path,
|
||||
"opts": self.model._meta
|
||||
})
|
||||
return render(request, "admin/import_preview.html", context)
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error: {e}", level=messages.ERROR)
|
||||
return redirect("../")
|
||||
|
||||
elif "_import" in request.POST:
|
||||
file_path, tenant_id = request.POST.get("file_path"), request.POST.get("tenant")
|
||||
tenant, mapping = Tenant.objects.get(id=tenant_id), {fn: request.POST.get(f"map_{fn}") for fn, _ in VOTER_MAPPABLE_FIELDS}
|
||||
try:
|
||||
created, updated, errors, failed = 0, 0, 0, []
|
||||
|
||||
with _read_csv_robust(file_path) as f:
|
||||
reader = csv.DictReader(f)
|
||||
chunk_size = 500
|
||||
chunk = []
|
||||
for row in reader:
|
||||
chunk.append(row)
|
||||
if len(chunk) >= chunk_size:
|
||||
c, u, e, f_rows = self._process_voter_chunk(tenant, mapping, chunk)
|
||||
created += c; updated += u; errors += e; failed.extend(f_rows)
|
||||
chunk = []
|
||||
if chunk:
|
||||
c, u, e, f_rows = self._process_voter_chunk(tenant, mapping, chunk)
|
||||
created += c; updated += u; errors += e; failed.extend(f_rows)
|
||||
|
||||
# Efficient post-import cleanup for the entire tenant
|
||||
self._run_voter_post_import_cleanup(tenant)
|
||||
|
||||
if os.path.exists(file_path): os.remove(file_path)
|
||||
self.message_user(request, f"Import complete: {created} created, {updated} updated, {errors} errors")
|
||||
request.session[f"{self.model._meta.model_name}_import_errors"] = failed[:1000]
|
||||
request.session.modified = True
|
||||
return redirect("../")
|
||||
except Exception as e:
|
||||
logger.error(f"Voter import failed: {e}", exc_info=True)
|
||||
self.message_user(request, f"Error: {e}", level=messages.ERROR)
|
||||
return redirect("../")
|
||||
else:
|
||||
form = VoterImportForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
|
||||
for chunk in request.FILES['file'].chunks(): tmp.write(chunk)
|
||||
file_path = tmp.name
|
||||
with _read_csv_robust(file_path) as f:
|
||||
headers = next(csv.reader(f))
|
||||
context = self.admin_site.each_context(request)
|
||||
context.update({
|
||||
"title": "Map Voter Fields",
|
||||
"headers": headers,
|
||||
"model_fields": VOTER_MAPPABLE_FIELDS,
|
||||
"tenant_id": form.cleaned_data['tenant'].id,
|
||||
"file_path": file_path,
|
||||
"action_url": request.path,
|
||||
"opts": self.model._meta
|
||||
})
|
||||
return render(request, "admin/import_mapping.html", context)
|
||||
return render(request, "admin/import_csv.html", {'form': VoterImportForm(), 'title': "Import Voters", 'opts': self.model._meta, 'action_url': request.path})
|
||||
|
||||
def _process_voter_chunk(self, tenant, mapping, chunk):
|
||||
created, updated, errors = 0, 0, 0
|
||||
failed = []
|
||||
voter_ids = [row.get(mapping.get("voter_id"), "").strip() for row in chunk if row.get(mapping.get("voter_id"))]
|
||||
existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)}
|
||||
|
||||
to_create = []
|
||||
to_update = []
|
||||
|
||||
# We'll use a transaction for each chunk to keep it atomic but not lock the whole table for long
|
||||
with transaction.atomic():
|
||||
for row in chunk:
|
||||
try:
|
||||
vid = row.get(mapping.get("voter_id"), "").strip()
|
||||
if not vid:
|
||||
row["Import Error"] = "Missing Voter ID"
|
||||
failed.append(row); errors += 1; continue
|
||||
|
||||
defaults = {}
|
||||
for fn, _ in VOTER_MAPPABLE_FIELDS:
|
||||
if fn == "voter_id": continue
|
||||
val = row.get(mapping.get(fn), "").strip()
|
||||
if not val: continue
|
||||
if fn in ["birthdate", "registration_date"]:
|
||||
defaults[fn] = parse_any_date(val)
|
||||
elif fn in ["is_targeted", "is_inactive", "target_door_visit", "door_visit", "voted"]:
|
||||
defaults[fn] = val.lower() in ['true', '1', 'yes']
|
||||
elif fn == "phone":
|
||||
defaults[fn] = format_phone_number(val)
|
||||
elif fn == "email":
|
||||
defaults[fn] = val.lower()
|
||||
elif fn == "call_queue_status":
|
||||
# Try to match label if it's not a valid internal value
|
||||
valid_keys = [c[0] for c in Voter.CALL_QUEUE_STATUS_CHOICES]
|
||||
if val not in valid_keys:
|
||||
label_map = {c[1].lower(): c[0] for c in Voter.CALL_QUEUE_STATUS_CHOICES}
|
||||
if val.lower() in label_map:
|
||||
defaults[fn] = label_map[val.lower()]
|
||||
else:
|
||||
defaults[fn] = val
|
||||
else:
|
||||
defaults[fn] = val
|
||||
else:
|
||||
defaults[fn] = val
|
||||
|
||||
if defaults.get("voted") is True:
|
||||
defaults["target_door_visit"] = False
|
||||
defaults["call_queue_status"] = "no_call_required"
|
||||
|
||||
voter = existing_voters.get(vid)
|
||||
if voter:
|
||||
for k, v in defaults.items(): setattr(voter, k, v)
|
||||
voter._skip_geocode = True # Important for performance
|
||||
to_update.append(voter)
|
||||
updated += 1
|
||||
else:
|
||||
voter = Voter(tenant=tenant, voter_id=vid, **defaults)
|
||||
voter._skip_geocode = True
|
||||
to_create.append(voter)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
row["Import Error"] = str(e)
|
||||
failed.append(row); errors += 1
|
||||
|
||||
if to_create:
|
||||
Voter.objects.bulk_create(to_create)
|
||||
if to_update:
|
||||
# bulk_update requires specifying fields
|
||||
fields = [fn for fn, _ in VOTER_MAPPABLE_FIELDS if fn != 'voter_id']
|
||||
Voter.objects.bulk_update(to_update, fields)
|
||||
|
||||
return created, updated, errors, failed
|
||||
|
||||
def _run_voter_post_import_cleanup(self, tenant):
|
||||
"""
|
||||
Runs the logic that was previously in signals but optimized for bulk.
|
||||
"""
|
||||
from django.db.models import Exists, OuterRef
|
||||
|
||||
# 0. Ensure consistency for voters who voted
|
||||
Voter.objects.filter(tenant=tenant, voted=True).update(
|
||||
target_door_visit=False,
|
||||
call_queue_status="no_call_required"
|
||||
)
|
||||
ScheduledCall.objects.filter(tenant=tenant, voter__voted=True, status="pending").update(status="cancelled")
|
||||
|
||||
# 1. Update target_door_visit logic (based on signal logic)
|
||||
# Set target_door_visit = False if door_visit = False and someone in household is targeted or has support
|
||||
# This is a bit complex to do in one query, but let's do the most important parts.
|
||||
|
||||
# Signal 1: Update target_door_visit = False if someone in household attended event or has support
|
||||
subquery = Voter.objects.filter(
|
||||
address_street=OuterRef('address_street'),
|
||||
city=OuterRef('city'),
|
||||
state=OuterRef('state'),
|
||||
zip_code=OuterRef('zip_code'),
|
||||
tenant=tenant,
|
||||
is_targeted=True
|
||||
)
|
||||
|
||||
# Set target_door_visit = False if NO ONE in household is targeted
|
||||
Voter.objects.filter(
|
||||
tenant=tenant,
|
||||
door_visit=False,
|
||||
target_door_visit=True
|
||||
).annotate(has_targeted=Exists(subquery)).filter(has_targeted=False).update(target_door_visit=False)
|
||||
|
||||
# Signal 2: Update candidate_support to 'supporting' if someone in household has yard sign AND voter is > 30
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
thirty_years_ago = today.replace(year=today.year - 30) if today.month != 2 or today.day != 29 else today.replace(year=today.year - 30, day=28)
|
||||
|
||||
sign_subquery = Voter.objects.filter(
|
||||
address_street=OuterRef('address_street'),
|
||||
city=OuterRef('city'),
|
||||
state=OuterRef('state'),
|
||||
zip_code=OuterRef('zip_code'),
|
||||
tenant=tenant,
|
||||
yard_sign__in=['wants', 'has']
|
||||
)
|
||||
|
||||
Voter.objects.filter(
|
||||
tenant=tenant,
|
||||
birthdate__lte=thirty_years_ago
|
||||
).exclude(
|
||||
candidate_support='supporting'
|
||||
).annotate(household_has_sign=Exists(sign_subquery)).filter(household_has_sign=True).update(candidate_support='supporting')
|
||||
|
||||
class MassAssignVolunteerForm(forms.Form):
|
||||
volunteer = forms.ModelChoiceField(queryset=Volunteer.objects.none(), required=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
tenant_ids = kwargs.pop('tenant_ids', [])
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant_ids:
|
||||
self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant_id__in=tenant_ids).order_by('first_name', 'last_name')
|
||||
else:
|
||||
self.fields['volunteer'].queryset = Volunteer.objects.all().order_by('first_name', 'last_name')
|
||||
|
||||
|
||||
@admin.register(Interaction)
|
||||
class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('voter', 'date', 'type', 'description', 'volunteer')
|
||||
list_filter = ('voter__tenant', 'type', 'volunteer')
|
||||
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description')
|
||||
autocomplete_fields = ['voter', 'volunteer']
|
||||
change_list_template = 'admin/interaction_change_list.html'
|
||||
actions = ['mass_assign_volunteer']
|
||||
|
||||
@admin.action(description="Assign selected interactions to a volunteer")
|
||||
def mass_assign_volunteer(self, request, queryset):
|
||||
tenant_ids = list(queryset.values_list('voter__tenant_id', flat=True).distinct())
|
||||
|
||||
if 'apply' in request.POST:
|
||||
form = MassAssignVolunteerForm(request.POST, tenant_ids=tenant_ids)
|
||||
if form.is_valid():
|
||||
volunteer = form.cleaned_data['volunteer']
|
||||
updated = queryset.update(volunteer=volunteer)
|
||||
self.message_user(request, f"Successfully assigned {updated} interactions to {volunteer}.", messages.SUCCESS)
|
||||
return None
|
||||
else:
|
||||
form = MassAssignVolunteerForm(tenant_ids=tenant_ids)
|
||||
|
||||
return TemplateResponse(request, "admin/mass_assign_volunteer.html", {
|
||||
'queryset': queryset,
|
||||
'form': form,
|
||||
'opts': self.model._meta,
|
||||
'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
|
||||
})
|
||||
|
||||
|
||||
def get_urls(self):
|
||||
return [
|
||||
path('download-errors/', self.admin_site.admin_view(self.download_errors), name='interaction-download-errors'),
|
||||
path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions')
|
||||
] + super().get_urls()
|
||||
|
||||
def import_interactions(self, request):
|
||||
if request.method == "POST":
|
||||
if "_preview" in request.POST:
|
||||
file_path, tenant_id = request.POST.get('file_path'), request.POST.get('tenant')
|
||||
tenant = Tenant.objects.get(id=tenant_id)
|
||||
campaign_tz = getattr(tenant.settings, 'timezone', 'UTC')
|
||||
mapping = {fn: request.POST.get(f'map_{fn}') for fn, _ in INTERACTION_MAPPABLE_FIELDS}
|
||||
try:
|
||||
with _read_csv_robust(file_path) as f:
|
||||
reader = csv.DictReader(f)
|
||||
total_count, create_count, update_count, preview_data = 0, 0, 0, []
|
||||
for row in reader:
|
||||
total_count += 1
|
||||
vid, type_name, date_str = row.get(mapping.get('voter_id')), row.get(mapping.get('type')), row.get(mapping.get('date'))
|
||||
parsed_date = parse_any_date(date_str, campaign_tz)
|
||||
exists = False
|
||||
if vid and type_name and parsed_date:
|
||||
try:
|
||||
voter = Voter.objects.get(tenant=tenant, voter_id=vid)
|
||||
exists = Interaction.objects.filter(voter=voter, type__name=type_name, date=parsed_date).exists()
|
||||
except: pass
|
||||
if exists: update_count += 1
|
||||
else: create_count += 1
|
||||
if len(preview_data) < 10:
|
||||
preview_data.append({'action': 'update' if exists else 'create', 'identifier': f"Voter ID: {vid}", 'details': f"Type: {type_name}, Date: {date_str}"})
|
||||
context = self.admin_site.each_context(request)
|
||||
context.update({'title': "Import Preview", 'total_count': total_count, 'create_count': create_count, 'update_count': update_count, 'preview_data': preview_data, 'mapping': mapping, 'file_path': file_path, 'tenant_id': tenant_id, 'action_url': request.path, 'opts': self.model._meta})
|
||||
return render(request, "admin/import_preview.html", context)
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error: {e}", level=messages.ERROR)
|
||||
return redirect("../")
|
||||
|
||||
elif "_import" in request.POST:
|
||||
file_path, tenant_id = request.POST.get('file_path'), request.POST.get('tenant')
|
||||
tenant = Tenant.objects.get(id=tenant_id)
|
||||
campaign_tz = getattr(tenant.settings, 'timezone', 'UTC')
|
||||
mapping = {fn: request.POST.get(f'map_{fn}') for fn, _ in INTERACTION_MAPPABLE_FIELDS}
|
||||
try:
|
||||
count, errors, failed = 0, 0, []
|
||||
# Optimized to avoid loading ALL voters
|
||||
with _read_csv_robust(file_path) as f:
|
||||
reader = csv.DictReader(f)
|
||||
chunk_size = 500
|
||||
chunk = []
|
||||
for row in reader:
|
||||
chunk.append(row)
|
||||
if len(chunk) >= chunk_size:
|
||||
c, e, f_rows = self._process_interaction_chunk(tenant, mapping, chunk, campaign_tz)
|
||||
count += c; errors += e; failed.extend(f_rows)
|
||||
chunk = []
|
||||
if chunk:
|
||||
c, e, f_rows = self._process_interaction_chunk(tenant, mapping, chunk, campaign_tz)
|
||||
count += c; errors += e; failed.extend(f_rows)
|
||||
|
||||
if os.path.exists(file_path): os.remove(file_path)
|
||||
self.message_user(request, f"Imported {count} interactions, {errors} errors")
|
||||
request.session[f"{self.model._meta.model_name}_import_errors"] = failed[:1000]
|
||||
request.session.modified = True
|
||||
return redirect("../")
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error: {e}", level=messages.ERROR)
|
||||
return redirect("../")
|
||||
else:
|
||||
form = InteractionImportForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
|
||||
for chunk in request.FILES['file'].chunks(): tmp.write(chunk)
|
||||
file_path = tmp.name
|
||||
with _read_csv_robust(file_path) as f:
|
||||
headers = next(csv.reader(f))
|
||||
context = self.admin_site.each_context(request)
|
||||
context.update({'title': "Map Interaction Fields", 'headers': headers, 'model_fields': INTERACTION_MAPPABLE_FIELDS, 'tenant_id': form.cleaned_data['tenant'].id, 'file_path': file_path, 'action_url': request.path, 'opts': self.model._meta})
|
||||
return render(request, "admin/import_mapping.html", context)
|
||||
return render(request, "admin/import_csv.html", {'form': InteractionImportForm(), 'title': "Import Interactions", 'opts': self.model._meta, 'action_url': request.path})
|
||||
|
||||
def _process_interaction_chunk(self, tenant, mapping, chunk, campaign_tz):
|
||||
count, errors = 0, 0
|
||||
failed = []
|
||||
voter_ids = [row.get(mapping.get("voter_id"), "").strip() for row in chunk if row.get(mapping.get("voter_id"))]
|
||||
voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)}
|
||||
|
||||
# Pre-fetch interaction types
|
||||
type_names = [row.get(mapping.get("type"), "").strip() for row in chunk if row.get(mapping.get("type"))]
|
||||
types = {t.name: t for t in InteractionType.objects.filter(tenant=tenant, name__in=type_names)}
|
||||
|
||||
to_create = []
|
||||
with transaction.atomic():
|
||||
for row in chunk:
|
||||
try:
|
||||
vid, type_name, date_str = row.get(mapping.get('voter_id'), "").strip(), row.get(mapping.get('type'), "").strip(), row.get(mapping.get('date'), "").strip()
|
||||
if not vid or not type_name or not date_str:
|
||||
row["Import Error"] = "Missing fields"; failed.append(row); errors += 1; continue
|
||||
|
||||
voter = voters.get(vid)
|
||||
if not voter:
|
||||
row["Import Error"] = f"Voter {vid} not found"; failed.append(row); errors += 1; continue
|
||||
|
||||
it_type = types.get(type_name)
|
||||
if not it_type:
|
||||
it_type, created = InteractionType.objects.get_or_create(tenant=tenant, name=type_name)
|
||||
types[type_name] = it_type
|
||||
|
||||
parsed_date = parse_any_date(date_str, campaign_tz)
|
||||
if not parsed_date:
|
||||
row["Import Error"] = f"Invalid date: {date_str}"; failed.append(row); errors += 1; continue
|
||||
|
||||
# Interaction model uses DateTimeField, so if we got a date, we should make it a datetime
|
||||
if isinstance(parsed_date, date) and not isinstance(parsed_date, datetime):
|
||||
parsed_date = datetime.combine(parsed_date, datetime.min.time())
|
||||
if django_timezone.is_naive(parsed_date):
|
||||
parsed_date = django_timezone.make_aware(parsed_date, zoneinfo.ZoneInfo(campaign_tz))
|
||||
|
||||
to_create.append(Interaction(
|
||||
voter=voter,
|
||||
type=it_type,
|
||||
date=parsed_date,
|
||||
description=row.get(mapping.get('description'), "")[:255],
|
||||
notes=row.get(mapping.get('notes'), "")
|
||||
))
|
||||
count += 1
|
||||
except Exception as e:
|
||||
row["Import Error"] = str(e)
|
||||
failed.append(row); errors += 1
|
||||
|
||||
if to_create:
|
||||
Interaction.objects.bulk_create(to_create)
|
||||
return count, errors, failed
|
||||
|
||||
@admin.register(DonationMethod)
|
||||
class DonationMethodAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'tenant', 'is_active')
|
||||
list_filter = ('tenant', 'is_active')
|
||||
|
||||
@admin.register(Donation)
|
||||
class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('voter', 'amount', 'date', 'method', 'tenant_name')
|
||||
list_filter = ('voter__tenant', 'method', 'date')
|
||||
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
|
||||
autocomplete_fields = ['voter']
|
||||
|
||||
def tenant_name(self, obj): return obj.voter.tenant.name
|
||||
tenant_name.short_description = "Tenant"
|
||||
|
||||
@admin.register(InteractionType)
|
||||
class InteractionTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'tenant', 'is_active')
|
||||
list_filter = ('tenant', 'is_active')
|
||||
|
||||
@admin.register(ElectionType)
|
||||
class ElectionTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'tenant', 'is_active')
|
||||
list_filter = ('tenant', 'is_active')
|
||||
|
||||
@admin.register(VoterLikelihood)
|
||||
class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('voter', 'election_type', 'likelihood')
|
||||
list_filter = ('voter__tenant', 'election_type', 'likelihood')
|
||||
autocomplete_fields = ['voter']
|
||||
|
||||
@admin.register(VotingRecord)
|
||||
class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('voter', 'election_date', 'election_description', 'primary_party')
|
||||
list_filter = ('voter__tenant', 'election_date', 'primary_party')
|
||||
autocomplete_fields = ['voter']
|
||||
|
||||
@admin.register(Event)
|
||||
class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('name', 'date', 'event_type', 'tenant')
|
||||
list_filter = ('tenant', 'event_type', 'date')
|
||||
search_fields = ('name', 'location_name')
|
||||
|
||||
@admin.register(Volunteer)
|
||||
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant')
|
||||
list_filter = ('tenant',)
|
||||
search_fields = ('first_name', 'last_name', 'email')
|
||||
|
||||
@admin.register(CampaignSettings)
|
||||
class CampaignSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ('tenant', 'timezone', 'donation_goal')
|
||||
list_filter = ('tenant',)
|
||||
|
||||
@admin.register(ScheduledCall)
|
||||
class ScheduledCallAdmin(admin.ModelAdmin):
|
||||
list_display = ('voter', 'volunteer', 'status', 'created_at', 'tenant')
|
||||
list_filter = ('tenant', 'status', 'volunteer')
|
||||
autocomplete_fields = ['voter', 'volunteer']
|
||||
0
core/admin_backup.txt
Normal file
0
core/admin_backup.txt
Normal file
1
core/admin_reconstruct.txt
Normal file
1
core/admin_reconstruct.txt
Normal file
File diff suppressed because one or more lines are too long
1
core/admin_recovered.py
Normal file
1
core/admin_recovered.py
Normal file
@ -0,0 +1 @@
|
||||
NONE
|
||||
1
core/admin_restored.py
Normal file
1
core/admin_restored.py
Normal file
@ -0,0 +1 @@
|
||||
NONE
|
||||
6
core/apps.py
Normal file
6
core/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
67
core/bulk_email_new.py
Normal file
67
core/bulk_email_new.py
Normal file
@ -0,0 +1,67 @@
|
||||
def voter_bulk_send_email(request):
|
||||
selected_tenant_id = request.session.get("tenant_id")
|
||||
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||
campaign_settings = CampaignSettings.objects.get(tenant=tenant)
|
||||
|
||||
if request.method == 'POST':
|
||||
subject = request.POST.get('subject')
|
||||
body = request.POST.get('body')
|
||||
is_html = request.POST.get("is_html") == "on"
|
||||
select_all_results = request.POST.get('select_all_results') == 'true'
|
||||
|
||||
if select_all_results:
|
||||
voters, _ = get_filtered_voter_queryset(request, tenant, data_source='POST')
|
||||
voters = voters.exclude(email='')
|
||||
else:
|
||||
voter_ids = request.POST.getlist('selected_voters')
|
||||
voters = Voter.objects.filter(id__in=voter_ids, tenant=tenant).exclude(email='')
|
||||
|
||||
if not voters.exists():
|
||||
messages.warning(request, "No voters with email addresses selected.")
|
||||
return redirect('voter_advanced_search')
|
||||
|
||||
connection = get_tenant_email_connection(campaign_settings)
|
||||
if not connection:
|
||||
messages.error(request, "SMTP settings are not configured. Please check Campaign Settings.")
|
||||
return redirect('voter_advanced_search')
|
||||
|
||||
from_email = campaign_settings.email_from_address or settings.DEFAULT_FROM_EMAIL
|
||||
if campaign_settings.email_from_name:
|
||||
from_email = f"{campaign_settings.email_from_name} <{from_email}>"
|
||||
email_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name='Email')
|
||||
|
||||
sent_count = 0
|
||||
error_count = 0
|
||||
|
||||
for voter in voters:
|
||||
try:
|
||||
email = EmailMessage(
|
||||
subject,
|
||||
body,
|
||||
from_email,
|
||||
[voter.email],
|
||||
connection=connection,
|
||||
)
|
||||
if is_html:
|
||||
email.content_subtype = "html"
|
||||
email.send()
|
||||
sent_count += 1
|
||||
|
||||
# Log interaction
|
||||
Interaction.objects.create(
|
||||
voter=voter,
|
||||
type=email_type,
|
||||
date=timezone.now(),
|
||||
description=subject,
|
||||
notes=body
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending bulk email to voter {voter.email}: {e}")
|
||||
error_count += 1
|
||||
|
||||
if sent_count > 0:
|
||||
messages.success(request, f"Successfully sent {sent_count} emails.")
|
||||
if error_count > 0:
|
||||
messages.error(request, f"Failed to send {error_count} emails.")
|
||||
|
||||
return redirect('voter_advanced_search')
|
||||
92
core/bulk_sms_new.py
Normal file
92
core/bulk_sms_new.py
Normal file
@ -0,0 +1,92 @@
|
||||
def bulk_send_sms(request):
|
||||
"""
|
||||
Sends bulk SMS to selected voters using Twilio API.
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return redirect('voter_advanced_search')
|
||||
|
||||
selected_tenant_id = request.session.get("tenant_id")
|
||||
if not selected_tenant_id:
|
||||
messages.warning(request, "Please select a campaign first.")
|
||||
return redirect("index")
|
||||
|
||||
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||
settings = getattr(tenant, 'settings', None)
|
||||
if not settings:
|
||||
messages.error(request, "Campaign settings not found.")
|
||||
return redirect('voter_advanced_search')
|
||||
|
||||
account_sid = settings.twilio_account_sid
|
||||
auth_token = settings.twilio_auth_token
|
||||
from_number = settings.twilio_from_number
|
||||
|
||||
if not account_sid or not auth_token or not from_number:
|
||||
messages.error(request, "Twilio configuration is incomplete in Campaign Settings.")
|
||||
return redirect('voter_advanced_search')
|
||||
|
||||
message_body = request.POST.get('message_body')
|
||||
if not message_body:
|
||||
messages.error(request, "Message body cannot be empty.")
|
||||
return redirect('voter_advanced_search')
|
||||
|
||||
select_all_results = request.POST.get('select_all_results') == 'true'
|
||||
|
||||
if select_all_results:
|
||||
voters, _ = get_filtered_voter_queryset(request, tenant, data_source='POST')
|
||||
voters = voters.filter(phone_type='cell').exclude(phone='')
|
||||
else:
|
||||
voter_ids = request.POST.getlist('selected_voters')
|
||||
voters = Voter.objects.filter(tenant=tenant, id__in=voter_ids, phone_type='cell').exclude(phone='')
|
||||
|
||||
if not voters.exists():
|
||||
messages.warning(request, "No voters with a valid cell phone number were selected.")
|
||||
return redirect('voter_advanced_search')
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
auth_str = f"{account_sid}:{auth_token}"
|
||||
auth_header = base64.b64encode(auth_str.encode()).decode()
|
||||
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
|
||||
|
||||
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="SMS Text")
|
||||
|
||||
for voter in voters:
|
||||
digits = re.sub(r'\D', '', str(voter.phone))
|
||||
if len(digits) == 10:
|
||||
to_number = f"+1{digits}"
|
||||
elif len(digits) == 11 and digits.startswith('1'):
|
||||
to_number = f"+{digits}"
|
||||
else:
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
data_dict = {
|
||||
'To': to_number,
|
||||
'From': from_number,
|
||||
'Body': message_body
|
||||
}
|
||||
data = urllib.parse.urlencode(data_dict).encode()
|
||||
|
||||
req = urllib.request.Request(url, data=data, method='POST')
|
||||
req.add_header("Authorization", f"Basic {auth_header}")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
if response.status in [200, 201]:
|
||||
success_count += 1
|
||||
Interaction.objects.create(
|
||||
voter=voter,
|
||||
type=interaction_type,
|
||||
date=timezone.now(),
|
||||
description='Mass SMS Text',
|
||||
notes=message_body
|
||||
)
|
||||
else:
|
||||
fail_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending SMS to {voter.phone}: {e}")
|
||||
fail_count += 1
|
||||
|
||||
messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.")
|
||||
return redirect('voter_advanced_search')
|
||||
43
core/context_processors.py
Normal file
43
core/context_processors.py
Normal file
@ -0,0 +1,43 @@
|
||||
import os
|
||||
import time
|
||||
from django.conf import settings
|
||||
from .models import Tenant
|
||||
from .permissions import (
|
||||
can_view_donations, can_edit_voter, get_user_role,
|
||||
can_view_volunteers, can_edit_volunteer, can_view_voters,
|
||||
is_block_walker, STAFF_ROLES, can_access_call_queue
|
||||
)
|
||||
|
||||
def project_context(request):
|
||||
"""
|
||||
Adds project-specific environment variables to the template context globally.
|
||||
"""
|
||||
context = {
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
# Used for cache-busting static assets
|
||||
"deployment_timestamp": int(time.time()),
|
||||
"GOOGLE_MAPS_API_KEY": getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
|
||||
}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
context['is_block_walker'] = is_block_walker(request.user)
|
||||
context['is_staff'] = request.user.is_superuser
|
||||
context['can_access_call_queue'] = can_access_call_queue(request.user)
|
||||
|
||||
tenant_id = request.session.get('tenant_id')
|
||||
if tenant_id:
|
||||
tenant = Tenant.objects.filter(id=tenant_id).first()
|
||||
if tenant:
|
||||
context['can_view_donations'] = can_view_donations(request.user, tenant)
|
||||
context['can_edit_voter'] = can_edit_voter(request.user, tenant)
|
||||
context['can_view_voters'] = can_view_voters(request.user, tenant)
|
||||
context['can_view_volunteers'] = can_view_volunteers(request.user, tenant)
|
||||
context['can_edit_volunteer'] = can_edit_volunteer(request.user, tenant)
|
||||
|
||||
role = get_user_role(request.user, tenant)
|
||||
context['user_role'] = role
|
||||
if not context['is_staff']:
|
||||
context['is_staff'] = role in STAFF_ROLES
|
||||
|
||||
return context
|
||||
64
core/export_new.py
Normal file
64
core/export_new.py
Normal file
@ -0,0 +1,64 @@
|
||||
def export_voters_csv(request):
|
||||
"""
|
||||
Exports selected or filtered voters to a CSV file.
|
||||
"""
|
||||
selected_tenant_id = request.session.get("tenant_id")
|
||||
if not selected_tenant_id:
|
||||
messages.warning(request, "Please select a campaign first.")
|
||||
return redirect("index")
|
||||
|
||||
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||
|
||||
if request.method != 'POST':
|
||||
return redirect('voter_advanced_search')
|
||||
|
||||
action = request.POST.get('action')
|
||||
select_all_results = request.POST.get('select_all_results') == 'true' or action == 'export_all'
|
||||
|
||||
if select_all_results:
|
||||
voters, _ = get_filtered_voter_queryset(request, tenant, data_source='POST')
|
||||
else:
|
||||
voter_ids = request.POST.getlist('selected_voters')
|
||||
voters = Voter.objects.filter(tenant=tenant, id__in=voter_ids, is_inactive=False)
|
||||
|
||||
voters = voters.order_by('last_name', 'first_name')
|
||||
|
||||
response = HttpResponse(content_type='text/csv')
|
||||
response['Content-Disposition'] = f'attachment; filename="voters_export_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"'
|
||||
|
||||
writer = csv.writer(response)
|
||||
writer.writerow([
|
||||
'Voter ID', 'First Name', 'Last Name', 'Nickname', 'Birthdate',
|
||||
'Address', 'City', 'State', 'Zip Code', 'Neighborhood', 'Phone', 'Phone Type', 'Secondary Phone', 'Secondary Phone Type', 'Email',
|
||||
'District', 'Precinct', 'Is Targeted', 'Voted', 'Support', 'Yard Sign', 'Window Sticker', 'Call Queue Status', 'Notes'
|
||||
])
|
||||
|
||||
for voter in voters:
|
||||
writer.writerow([
|
||||
voter.voter_id,
|
||||
voter.first_name,
|
||||
voter.last_name,
|
||||
voter.nickname,
|
||||
voter.birthdate.strftime('%Y-%m-%d') if voter.birthdate else '',
|
||||
voter.address,
|
||||
voter.city,
|
||||
voter.state,
|
||||
voter.zip_code,
|
||||
voter.neighborhood,
|
||||
voter.phone,
|
||||
voter.get_phone_type_display(),
|
||||
voter.secondary_phone,
|
||||
voter.get_secondary_phone_type_display(),
|
||||
voter.email,
|
||||
voter.district,
|
||||
voter.precinct,
|
||||
'Yes' if voter.is_targeted else 'No',
|
||||
'Yes' if voter.voted else 'No',
|
||||
voter.get_candidate_support_display(),
|
||||
voter.get_yard_sign_display(),
|
||||
voter.get_window_sticker_display(),
|
||||
voter.get_call_queue_status_display(),
|
||||
voter.notes
|
||||
])
|
||||
|
||||
return response
|
||||
114
core/filter_helper.py
Normal file
114
core/filter_helper.py
Normal file
@ -0,0 +1,114 @@
|
||||
import re
|
||||
from django.db.models import Q, Sum, Value, DecimalField
|
||||
from django.db.models.functions import Coalesce
|
||||
from .models import Voter
|
||||
from .forms import AdvancedVoterSearchForm
|
||||
|
||||
def get_phone_search_filters(phone_query, secondary=True):
|
||||
"""
|
||||
Returns a Q object that searches for various formats of a phone number.
|
||||
"""
|
||||
if not phone_query:
|
||||
return Q()
|
||||
|
||||
digits = re.sub(r"\D", "", str(phone_query))
|
||||
variants = {str(phone_query), digits}
|
||||
|
||||
if len(digits) == 10:
|
||||
variants.add(f"({digits[:3]}) {digits[3:6]}-{digits[6:]}")
|
||||
elif len(digits) == 11 and digits.startswith("1"):
|
||||
variants.add(f"({digits[1:4]}) {digits[4:7]}-{digits[7:]}")
|
||||
elif len(digits) == 7:
|
||||
variants.add(f"{digits[:3]}-{digits[3:]}")
|
||||
|
||||
phone_filter = Q()
|
||||
for variant in variants:
|
||||
if variant:
|
||||
phone_filter |= Q(phone__icontains=variant)
|
||||
if secondary:
|
||||
phone_filter |= Q(secondary_phone__icontains=variant)
|
||||
|
||||
return phone_filter
|
||||
|
||||
def get_filtered_voter_queryset_from_filters(tenant, filters):
|
||||
"""
|
||||
Apply voter filters from a dictionary of filters.
|
||||
"""
|
||||
voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name")
|
||||
form = AdvancedVoterSearchForm(filters)
|
||||
|
||||
if form.is_valid():
|
||||
data = form.cleaned_data
|
||||
if data.get("first_name"):
|
||||
voters = voters.filter(first_name__icontains=data["first_name"])
|
||||
if data.get("last_name"):
|
||||
voters = voters.filter(last_name__icontains=data["last_name"])
|
||||
if data.get("address"):
|
||||
voters = voters.filter(Q(address__icontains=data["address"]) | Q(address_street__icontains=data["address"]))
|
||||
if data.get("voter_id"):
|
||||
voters = voters.filter(voter_id__iexact=data["voter_id"])
|
||||
if data.get("birth_month"):
|
||||
voters = voters.filter(birthdate__month=data["birth_month"])
|
||||
if data.get("city"):
|
||||
voters = voters.filter(city__icontains=data["city"])
|
||||
if data.get("zip_code"):
|
||||
voters = voters.filter(zip_code__icontains=data["zip_code"])
|
||||
if data.get("neighborhood"):
|
||||
voters = voters.filter(neighborhood__icontains=data["neighborhood"])
|
||||
if data.get("district"):
|
||||
voters = voters.filter(district=data["district"])
|
||||
if data.get("precinct"):
|
||||
voters = voters.filter(precinct=data["precinct"])
|
||||
if data.get("email"):
|
||||
voters = voters.filter(email__icontains=data["email"])
|
||||
if data.get("phone"):
|
||||
voters = voters.filter(get_phone_search_filters(data["phone"]))
|
||||
if data.get("phone_type"):
|
||||
voters = voters.filter(phone_type=data["phone_type"])
|
||||
if data.get("is_targeted"):
|
||||
voters = voters.filter(is_targeted=(data["is_targeted"] == "True"))
|
||||
if data.get("target_door_visit"):
|
||||
voters = voters.filter(target_door_visit=(data["target_door_visit"] == "True"))
|
||||
if data.get("door_visit"):
|
||||
voters = voters.filter(door_visit=(data["door_visit"] == "True"))
|
||||
if data.get("voted"):
|
||||
voters = voters.filter(voted=(data["voted"] == "True"))
|
||||
if data.get("candidate_support"):
|
||||
voters = voters.filter(candidate_support=data["candidate_support"])
|
||||
if data.get("yard_sign"):
|
||||
voters = voters.filter(yard_sign=data["yard_sign"])
|
||||
if data.get("window_sticker"):
|
||||
voters = voters.filter(window_sticker=data["window_sticker"])
|
||||
if data.get("call_queue_status"):
|
||||
voters = voters.filter(call_queue_status=data["call_queue_status"])
|
||||
|
||||
# Add donation amount filters
|
||||
min_total_donation = data.get("min_total_donation")
|
||||
max_total_donation = data.get("max_total_donation")
|
||||
|
||||
if min_total_donation is not None or max_total_donation is not None:
|
||||
voters = voters.annotate(total_donation_amount=Coalesce(Sum("donations__amount"), Value(0), output_field=DecimalField()))
|
||||
if min_total_donation is not None:
|
||||
voters = voters.filter(total_donation_amount__gte=min_total_donation)
|
||||
if max_total_donation is not None:
|
||||
voters = voters.filter(total_donation_amount__lte=max_total_donation)
|
||||
|
||||
return voters
|
||||
|
||||
def get_filtered_voter_queryset(request, tenant, data_source="GET"):
|
||||
"""
|
||||
Helper to apply voter filters from AdvancedVoterSearchForm.
|
||||
data_source: "GET" for search page, "POST" for bulk actions using filter_ prefix.
|
||||
"""
|
||||
if data_source == "POST":
|
||||
filters = {}
|
||||
for key, value in request.POST.items():
|
||||
if key.startswith("filter_") and value:
|
||||
field_name = key.replace("filter_", "")
|
||||
filters[field_name] = value
|
||||
else:
|
||||
filters = request.GET
|
||||
|
||||
voters = get_filtered_voter_queryset_from_filters(tenant, filters)
|
||||
form = AdvancedVoterSearchForm(filters)
|
||||
return voters, form
|
||||
518
core/forms.py
Normal file
518
core/forms.py
Normal file
@ -0,0 +1,518 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus, Volunteer, VolunteerEvent, VolunteerRole, ScheduledCall
|
||||
from core.permissions import get_user_role
|
||||
|
||||
class Select2MultipleWidget(forms.SelectMultiple):
|
||||
"""
|
||||
Custom widget to mark fields for Select2 initialization in the template.
|
||||
"""
|
||||
def __init__(self, attrs=None, choices=()):
|
||||
default_attrs = {"multiple": "multiple"}
|
||||
if attrs:
|
||||
default_attrs.update(attrs)
|
||||
super().__init__(attrs=default_attrs, choices=choices)
|
||||
|
||||
class VoterForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Voter
|
||||
fields = [
|
||||
'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state',
|
||||
'zip_code', 'county', 'neighborhood', 'latitude', 'longitude',
|
||||
'phone', 'phone_type', 'secondary_phone', 'secondary_phone_type', 'email', 'voter_id', 'district', 'precinct',
|
||||
'registration_date', 'is_targeted', 'is_inactive', 'target_door_visit', 'door_visit', 'voted', 'candidate_support', 'yard_sign', 'window_sticker', 'notes',
|
||||
'call_queue_status'
|
||||
]
|
||||
widgets = {
|
||||
'birthdate': forms.DateInput(attrs={'type': 'date'}),
|
||||
'registration_date': forms.DateInput(attrs={'type': 'date'}),
|
||||
'latitude': forms.TextInput(attrs={'class': 'form-control bg-light'}),
|
||||
'longitude': forms.TextInput(attrs={'class': 'form-control bg-light'}),
|
||||
'notes': forms.Textarea(attrs={'rows': 3}),
|
||||
'call_queue_status': forms.Select(attrs={'class': 'form-select'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, tenant=None, **kwargs):
|
||||
self.user = user
|
||||
self.tenant = tenant
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Always make call_queue_status readonly as it's automated
|
||||
|
||||
# Restrict fields for non-admin users
|
||||
is_admin = False
|
||||
if user:
|
||||
if user.is_superuser:
|
||||
is_admin = True
|
||||
elif tenant:
|
||||
role = get_user_role(user, tenant)
|
||||
if role in ["admin", "system_admin", "campaign_admin"]:
|
||||
is_admin = True
|
||||
|
||||
if not is_admin:
|
||||
restricted_fields = [
|
||||
"first_name", "last_name", "voter_id", "district", "precinct",
|
||||
"registration_date", "address_street", "city", "state", "zip_code"
|
||||
]
|
||||
for field_name in restricted_fields:
|
||||
if field_name in self.fields:
|
||||
self.fields[field_name].widget.attrs["readonly"] = True
|
||||
self.fields[field_name].widget.attrs["class"] = self.fields[field_name].widget.attrs.get("class", "") + " bg-light"
|
||||
|
||||
for name, field in self.fields.items():
|
||||
if name in ['latitude', 'longitude']:
|
||||
continue
|
||||
if isinstance(field.widget, forms.CheckboxInput):
|
||||
field.widget.attrs.update({'class': 'form-check-input'})
|
||||
else:
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['window_sticker'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['phone_type'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['secondary_phone_type'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['call_queue_status'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Backend protection for restricted fields
|
||||
is_admin = False
|
||||
user = getattr(self, "user", None)
|
||||
tenant = getattr(self, "tenant", None)
|
||||
|
||||
if self.user:
|
||||
if self.user.is_superuser:
|
||||
is_admin = True
|
||||
elif self.tenant:
|
||||
role = get_user_role(self.user, self.tenant)
|
||||
if role in ["admin", "system_admin", "campaign_admin"]:
|
||||
is_admin = True
|
||||
|
||||
if not is_admin and self.instance.pk:
|
||||
restricted_fields = [
|
||||
"first_name", "last_name", "voter_id", "district", "precinct",
|
||||
"registration_date", "address_street", "city", "state", "zip_code"
|
||||
]
|
||||
for field in restricted_fields:
|
||||
if field in self.changed_data:
|
||||
# Revert to original value
|
||||
cleaned_data[field] = getattr(self.instance, field)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class AdvancedVoterSearchForm(forms.Form):
|
||||
MONTH_CHOICES = [
|
||||
('', 'Any Month'),
|
||||
(1, 'January'), (2, 'February'), (3, 'March'), (4, 'April'),
|
||||
(5, 'May'), (6, 'June'), (7, 'July'), (8, 'August'),
|
||||
(9, 'September'), (10, 'October'), (11, 'November'), (12, 'December')
|
||||
]
|
||||
BOOLEAN_CHOICES = [('', 'Any'), ('True', 'Yes'), ('False', 'No')]
|
||||
|
||||
first_name = forms.CharField(required=False)
|
||||
last_name = forms.CharField(required=False)
|
||||
address = forms.CharField(required=False)
|
||||
voter_id = forms.CharField(required=False, label="Voter ID")
|
||||
birth_month = forms.ChoiceField(choices=MONTH_CHOICES, required=False, label="Birth Month")
|
||||
city = forms.CharField(required=False)
|
||||
zip_code = forms.CharField(required=False)
|
||||
neighborhood = forms.CharField(required=False)
|
||||
district = forms.CharField(required=False)
|
||||
precinct = forms.CharField(required=False)
|
||||
email = forms.EmailField(required=False)
|
||||
phone = forms.CharField(required=False, label="Phone Number")
|
||||
phone_type = forms.ChoiceField(
|
||||
choices=[('', 'Any')] + Voter.PHONE_TYPE_CHOICES,
|
||||
required=False
|
||||
)
|
||||
is_targeted = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Is Targeted")
|
||||
target_door_visit = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Target Door Visit")
|
||||
voted = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Voted")
|
||||
door_visit = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Door Visited")
|
||||
ever_had_yard_sign = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Ever Had Yard Sign")
|
||||
ever_had_large_sign = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Ever Had Large Sign")
|
||||
candidate_support = forms.ChoiceField(
|
||||
choices=[('', 'Any')] + Voter.CANDIDATE_SUPPORT_CHOICES,
|
||||
required=False
|
||||
)
|
||||
yard_sign = forms.ChoiceField(
|
||||
choices=[('', 'Any')] + Voter.YARD_SIGN_CHOICES,
|
||||
required=False
|
||||
)
|
||||
window_sticker = forms.ChoiceField(
|
||||
choices=[('', 'Any')] + Voter.WINDOW_STICKER_CHOICES,
|
||||
required=False
|
||||
)
|
||||
call_queue_status = forms.ChoiceField(
|
||||
choices=[('', 'Any')] + Voter.CALL_QUEUE_STATUS_CHOICES,
|
||||
required=False,
|
||||
label="Call Queue Status"
|
||||
)
|
||||
min_total_donation = forms.DecimalField(required=False, min_value=0, label="Min Total Donation")
|
||||
max_total_donation = forms.DecimalField(required=False, min_value=0, label="Max Total Donation")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field in self.fields.values():
|
||||
if isinstance(field.widget, forms.CheckboxInput):
|
||||
field.widget.attrs.update({'class': 'form-check-input'})
|
||||
else:
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
self.fields['birth_month'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['window_sticker'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['phone_type'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['call_queue_status'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['is_targeted'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['target_door_visit'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['door_visit'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['voted'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
class InteractionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Interaction
|
||||
fields = ['type', 'volunteer', 'date', 'description', 'notes']
|
||||
widgets = {
|
||||
'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'),
|
||||
'notes': forms.Textarea(attrs={'rows': 2}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, tenant=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
self.fields['type'].queryset = InteractionType.objects.filter(tenant=tenant, is_active=True)
|
||||
self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['type'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['volunteer'].widget.attrs.update({'class': 'form-select'})
|
||||
if self.instance and self.instance.date:
|
||||
self.initial['date'] = self.instance.date.strftime('%Y-%m-%dT%H:%M')
|
||||
|
||||
class DonationForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Donation
|
||||
fields = ['date', 'method', 'amount']
|
||||
widgets = {
|
||||
'date': forms.DateInput(attrs={'type': 'date'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, tenant=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
self.fields['method'].queryset = DonationMethod.objects.filter(tenant=tenant, is_active=True)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['method'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
class VoterLikelihoodForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = VoterLikelihood
|
||||
fields = ['election_type', 'likelihood']
|
||||
|
||||
def __init__(self, *args, tenant=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
self.fields['election_type'].queryset = ElectionType.objects.filter(tenant=tenant, is_active=True)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['election_type'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['likelihood'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
class EventParticipationForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = EventParticipation
|
||||
fields = ['event', 'participation_status']
|
||||
|
||||
def __init__(self, *args, tenant=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
self.fields['event'].queryset = Event.objects.filter(tenant=tenant)
|
||||
self.fields['participation_status'].queryset = ParticipationStatus.objects.filter(tenant=tenant, is_active=True)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['event'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['participation_status'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
class EventParticipantAddForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = EventParticipation
|
||||
fields = ['voter', 'participation_status']
|
||||
|
||||
def __init__(self, *args, tenant=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
voter_id = self.data.get('voter') or self.initial.get('voter')
|
||||
if voter_id:
|
||||
self.fields['voter'].queryset = Voter.objects.filter(tenant=tenant, id=voter_id)
|
||||
else:
|
||||
self.fields['voter'].queryset = Voter.objects.none()
|
||||
self.fields['participation_status'].queryset = ParticipationStatus.objects.filter(tenant=tenant, is_active=True)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['voter'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['participation_status'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
class EventForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'default_volunteer_role', 'description', 'location_name', 'address', 'city', 'state', 'zip_code', 'latitude', 'longitude']
|
||||
widgets = {
|
||||
'date': forms.DateInput(attrs={'type': 'date'}),
|
||||
'start_time': forms.TimeInput(attrs={'type': 'time'}),
|
||||
'end_time': forms.TimeInput(attrs={'type': 'time'}),
|
||||
'description': forms.Textarea(attrs={'rows': 2}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, tenant=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
self.fields['event_type'].queryset = EventType.objects.filter(tenant=tenant, is_active=True)
|
||||
self.fields['default_volunteer_role'].queryset = VolunteerRole.objects.filter(tenant=tenant, is_active=True)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['event_type'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['default_volunteer_role'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
class VoterImportForm(forms.Form):
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||
file = forms.FileField(label="Select CSV file")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class EventImportForm(forms.Form):
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||
file = forms.FileField(label="Select CSV file")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class EventParticipationImportForm(forms.Form):
|
||||
file = forms.FileField(label="Select CSV file")
|
||||
|
||||
def __init__(self, *args, event=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# No tenant field needed as event_id is passed directly
|
||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class ParticipantMappingForm(forms.Form):
|
||||
def __init__(self, *args, headers, tenant, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['email_column'] = forms.ChoiceField(
|
||||
choices=[(header, header) for header in headers],
|
||||
label="Column for Email Address",
|
||||
required=True,
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
name_choices = [('', '-- Select Name Column (Optional) --')] + [(header, header) for header in headers]
|
||||
self.fields['name_column'] = forms.ChoiceField(
|
||||
choices=name_choices,
|
||||
label="Column for Participant Name",
|
||||
required=False,
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
phone_choices = [('', '-- Select Phone Column (Optional) --')] + [(header, header) for header in headers]
|
||||
self.fields['phone_column'] = forms.ChoiceField(
|
||||
choices=phone_choices,
|
||||
label="Column for Phone Number",
|
||||
required=False,
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
participation_status_choices = [('', '-- Select Status Column (Optional) --')] + [(header, header) for header in headers]
|
||||
self.fields['participation_status_column'] = forms.ChoiceField(
|
||||
choices=participation_status_choices,
|
||||
label="Column for Participation Status",
|
||||
required=False,
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
# Optional: Add a default participation status if no column is mapped
|
||||
self.fields['default_participation_status'] = forms.ModelChoiceField(
|
||||
queryset=ParticipationStatus.objects.filter(tenant=tenant, is_active=True),
|
||||
label="Default Participation Status (if no column mapped or column is empty)",
|
||||
required=False,
|
||||
empty_label="-- Select a Default Status --",
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
class DonationImportForm(forms.Form):
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||
file = forms.FileField(label="Select CSV file")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class InteractionImportForm(forms.Form):
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||
file = forms.FileField(label="Select CSV file")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class VoterLikelihoodImportForm(forms.Form):
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||
file = forms.FileField(label="Select CSV file")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class VolunteerImportForm(forms.Form):
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||
file = forms.FileField(label="Select CSV file")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class VolunteerForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Volunteer
|
||||
fields = ['first_name', 'last_name', 'email', 'phone', 'is_default_caller', 'notes', 'interests']
|
||||
widgets = {
|
||||
'notes': forms.Textarea(attrs={'rows': 3}),
|
||||
'interests': Select2MultipleWidget(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, tenant=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
from .models import Interest
|
||||
self.fields['interests'].queryset = Interest.objects.filter(tenant=tenant)
|
||||
for field in self.fields.values():
|
||||
if not isinstance(field.widget, forms.CheckboxInput):
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
else:
|
||||
field.widget.attrs.update({'class': 'form-check-input'})
|
||||
|
||||
class VolunteerEventForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = VolunteerEvent
|
||||
fields = ['event', 'role_type']
|
||||
|
||||
def __init__(self, *args, tenant=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
self.fields['event'].queryset = Event.objects.filter(tenant=tenant)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['event'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
class VolunteerEventAddForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = VolunteerEvent
|
||||
fields = ['volunteer', 'role_type']
|
||||
|
||||
def __init__(self, *args, tenant=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
volunteer_id = self.data.get('volunteer') or self.initial.get('volunteer')
|
||||
if volunteer_id:
|
||||
self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant, id=volunteer_id)
|
||||
else:
|
||||
self.fields['volunteer'].queryset = Volunteer.objects.none()
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['volunteer'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
class VotingRecordImportForm(forms.Form):
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||
file = forms.FileField(label="Select CSV file")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class DoorVisitLogForm(forms.Form):
|
||||
OUTCOME_CHOICES = [
|
||||
("No Answer Left Literature", "No Answer Left Literature"),
|
||||
("Spoke to voter", "Spoke to voter"),
|
||||
("No Access to House", "No Access to House"),
|
||||
]
|
||||
outcome = forms.ChoiceField(
|
||||
choices=OUTCOME_CHOICES,
|
||||
widget=forms.RadioSelect(attrs={"class": "btn-check"}),
|
||||
label="Outcome"
|
||||
)
|
||||
notes = forms.CharField(
|
||||
widget=forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
required=False,
|
||||
label="Notes"
|
||||
)
|
||||
yard_sign_status = forms.ChoiceField(
|
||||
choices=[('no_change', 'No Change'), ('none', 'No Sign'), ('wants', 'Wants Yard Sign'), ('wants_large', 'Wants Large Sign')],
|
||||
initial='no_change',
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
label="Yard Sign Status"
|
||||
)
|
||||
candidate_support = forms.ChoiceField(
|
||||
choices=Voter.CANDIDATE_SUPPORT_CHOICES,
|
||||
initial="unknown",
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
label="Candidate Support"
|
||||
)
|
||||
follow_up = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
label="Follow Up"
|
||||
)
|
||||
follow_up_voter = forms.ChoiceField(choices=[('', '-- Select Voter --')], required=False, widget=forms.Select(attrs={"class": "form-select"}), label="Voter to Follow Up")
|
||||
|
||||
def __init__(self, *args, voter_choices=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if voter_choices:
|
||||
self.fields["follow_up_voter"].choices = [('', '-- Select Voter --')] + list(voter_choices)
|
||||
call_notes = forms.CharField(
|
||||
widget=forms.Textarea(attrs={"class": "form-control", "rows": 2}),
|
||||
required=False,
|
||||
label="Call Notes"
|
||||
)
|
||||
|
||||
class ScheduledCallForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ScheduledCall
|
||||
fields = ['volunteer', 'comments']
|
||||
widgets = {
|
||||
'comments': forms.Textarea(attrs={'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, tenant=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant)
|
||||
default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
|
||||
if default_caller:
|
||||
self.initial['volunteer'] = default_caller
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['volunteer'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
class UserUpdateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['first_name', 'last_name', 'email']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
24
core/grep_dump.txt
Normal file
24
core/grep_dump.txt
Normal file
File diff suppressed because one or more lines are too long
68
core/middleware.py
Normal file
68
core/middleware.py
Normal file
@ -0,0 +1,68 @@
|
||||
import zoneinfo
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from core.models import CampaignSettings, Tenant
|
||||
|
||||
class LoginRequiredMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
path = request.path_info
|
||||
|
||||
# Allow access to login, logout, admin, and any other exempted paths
|
||||
try:
|
||||
login_url = reverse('login')
|
||||
logout_url = reverse('logout')
|
||||
except:
|
||||
login_url = '/accounts/login/'
|
||||
logout_url = '/accounts/logout/'
|
||||
|
||||
exempt_urls = [
|
||||
login_url,
|
||||
logout_url,
|
||||
'/admin/',
|
||||
]
|
||||
|
||||
# Check if path starts with any of the exempt URLs
|
||||
is_exempt = any(path.startswith(url) for url in exempt_urls)
|
||||
|
||||
if not is_exempt:
|
||||
return redirect(f"{login_url}?next={path}")
|
||||
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
||||
class TimezoneMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
tzname = None
|
||||
|
||||
# 1. Try to get tenant from session
|
||||
tenant_id = request.session.get("tenant_id")
|
||||
if tenant_id:
|
||||
try:
|
||||
campaign_settings = CampaignSettings.objects.get(tenant_id=tenant_id)
|
||||
tzname = campaign_settings.timezone
|
||||
except CampaignSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
# 2. If not found and user is authenticated, maybe they are in admin?
|
||||
# In admin, we might not have tenant_id in session if they went directly there.
|
||||
# But this is a multi-tenant app, usually they select a campaign first.
|
||||
# If they are superuser in admin, we might want to default to something or let them see UTC.
|
||||
|
||||
if tzname:
|
||||
try:
|
||||
timezone.activate(zoneinfo.ZoneInfo(tzname))
|
||||
except:
|
||||
timezone.deactivate()
|
||||
else:
|
||||
timezone.deactivate()
|
||||
|
||||
return self.get_response(request)
|
||||
78
core/migrations/0001_initial.py
Normal file
78
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,78 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 05:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tenant',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('slug', models.SlugField(blank=True, unique=True)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Voter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('voter_id', models.CharField(blank=True, max_length=50)),
|
||||
('first_name', models.CharField(max_length=100)),
|
||||
('last_name', models.CharField(max_length=100)),
|
||||
('address', models.TextField(blank=True)),
|
||||
('phone', models.CharField(blank=True, max_length=20)),
|
||||
('email', models.EmailField(blank=True, max_length=254)),
|
||||
('geocode', models.CharField(blank=True, max_length=100)),
|
||||
('district', models.CharField(blank=True, max_length=100)),
|
||||
('precinct', models.CharField(blank=True, max_length=100)),
|
||||
('registration_date', models.DateField(blank=True, null=True)),
|
||||
('candidate_support', models.CharField(choices=[('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting')], default='unknown', max_length=20)),
|
||||
('yard_sign', models.CharField(choices=[('none', 'None'), ('wants', 'Wants a yard sign'), ('has', 'Has a yard sign')], default='none', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voters', to='core.tenant')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InteractionType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interaction_types', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('tenant', 'name')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ElectionType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='election_types', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('tenant', 'name')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DonationMethod',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='donation_methods', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('tenant', 'name')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,75 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 05:18
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Donation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField()),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('method', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.donationmethod')),
|
||||
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='donations', to='core.voter')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Event',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField()),
|
||||
('event_type', models.CharField(max_length=100)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.tenant')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventParticipation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participations', to='core.event')),
|
||||
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_participations', to='core.voter')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Interaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField()),
|
||||
('description', models.CharField(max_length=255)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.interactiontype')),
|
||||
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interactions', to='core.voter')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VotingRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('election_date', models.DateField()),
|
||||
('election_description', models.CharField(max_length=255)),
|
||||
('primary_party', models.CharField(blank=True, max_length=100)),
|
||||
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voting_records', to='core.voter')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VoterLikelihood',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('likelihood', models.CharField(choices=[('not_likely', 'Not Likely'), ('somewhat_likely', 'Somewhat Likely'), ('very_likely', 'Very Likely')], max_length=20)),
|
||||
('election_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.electiontype')),
|
||||
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likelihoods', to='core.voter')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('voter', 'election_type')},
|
||||
},
|
||||
),
|
||||
]
|
||||
28
core/migrations/0003_tenantuserrole.py
Normal file
28
core/migrations/0003_tenantuserrole.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 05:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_donation_event_eventparticipation_interaction_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TenantUserRole',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('system_admin', 'System Administrator'), ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff')], max_length=20)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to='core.tenant')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_roles', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'tenant', 'role')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 14:50
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_tenantuserrole'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='voter',
|
||||
name='geocode',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='donationmethod',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='electiontype',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interactiontype',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_types', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('tenant', 'name')},
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='event_type',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='core.eventtype'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 16:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_remove_voter_geocode_donationmethod_is_active_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='eventparticipation',
|
||||
name='participation_type',
|
||||
field=models.CharField(choices=[('invited', 'Invited'), ('invited_not_attended', "Invited but didn't attend"), ('attended', 'Attended')], default='invited', max_length=50),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0006_voter_is_targeted.py
Normal file
18
core/migrations/0006_voter_is_targeted.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 16:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_eventparticipation_participation_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='is_targeted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 16:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_voter_is_targeted'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='address_street',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='city',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='county',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='latitude',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='longitude',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='state',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='zip_code',
|
||||
field=models.CharField(blank=True, max_length=20),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 21:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_voter_address_street_voter_city_voter_county_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='latitude',
|
||||
field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='longitude',
|
||||
field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0009_voter_window_sticker.py
Normal file
18
core/migrations/0009_voter_window_sticker.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 23:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_alter_voter_latitude_alter_voter_longitude'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='window_sticker',
|
||||
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], default='none', max_length=20),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 23:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_voter_window_sticker'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='window_sticker',
|
||||
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], default='none', max_length=20, verbose_name='Window Sticker Status'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CampaignSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('donation_goal', models.DecimalField(decimal_places=2, default=170000.0, max_digits=12)),
|
||||
('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Campaign Settings',
|
||||
'verbose_name_plural': 'Campaign Settings',
|
||||
},
|
||||
),
|
||||
]
|
||||
23
core/migrations/0011_voter_birthdate_voter_nickname.py
Normal file
23
core/migrations/0011_voter_birthdate_voter_nickname.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 00:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_alter_voter_window_sticker_campaignsettings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='birthdate',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='nickname',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
]
|
||||
23
core/migrations/0012_voter_prior_state_alter_voter_state.py
Normal file
23
core/migrations/0012_voter_prior_state_alter_voter_state.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 01:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_voter_birthdate_voter_nickname'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='prior_state',
|
||||
field=models.CharField(blank=True, max_length=2),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='state',
|
||||
field=models.CharField(blank=True, max_length=2),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,71 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 16:33
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_voter_prior_state_alter_voter_state'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='tenant',
|
||||
name='description',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tenant',
|
||||
name='slug',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tenant',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tenantuserrole',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('admin', 'Admin'), ('campaign_manager', 'Campaign Manager'), ('campaign_staff', 'Campaign Staff')], max_length=20),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Interest',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interests', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('tenant', 'name')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Volunteer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('phone', models.CharField(blank=True, max_length=20)),
|
||||
('interests', models.ManyToManyField(blank=True, related_name='volunteers', to='core.interest')),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteers', to='core.tenant')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='volunteer_profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interaction',
|
||||
name='volunteer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interactions', to='core.volunteer'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VolunteerEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(max_length=100)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteers', to='core.event')),
|
||||
('volunteer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_assignments', to='core.volunteer')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 16:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_remove_tenant_description_remove_tenant_slug_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='volunteer',
|
||||
name='assigned_events',
|
||||
field=models.ManyToManyField(related_name='assigned_volunteers', through='core.VolunteerEvent', to='core.event'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='volunteerevent',
|
||||
name='event',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteer_assignments', to='core.event'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 18:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_volunteer_assigned_events_alter_volunteerevent_event'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='eventparticipation',
|
||||
old_name='participation_type',
|
||||
new_name='participation_status',
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 18:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_remove_eventparticipation_participation_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='eventparticipation',
|
||||
name='participation_status',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ParticipationStatus',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participation_statuses', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Participation Statuses',
|
||||
'unique_together': {('tenant', 'name')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventparticipation',
|
||||
name='participation_status_link',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='core.participationstatus'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 18:52
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0016_alter_eventparticipation_participation_status_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='eventparticipation',
|
||||
name='participation_status',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='eventparticipation',
|
||||
old_name='participation_status_link',
|
||||
new_name='participation_status',
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 19:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0017_remove_eventparticipation_participation_status_link_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='end_time',
|
||||
field=models.TimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='start_time',
|
||||
field=models.TimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 05:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0018_event_end_time_event_name_event_start_time'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='volunteer',
|
||||
name='first_name',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='volunteer',
|
||||
name='last_name',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
]
|
||||
17
core/migrations/0020_remove_volunteer_name.py
Normal file
17
core/migrations/0020_remove_volunteer_name.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 13:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0019_volunteer_first_name_volunteer_last_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='volunteer',
|
||||
name='name',
|
||||
),
|
||||
]
|
||||
18
core/migrations/0021_voter_phone_type.py
Normal file
18
core/migrations/0021_voter_phone_type.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 16:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0020_remove_volunteer_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='phone_type',
|
||||
field=models.CharField(choices=[('home', 'Home Phone'), ('cell', 'Cell Phone'), ('work', 'Work Phone')], default='cell', max_length=10),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0022_voter_notes.py
Normal file
18
core/migrations/0022_voter_notes.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 17:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0021_voter_phone_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,83 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-28 04:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0022_voter_notes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='address_street',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='birthdate',
|
||||
field=models.DateField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='candidate_support',
|
||||
field=models.CharField(choices=[('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting')], db_index=True, default='unknown', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='city',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='district',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='first_name',
|
||||
field=models.CharField(db_index=True, max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='is_targeted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='last_name',
|
||||
field=models.CharField(db_index=True, max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='precinct',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='state',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=2),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='voter_id',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='window_sticker',
|
||||
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], db_index=True, default='none', max_length=20, verbose_name='Window Sticker Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='yard_sign',
|
||||
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants a yard sign'), ('has', 'Has a yard sign')], db_index=True, default='none', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='zip_code',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=20),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-28 21:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0023_alter_voter_address_street_alter_voter_birthdate_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, max_length=255),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='event',
|
||||
unique_together={('tenant', 'name')},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-29 01:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0024_alter_event_name_alter_event_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='twilio_account_sid',
|
||||
field=models.CharField(blank=True, default='ACcd11acb5095cec6477245d385a2bf127', max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='twilio_auth_token',
|
||||
field=models.CharField(blank=True, default='89ec830d0fa02ab0afa6c76084865713', max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='twilio_from_number',
|
||||
field=models.CharField(blank=True, default='+18556945903', max_length=20),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0026_alter_interaction_date.py
Normal file
18
core/migrations/0026_alter_interaction_date.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-29 03:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0025_campaignsettings_twilio_account_sid_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interaction',
|
||||
name='date',
|
||||
field=models.DateTimeField(),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-29 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0026_alter_interaction_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='secondary_phone',
|
||||
field=models.CharField(blank=True, max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='secondary_phone_type',
|
||||
field=models.CharField(choices=[('home', 'Home Phone'), ('cell', 'Cell Phone'), ('work', 'Work Phone')], default='cell', max_length=10),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0028_volunteer_notes.py
Normal file
18
core/migrations/0028_volunteer_notes.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-29 21:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0027_voter_secondary_phone_voter_secondary_phone_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='volunteer',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-29 22:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0028_volunteer_notes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='address',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='city',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='latitude',
|
||||
field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='longitude',
|
||||
field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='state',
|
||||
field=models.CharField(blank=True, max_length=2),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='zip_code',
|
||||
field=models.CharField(blank=True, max_length=20),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0030_event_location_name.py
Normal file
18
core/migrations/0030_event_location_name.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-29 22:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0029_event_address_event_city_event_latitude_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='location_name',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-31 13:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0030_event_location_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VolunteerRole',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteer_roles', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('tenant', 'name')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='default_volunteer_role',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_events', to='core.volunteerrole'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventtype',
|
||||
name='available_roles',
|
||||
field=models.ManyToManyField(blank=True, related_name='event_types', to='core.volunteerrole'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='volunteerevent',
|
||||
name='role_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='volunteer_assignments', to='core.volunteerrole'),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0032_alter_volunteerevent_role.py
Normal file
18
core/migrations/0032_alter_volunteerevent_role.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 00:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0031_volunteerrole_event_default_volunteer_role_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='volunteerevent',
|
||||
name='role',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
]
|
||||
17
core/migrations/0033_remove_volunteerevent_role.py
Normal file
17
core/migrations/0033_remove_volunteerevent_role.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 00:54
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0032_alter_volunteerevent_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='volunteerevent',
|
||||
name='role',
|
||||
),
|
||||
]
|
||||
19
core/migrations/0034_eventtype_default_volunteer_role.py
Normal file
19
core/migrations/0034_eventtype_default_volunteer_role.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 01:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0033_remove_volunteerevent_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='eventtype',
|
||||
name='default_volunteer_role',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_event_types', to='core.volunteerrole'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 01:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0034_eventtype_default_volunteer_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='interaction',
|
||||
name='door_visit',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interaction',
|
||||
name='neighborhood',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='volunteer',
|
||||
name='door_visit',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='volunteer',
|
||||
name='neighborhood',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='door_visit',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='neighborhood',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=100),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 01:55
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0035_interaction_door_visit_interaction_neighborhood_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='interaction',
|
||||
name='door_visit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='interaction',
|
||||
name='neighborhood',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='volunteer',
|
||||
name='door_visit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='volunteer',
|
||||
name='neighborhood',
|
||||
),
|
||||
]
|
||||
18
core/migrations/0037_campaignsettings_timezone.py
Normal file
18
core/migrations/0037_campaignsettings_timezone.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 03:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0036_remove_interaction_door_visit_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='timezone',
|
||||
field=models.CharField(default='America/Chicago', max_length=50),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0038_alter_campaignsettings_timezone.py
Normal file
18
core/migrations/0038_alter_campaignsettings_timezone.py
Normal file
File diff suppressed because one or more lines are too long
18
core/migrations/0039_alter_tenantuserrole_role.py
Normal file
18
core/migrations/0039_alter_tenantuserrole_role.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 15:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0038_alter_campaignsettings_timezone'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='tenantuserrole',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('system_admin', 'System Administrator'), ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff')], max_length=20),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-03 01:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0039_alter_tenantuserrole_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='volunteer',
|
||||
name='is_default_caller',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScheduledCall',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('comments', models.TextField(blank=True)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_calls', to='core.tenant')),
|
||||
('volunteer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_calls', to='core.volunteer')),
|
||||
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_calls', to='core.voter')),
|
||||
],
|
||||
),
|
||||
]
|
||||
17
core/migrations/0041_alter_volunteer_options.py
Normal file
17
core/migrations/0041_alter_volunteer_options.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-03 03:43
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0040_volunteer_is_default_caller_scheduledcall'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='volunteer',
|
||||
options={'ordering': ('last_name', 'first_name')},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-11 15:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0041_alter_volunteer_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='email_from_address',
|
||||
field=models.EmailField(blank=True, max_length=254),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='smtp_host',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='smtp_password',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='smtp_port',
|
||||
field=models.IntegerField(default=587),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='smtp_use_ssl',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='smtp_use_tls',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='smtp_username',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0043_campaignsettings_email_from_name.py
Normal file
18
core/migrations/0043_campaignsettings_email_from_name.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-13 02:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0042_campaignsettings_email_from_address_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='email_from_name',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0044_voter_target_door_visit.py
Normal file
18
core/migrations/0044_voter_target_door_visit.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-01 14:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0043_campaignsettings_email_from_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='target_door_visit',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0045_voter_is_inactive.py
Normal file
18
core/migrations/0045_voter_is_inactive.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-03 15:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0044_voter_target_door_visit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='is_inactive',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-05 14:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0045_voter_is_inactive'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='voter',
|
||||
index=models.Index(fields=['tenant', 'address_street', 'city', 'state', 'zip_code'], name='core_voter_tenant__6a281d_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='voter',
|
||||
index=models.Index(fields=['tenant', 'is_inactive', 'door_visit', 'target_door_visit'], name='core_voter_tenant__52db3f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='voter',
|
||||
index=models.Index(fields=['tenant', 'last_name', 'first_name'], name='core_voter_tenant__ad8046_idx'),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0047_alter_voter_yard_sign.py
Normal file
18
core/migrations/0047_alter_voter_yard_sign.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-08 00:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0046_voter_core_voter_tenant__6a281d_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='voter',
|
||||
name='yard_sign',
|
||||
field=models.CharField(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')], db_index=True, default='none', max_length=20),
|
||||
),
|
||||
]
|
||||
37
core/migrations/0048_voter_call_queue_status.py
Normal file
37
core/migrations/0048_voter_call_queue_status.py
Normal file
@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-15 14:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
def initialize_call_queue_status(apps, schema_editor):
|
||||
Voter = apps.get_model('core', 'Voter')
|
||||
ScheduledCall = apps.get_model('core', 'ScheduledCall')
|
||||
|
||||
# 1. Not targeted -> no_call_required
|
||||
Voter.objects.filter(is_targeted=False).update(call_queue_status='no_call_required')
|
||||
|
||||
# 2. In call queue -> in_call_queue
|
||||
pending_calls_voter_ids = ScheduledCall.objects.filter(status='pending').values_list('voter_id', flat=True).distinct()
|
||||
Voter.objects.filter(is_targeted=True, id__in=pending_calls_voter_ids).update(call_queue_status='in_call_queue')
|
||||
|
||||
# 3. Targeted, not in queue, but was called -> called
|
||||
completed_calls_voter_ids = ScheduledCall.objects.filter(status='completed').values_list('voter_id', flat=True).distinct()
|
||||
Voter.objects.filter(is_targeted=True, id__in=completed_calls_voter_ids).exclude(call_queue_status='in_call_queue').update(call_queue_status='called')
|
||||
|
||||
# 4. Targeted, not in queue, never called -> to_be_called
|
||||
# This covers voters who were targeted but never added to a call queue
|
||||
Voter.objects.filter(is_targeted=True, call_queue_status='no_call_required').update(call_queue_status='to_be_called')
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0047_alter_voter_yard_sign'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='call_queue_status',
|
||||
field=models.CharField(choices=[('no_call_required', 'No Call Required'), ('to_be_called', 'To Be Called'), ('in_call_queue', 'In Call Queue'), ('called', 'Called')], db_index=True, default='no_call_required', max_length=20),
|
||||
),
|
||||
migrations.RunPython(initialize_call_queue_status, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
18
core/migrations/0049_campaignsettings_call_script.py
Normal file
18
core/migrations/0049_campaignsettings_call_script.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-17 04:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0048_voter_call_queue_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='campaignsettings',
|
||||
name='call_script',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0050_voter_voted.py
Normal file
18
core/migrations/0050_voter_voted.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-15 03:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0049_campaignsettings_call_script'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='voted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
]
|
||||
29
core/migrations/0051_bulktask.py
Normal file
29
core/migrations/0051_bulktask.py
Normal file
@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-15 19:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0050_voter_voted'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BulkTask',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('task_type', models.CharField(choices=[('sms', 'SMS'), ('email', 'Email')], max_length=10)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'In Progress'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)),
|
||||
('total_count', models.IntegerField(default=0)),
|
||||
('success_count', models.IntegerField(default=0)),
|
||||
('fail_count', models.IntegerField(default=0)),
|
||||
('error_message', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_tasks', to='core.tenant')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-15 19:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0051_bulktask'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bulktask',
|
||||
name='message_body',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='bulktask',
|
||||
name='subject',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-18 02:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0052_bulktask_message_body_bulktask_subject'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='ever_had_large_sign',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voter',
|
||||
name='ever_had_yard_sign',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
]
|
||||
0
core/migrations/__init__.py
Normal file
0
core/migrations/__init__.py
Normal file
883
core/models.py
Normal file
883
core/models.py
Normal file
@ -0,0 +1,883 @@
|
||||
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'])
|
||||
127
core/permissions.py
Normal file
127
core/permissions.py
Normal file
@ -0,0 +1,127 @@
|
||||
from functools import wraps
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from .models import TenantUserRole
|
||||
|
||||
# Allowed roles for staff/admin actions
|
||||
STAFF_ROLES = [
|
||||
'admin', 'campaign_manager', 'campaign_staff',
|
||||
'system_admin', 'campaign_admin'
|
||||
]
|
||||
|
||||
def get_user_role(user, tenant):
|
||||
if user.is_superuser:
|
||||
return 'admin'
|
||||
role_obj = TenantUserRole.objects.filter(user=user, tenant=tenant).first()
|
||||
if role_obj:
|
||||
return role_obj.role
|
||||
return None
|
||||
|
||||
def has_role(user, tenant, roles):
|
||||
if user.is_superuser:
|
||||
return True
|
||||
if not tenant:
|
||||
return False
|
||||
user_role = get_user_role(user, tenant)
|
||||
return user_role in roles
|
||||
|
||||
def is_block_walker(user):
|
||||
return user.groups.filter(name='Block Walker').exists()
|
||||
|
||||
def is_call_queue(user):
|
||||
return user.groups.filter(name='Call Queue').exists()
|
||||
|
||||
def is_editor(user):
|
||||
return user.groups.filter(name='Editor').exists()
|
||||
|
||||
def can_access_call_queue(user):
|
||||
if user.is_superuser:
|
||||
return True
|
||||
return is_call_queue(user) or is_editor(user)
|
||||
|
||||
def can_view_voters(user, tenant):
|
||||
if user.has_perm("core.view_voter"):
|
||||
return True
|
||||
if user.is_superuser:
|
||||
return True
|
||||
# If they can edit, they can view
|
||||
if can_edit_voter(user, tenant):
|
||||
return True
|
||||
# All authenticated users with a tenant role can usually view voters in our app
|
||||
# but we should restrict it if they have NO role and NO permission.
|
||||
role = get_user_role(user, tenant)
|
||||
if role: # Any role (even if not in STAFF_ROLES) allows viewing voters?
|
||||
# Block Walkers don't have a TenantUserRole usually, they have a Group.
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_view_donations(user, tenant):
|
||||
if user.has_perm("core.view_donation"):
|
||||
return True
|
||||
if user.is_superuser:
|
||||
return True
|
||||
role = get_user_role(user, tenant)
|
||||
if role in STAFF_ROLES:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_edit_voter(user, tenant):
|
||||
if user.has_perm("core.change_voter"):
|
||||
return True
|
||||
if user.is_superuser:
|
||||
return True
|
||||
role = get_user_role(user, tenant)
|
||||
if role in STAFF_ROLES:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_view_volunteers(user, tenant):
|
||||
if user.has_perm("core.view_volunteer"):
|
||||
return True
|
||||
if user.is_superuser:
|
||||
return True
|
||||
role = get_user_role(user, tenant)
|
||||
if role in STAFF_ROLES:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_edit_volunteer(user, tenant):
|
||||
if user.has_perm("core.change_volunteer"):
|
||||
return True
|
||||
if user.is_superuser:
|
||||
return True
|
||||
role = get_user_role(user, tenant)
|
||||
if role in STAFF_ROLES:
|
||||
return True
|
||||
return False
|
||||
|
||||
def role_required(roles, permission=None):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
from .models import Tenant
|
||||
tenant_id = request.session.get('tenant_id')
|
||||
if not tenant_id:
|
||||
if request.user.is_superuser:
|
||||
return view_func(request, *args, **kwargs)
|
||||
messages.warning(request, "Please select a campaign first.")
|
||||
return redirect('index')
|
||||
|
||||
tenant = Tenant.objects.filter(id=tenant_id).first()
|
||||
if not tenant:
|
||||
messages.warning(request, "Campaign not found.")
|
||||
return redirect('index')
|
||||
|
||||
# Check roles first
|
||||
if has_role(request.user, tenant, roles):
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
# Check for specific permission if provided
|
||||
if permission and request.user.has_perm(permission):
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
messages.error(request, "You do not have permission to perform this action.")
|
||||
return redirect('index')
|
||||
return _wrapped_view
|
||||
return decorator
|
||||
217
core/task_runners.py
Normal file
217
core/task_runners.py
Normal file
@ -0,0 +1,217 @@
|
||||
import threading
|
||||
import base64
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import re
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from django.utils import timezone
|
||||
from django.db import connection, transaction
|
||||
from .models import BulkTask, Voter, Volunteer, Interaction, InteractionType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def send_single_sms(url, auth_header, from_number, to_number, message_body):
|
||||
"""
|
||||
Sends a single SMS using Twilio API. Returns (success, error_msg).
|
||||
"""
|
||||
data_dict = {
|
||||
'To': to_number,
|
||||
'From': from_number,
|
||||
'Body': message_body
|
||||
}
|
||||
data = urllib.parse.urlencode(data_dict).encode()
|
||||
|
||||
req = urllib.request.Request(url, data=data, method='POST')
|
||||
req.add_header("Authorization", f"Basic {auth_header}")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
if response.status in [200, 201]:
|
||||
return True, None
|
||||
else:
|
||||
return False, f"HTTP {response.status}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def run_bulk_sms_task(task_id, object_ids, select_all_results, search_filters=None, object_type='voter'):
|
||||
"""
|
||||
Background task to send bulk SMS to voters or volunteers.
|
||||
"""
|
||||
# Ensure a fresh connection for the thread
|
||||
connection.close()
|
||||
|
||||
try:
|
||||
task = BulkTask.objects.get(id=task_id)
|
||||
task.status = 'processing'
|
||||
task.save()
|
||||
|
||||
tenant = task.tenant
|
||||
settings_obj = getattr(tenant, 'settings', None)
|
||||
|
||||
if not settings_obj:
|
||||
task.status = 'failed'
|
||||
task.error_message = "Campaign settings not found."
|
||||
task.save()
|
||||
return
|
||||
|
||||
account_sid = settings_obj.twilio_account_sid
|
||||
auth_token = settings_obj.twilio_auth_token
|
||||
from_number = settings_obj.twilio_from_number
|
||||
|
||||
if not account_sid or not auth_token or not from_number:
|
||||
task.status = 'failed'
|
||||
task.error_message = "Twilio configuration is incomplete."
|
||||
task.save()
|
||||
return
|
||||
|
||||
message_body = task.message_body
|
||||
|
||||
# Determine the queryset of objects (Voters or Volunteers)
|
||||
if object_type == 'voter':
|
||||
if select_all_results and search_filters:
|
||||
from .filter_helper import get_filtered_voter_queryset_from_filters
|
||||
queryset = get_filtered_voter_queryset_from_filters(tenant, search_filters)
|
||||
queryset = queryset.filter(phone_type='cell').exclude(phone='')
|
||||
else:
|
||||
queryset = Voter.objects.filter(tenant=tenant, id__in=object_ids, phone_type='cell').exclude(phone='')
|
||||
else: # volunteer
|
||||
queryset = Volunteer.objects.filter(tenant=tenant, id__in=object_ids).exclude(phone='')
|
||||
|
||||
task.total_count = queryset.count()
|
||||
task.save()
|
||||
|
||||
if task.total_count == 0:
|
||||
task.status = 'completed'
|
||||
task.error_message = f"No {object_type}s with a valid phone number found."
|
||||
task.save()
|
||||
return
|
||||
|
||||
auth_str = f"{account_sid}:{auth_token}"
|
||||
auth_header = base64.b64encode(auth_str.encode()).decode()
|
||||
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
|
||||
|
||||
interaction_type = None
|
||||
if object_type == 'voter':
|
||||
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="SMS Text")
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
interactions_to_create = []
|
||||
|
||||
# Batch size for updating task status and creating interactions
|
||||
batch_size = 50
|
||||
max_workers = 10 # Parallelize Twilio requests
|
||||
|
||||
# Using iterator() to avoid loading all objects into memory
|
||||
# Note: We need to handle IDs and phones to avoid "too many open connections" or stale data
|
||||
# Actually, iterator() is fine.
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_obj = {}
|
||||
|
||||
for obj in queryset.iterator():
|
||||
# Format phone to E.164 (assume US +1)
|
||||
digits = re.sub(r'\D', '', str(obj.phone))
|
||||
if len(digits) == 10:
|
||||
to_number = f"+1{digits}"
|
||||
elif len(digits) == 11 and digits.startswith('1'):
|
||||
to_number = f"+{digits}"
|
||||
else:
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
future = executor.submit(send_single_sms, url, auth_header, from_number, to_number, message_body)
|
||||
future_to_obj[future] = obj
|
||||
|
||||
# If we have many futures, process them to avoid memory issues and see progress
|
||||
if len(future_to_obj) >= batch_size:
|
||||
# Collect completed results
|
||||
for future in as_completed(future_to_obj):
|
||||
obj = future_to_obj.pop(future)
|
||||
success, error = future.result()
|
||||
if success:
|
||||
success_count += 1
|
||||
if object_type == 'voter':
|
||||
interactions_to_create.append(Interaction(
|
||||
voter=obj,
|
||||
type=interaction_type,
|
||||
date=timezone.now(),
|
||||
description='Mass SMS Text',
|
||||
notes=message_body
|
||||
))
|
||||
else:
|
||||
fail_count += 1
|
||||
logger.error(f"Error sending SMS to {obj.phone}: {error}")
|
||||
|
||||
if len(future_to_obj) < batch_size // 2: # Keep some buffer
|
||||
break
|
||||
|
||||
# Update status and interactions
|
||||
if len(interactions_to_create) >= batch_size:
|
||||
Interaction.objects.bulk_create(interactions_to_create)
|
||||
interactions_to_create = []
|
||||
|
||||
task.success_count = success_count
|
||||
task.fail_count = fail_count
|
||||
task.save()
|
||||
|
||||
# Process remaining futures
|
||||
for future in as_completed(future_to_obj):
|
||||
obj = future_to_obj[future]
|
||||
success, error = future.result()
|
||||
if success:
|
||||
success_count += 1
|
||||
if object_type == 'voter':
|
||||
interactions_to_create.append(Interaction(
|
||||
voter=obj,
|
||||
type=interaction_type,
|
||||
date=timezone.now(),
|
||||
description='Mass SMS Text',
|
||||
notes=message_body
|
||||
))
|
||||
else:
|
||||
fail_count += 1
|
||||
logger.error(f"Error sending SMS to {obj.phone}: {error}")
|
||||
|
||||
if interactions_to_create:
|
||||
Interaction.objects.bulk_create(interactions_to_create)
|
||||
interactions_to_create = []
|
||||
|
||||
task.success_count = success_count
|
||||
task.fail_count = fail_count
|
||||
task.status = 'completed'
|
||||
task.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error in bulk SMS task: {e}")
|
||||
try:
|
||||
task = BulkTask.objects.get(id=task_id)
|
||||
task.status = 'failed'
|
||||
task.error_message = str(e)
|
||||
task.save()
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def start_bulk_sms_task(tenant, message_body, object_ids, select_all_results, search_filters=None, object_type='voter'):
|
||||
"""
|
||||
Creates a BulkTask and starts the background thread.
|
||||
"""
|
||||
task = BulkTask.objects.create(
|
||||
tenant=tenant,
|
||||
task_type='sms',
|
||||
message_body=message_body,
|
||||
status='pending'
|
||||
)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=run_bulk_sms_task,
|
||||
args=(task.id, object_ids, select_all_results, search_filters, object_type)
|
||||
)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return task
|
||||
9
core/templates/admin/donation_change_list.html
Normal file
9
core/templates/admin/donation_change_list.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="import-donations/" class="addlink">
|
||||
Import Donations
|
||||
</a>
|
||||
</li>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
38
core/templates/admin/event_change_list.html
Normal file
38
core/templates/admin/event_change_list.html
Normal file
@ -0,0 +1,38 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% load i18n admin_urls static admin_list %}
|
||||
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="import-events/" class="addlink">Import Events</a>
|
||||
</li>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
{% block search %}
|
||||
{{ block.super }}
|
||||
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
|
||||
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
|
||||
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
|
||||
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
|
||||
{% for tenant in tenants %}
|
||||
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
|
||||
{{ tenant.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterTenant(tenantId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tenantId) {
|
||||
url.searchParams.set('tenant__id__exact', tenantId);
|
||||
} else {
|
||||
url.searchParams.delete('tenant__id__exact');
|
||||
}
|
||||
// Reset to page 1 if filtering
|
||||
url.searchParams.delete('p');
|
||||
window.location.href = url.pathname + url.search;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
7
core/templates/admin/eventparticipation_change_list.html
Normal file
7
core/templates/admin/eventparticipation_change_list.html
Normal file
@ -0,0 +1,7 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="import-event-participations/" class="addlink">Import Participants</a>
|
||||
</li>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
42
core/templates/admin/import_csv.html
Normal file
42
core/templates/admin/import_csv.html
Normal file
@ -0,0 +1,42 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n admin_urls static %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› {{ title }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<fieldset class="module aligned">
|
||||
<div class="description">
|
||||
<p>Upload a CSV file to import {{ opts.verbose_name_plural }}.</p>
|
||||
{% if title == "Import Voters" %}
|
||||
<p>Expected columns (header mandatory): <strong>voter_id, first_name, last_name, address_street, city, state, zip_code, county, phone, email, district, precinct, registration_date, is_targeted, candidate_support, yard_sign, window_sticker</strong></p>
|
||||
{% else %}
|
||||
<p>Expected columns (header mandatory): <strong>date, event_type, description</strong></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% for field in form %}
|
||||
<div class="form-row">
|
||||
{{ field.errors }}
|
||||
<label class="required" for="{{ field.id_for_label }}">{{ field.label }}:</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}
|
||||
<div class="help">{{ field.help_text|safe }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="Upload" class="default" name="_save">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
48
core/templates/admin/import_mapping.html
Normal file
48
core/templates/admin/import_mapping.html
Normal file
@ -0,0 +1,48 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n admin_urls static %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› {% translate 'Import Mapping' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
<form method="post" action="{{ action_url }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="file_path" value="{{ file_path }}">
|
||||
<input type="hidden" name="tenant" value="{{ tenant_id }}">
|
||||
|
||||
<fieldset class="module aligned">
|
||||
<h2>{% translate "Map CSV Columns to Model Fields" %}</h2>
|
||||
<div class="description">
|
||||
Select which CSV column matches each model field. Leave blank to skip.
|
||||
</div>
|
||||
|
||||
{% for field_name, verbose_name in model_fields %}
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label for="id_map_{{ field_name }}">{{ verbose_name }}:</label>
|
||||
<select name="map_{{ field_name }}" id="id_map_{{ field_name }}">
|
||||
<option value="">-- {% translate "Skip" %} --</option>
|
||||
{% for header in headers %}
|
||||
<option value="{{ header }}" {% if header|lower == field_name|lower or header|lower == verbose_name|lower %}selected{% endif %}>
|
||||
{{ header }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="{% translate 'Preview Import' %}" class="default" name="_preview">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
87
core/templates/admin/import_preview.html
Normal file
87
core/templates/admin/import_preview.html
Normal file
@ -0,0 +1,87 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n admin_urls static %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› {% translate 'Import Preview' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
<div class="module">
|
||||
<h2>{% translate "Import Preview" %}</h2>
|
||||
<p>
|
||||
{% blocktranslate with total=total_count created=create_count updated=update_count %}
|
||||
Found <strong>{{ total }}</strong> records in the CSV file.
|
||||
<br>
|
||||
- <strong>{{ created }}</strong> will be created.
|
||||
<br>
|
||||
- <strong>{{ updated }}</strong> will be updated.
|
||||
{% endblocktranslate %}
|
||||
</p>
|
||||
|
||||
{% if preview_data %}
|
||||
<div class="results">
|
||||
<h3>{% translate "Sample Records" %}</h3>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: #f8f8f8; border-bottom: 1px solid #ccc;">
|
||||
<th style="padding: 8px; text-align: left;">{% translate "Action" %}</th>
|
||||
<th style="padding: 8px; text-align: left;">{% translate "CSV Name / Matched Voter" %}</th>
|
||||
<th style="padding: 8px; text-align: left;">{% translate "Details" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in preview_data %}
|
||||
<tr style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 8px;">
|
||||
{% if row.action == 'create' %}
|
||||
<span style="color: green; font-weight: bold;">{% translate "CREATE" %}</span>
|
||||
{% else %}
|
||||
<span style="color: blue; font-weight: bold;">{% translate "UPDATE" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 8px;">
|
||||
{% if row.csv_full_name %}
|
||||
<strong>CSV:</strong> {{ row.csv_full_name }}
|
||||
{% if "Voter: N/A" not in row.identifier %}<br>{% endif %}
|
||||
{% endif %}
|
||||
{% if "Voter: N/A" not in row.identifier %}
|
||||
<strong>Matched:</strong> {{ row.identifier|cut:"Voter: " }}
|
||||
{% else %}
|
||||
{% if not row.csv_full_name %}N/A{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 8px; font-size: 0.9em; color: #666;">{{ row.details }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if total_count > preview_data|length %}
|
||||
<p><em>... and {{ total_count|add:"-10" }} more records.</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ action_url }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="file_path" value="{{ file_path }}">
|
||||
<input type="hidden" name="tenant" value="{{ tenant_id }}">
|
||||
|
||||
{# Pass mapping as hidden fields #}
|
||||
{% for field_name, csv_col in mapping.items %}
|
||||
<input type="hidden" name="map_{{ field_name }}" value="{{ csv_col }}">
|
||||
{% endfor %}
|
||||
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="{% translate 'Confirm Import' %}" class="default" name="_import">
|
||||
<a href="#" onclick="window.history.back(); return false;" class="closelink">{% translate "Cancel and go back" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
9
core/templates/admin/interaction_change_list.html
Normal file
9
core/templates/admin/interaction_change_list.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="import-interactions/" class="addlink">
|
||||
Import Interactions
|
||||
</a>
|
||||
</li>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
39
core/templates/admin/mass_assign_volunteer.html
Normal file
39
core/templates/admin/mass_assign_volunteer.html
Normal file
@ -0,0 +1,39 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n admin_urls static admin_modify %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› Assign Volunteer
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
<p>Select a volunteer to assign to the {{ queryset|length }} selected interactions:</p>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
{% for field in form %}
|
||||
<div class="form-row">
|
||||
{{ field.errors }}
|
||||
{{ field.label_tag }} {{ field }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<input type="hidden" name="action" value="mass_assign_volunteer" />
|
||||
<input type="hidden" name="post" value="yes" />
|
||||
{% for obj in queryset %}
|
||||
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}" />
|
||||
{% endfor %}
|
||||
|
||||
<div class="submit-row">
|
||||
<input type="submit" name="apply" value="Assign Volunteer" class="default" />
|
||||
<a href="." class="button cancel-link">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
31
core/templates/admin/participationstatus_change_list.html
Normal file
31
core/templates/admin/participationstatus_change_list.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% load i18n admin_urls static admin_list %}
|
||||
|
||||
{% block search %}
|
||||
{{ block.super }}
|
||||
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
|
||||
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
|
||||
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
|
||||
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
|
||||
{% for tenant in tenants %}
|
||||
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
|
||||
{{ tenant.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterTenant(tenantId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tenantId) {
|
||||
url.searchParams.set('tenant__id__exact', tenantId);
|
||||
} else {
|
||||
url.searchParams.delete('tenant__id__exact');
|
||||
}
|
||||
// Reset to page 1 if filtering
|
||||
url.searchParams.delete('p');
|
||||
window.location.href = url.pathname + url.search;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
38
core/templates/admin/volunteer_change_list.html
Normal file
38
core/templates/admin/volunteer_change_list.html
Normal file
@ -0,0 +1,38 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% load i18n admin_urls static admin_list %}
|
||||
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="import-volunteers/" class="addlink">Import Volunteers</a>
|
||||
</li>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
{% block search %}
|
||||
{{ block.super }}
|
||||
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
|
||||
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
|
||||
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
|
||||
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
|
||||
{% for tenant in tenants %}
|
||||
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
|
||||
{{ tenant.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterTenant(tenantId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tenantId) {
|
||||
url.searchParams.set('tenant__id__exact', tenantId);
|
||||
} else {
|
||||
url.searchParams.delete('tenant__id__exact');
|
||||
}
|
||||
// Reset to page 1 if filtering
|
||||
url.searchParams.delete('p');
|
||||
window.location.href = url.pathname + url.search;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
38
core/templates/admin/voter_change_list.html
Normal file
38
core/templates/admin/voter_change_list.html
Normal file
@ -0,0 +1,38 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% load i18n admin_urls static admin_list %}
|
||||
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="import-voters/" class="addlink">Import Voters</a>
|
||||
</li>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
{% block search %}
|
||||
{{ block.super }}
|
||||
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
|
||||
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
|
||||
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
|
||||
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
|
||||
{% for tenant in tenants %}
|
||||
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
|
||||
{{ tenant.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterTenant(tenantId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tenantId) {
|
||||
url.searchParams.set('tenant__id__exact', tenantId);
|
||||
} else {
|
||||
url.searchParams.delete('tenant__id__exact');
|
||||
}
|
||||
// Reset to page 1 if filtering
|
||||
url.searchParams.delete('p');
|
||||
window.location.href = url.pathname + url.search;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
9
core/templates/admin/voterlikelihood_change_list.html
Normal file
9
core/templates/admin/voterlikelihood_change_list.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="import-likelihoods/" class="addlink">
|
||||
Import Likelihoods
|
||||
</a>
|
||||
</li>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
38
core/templates/admin/votingrecord_change_list.html
Normal file
38
core/templates/admin/votingrecord_change_list.html
Normal file
@ -0,0 +1,38 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% load i18n admin_urls static admin_list %}
|
||||
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="import-voting-records/" class="addlink">Import Voting Records</a>
|
||||
</li>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
{% block search %}
|
||||
{{ block.super }}
|
||||
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
|
||||
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
|
||||
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
|
||||
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
|
||||
{% for tenant in tenants %}
|
||||
<option value="{{ tenant.id }}" {% if request.GET.voter__tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
|
||||
{{ tenant.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterTenant(tenantId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tenantId) {
|
||||
url.searchParams.set('voter__tenant__id__exact', tenantId);
|
||||
} else {
|
||||
url.searchParams.delete('voter__tenant__id__exact');
|
||||
}
|
||||
// Reset to page 1 if filtering
|
||||
url.searchParams.delete('p');
|
||||
window.location.href = url.pathname + url.search;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
115
core/templates/base.html
Normal file
115
core/templates/base.html
Normal file
@ -0,0 +1,115 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Grassroots Campaign Manager{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">Grassroots CM</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/">Home</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="votersDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Voters
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow border-0" aria-labelledby="votersDropdown">
|
||||
<li><a class="dropdown-item small" href="/voters/"><i class="bi bi-people me-2"></i>Registry</a></li>
|
||||
<li><a class="dropdown-item small" href="{% url 'voter_advanced_search' %}"><i class="bi bi-search me-2"></i>Advanced Search</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item small" href="{% url 'bulk_task_list' %}"><i class="bi bi-list-task me-2"></i>Bulk Operations Log</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% if can_access_call_queue %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/call-queue/">Call Queue</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/door-visits/">Door Visits</a>
|
||||
</li>
|
||||
{% if not is_block_walker or is_staff %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/events/">Events</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="signsDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Yard Signs
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow border-0" aria-labelledby="signsDropdown">
|
||||
<li><a class="dropdown-item small" href="{% url 'yard_sign_voters' %}"><i class="bi bi-card-checklist me-2"></i>Sign Requests</a></li>
|
||||
<li><a class="dropdown-item small" href="{% url 'view_signs' %}"><i class="bi bi-signpost-2 me-2"></i>View Signs</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% if can_view_volunteers %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/volunteers/">Volunteers</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="d-flex align-items-center">
|
||||
<a href="/admin/" class="btn btn-outline-primary btn-sm me-3">Admin Panel</a>
|
||||
{% if user.is_authenticated %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-link nav-link dropdown-toggle text-white d-flex align-items-center p-0" type="button" id="userDropdown" data-bs-toggle="dropdown" aria-expanded="false" style="text-decoration: none;">
|
||||
<i class="bi bi-person-circle me-1"></i>
|
||||
<span class="small">{{ user.username }}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow border-0" aria-labelledby="userDropdown">
|
||||
<li><a class="dropdown-item small" href="{% url 'profile' %}"><i class="bi bi-person me-2"></i>My Profile</a></li>
|
||||
<li><a class="dropdown-item small" href="{% url 'password_change' %}"><i class="bi bi-shield-lock me-2"></i>Change Password</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form method="post" action="{% url 'logout' %}" class="px-3">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link dropdown-item small p-0 m-0"><i class="bi bi-box-arrow-right me-2"></i>Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{% url 'login' %}" class="btn btn-link nav-link text-white">Login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container py-4">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
14
core/templates/core/article_detail.html
Normal file
14
core/templates/core/article_detail.html
Normal file
@ -0,0 +1,14 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ article.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<h1>{{ article.title }}</h1>
|
||||
<p class="text-muted">Published on {{ article.created_at|date:"F d, Y" }}</p>
|
||||
<hr>
|
||||
<div>
|
||||
{{ article.content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user