Skip to content

Commit b69e07e

Browse files
committed
Org importer
1 parent 1a9139f commit b69e07e

File tree

9 files changed

+2547
-97
lines changed

9 files changed

+2547
-97
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import json
2+
import logging
3+
import hashlib
4+
from typing import Any
5+
from urllib.parse import urlencode
6+
from urllib.request import Request, urlopen
7+
8+
from django.core.cache import cache
9+
10+
from core.views_utils import _normalize_str
11+
12+
logger = logging.getLogger(__name__)
13+
14+
_PHOTON_ADDRESS_CACHE_PREFIX = "photon_address_parts:"
15+
_PHOTON_ADDRESS_CACHE_TTL_SECONDS = 24 * 60 * 60
16+
_PHOTON_MAX_ATTEMPTS = 3
17+
18+
19+
def _photon_address_parts_from_feature(feature: dict[str, Any]) -> dict[str, str] | None:
20+
properties = feature.get("properties")
21+
if not isinstance(properties, dict):
22+
return None
23+
24+
street_name = _normalize_str(properties.get("street") or properties.get("name"))
25+
house_number = _normalize_str(properties.get("housenumber"))
26+
street = f"{street_name} {house_number}".strip()
27+
28+
city = _normalize_str(
29+
properties.get("city")
30+
or properties.get("town")
31+
or properties.get("village")
32+
or properties.get("hamlet")
33+
or properties.get("locality")
34+
)
35+
state = _normalize_str(properties.get("state"))
36+
postal_code = _normalize_str(properties.get("postcode"))
37+
country_code = _normalize_str(properties.get("countrycode")).upper()
38+
39+
result = {
40+
"street": street,
41+
"city": city,
42+
"state": state,
43+
"postal_code": postal_code,
44+
"country_code": country_code,
45+
}
46+
if not any(result.values()):
47+
return None
48+
49+
return {key: value for key, value in result.items() if value}
50+
51+
52+
def decompose_full_address_with_photon(full_address: str, *, timeout_seconds: int = 5) -> dict[str, str]:
53+
query = _normalize_str(full_address)
54+
if not query:
55+
return {}
56+
57+
normalized_query = query.lower()
58+
query_hash = hashlib.sha256(normalized_query.encode("utf-8")).hexdigest()
59+
cache_key = f"{_PHOTON_ADDRESS_CACHE_PREFIX}{query_hash}"
60+
cached = cache.get(cache_key)
61+
if isinstance(cached, dict):
62+
return cached
63+
64+
url = f"https://photon.komoot.io/api/?{urlencode({'q': query, 'limit': 1})}"
65+
request = Request(url, headers={"User-Agent": "astra-address-import/1.0"})
66+
payload: dict[str, Any] | None = None
67+
last_error: Exception | None = None
68+
for attempt in range(1, _PHOTON_MAX_ATTEMPTS + 1):
69+
try:
70+
with urlopen(request, timeout=timeout_seconds) as response:
71+
parsed = json.loads(response.read().decode("utf-8"))
72+
if isinstance(parsed, dict):
73+
payload = parsed
74+
else:
75+
payload = {}
76+
break
77+
except Exception as exc:
78+
last_error = exc
79+
logger.warning(
80+
"Photon geocoding attempt failed query=%r attempt=%d/%d error=%s",
81+
query,
82+
attempt,
83+
_PHOTON_MAX_ATTEMPTS,
84+
exc,
85+
)
86+
87+
if payload is None:
88+
if last_error is not None:
89+
logger.error("Photon geocoding failed for full_address lookup: %s", last_error)
90+
cache.set(cache_key, {}, timeout=_PHOTON_ADDRESS_CACHE_TTL_SECONDS)
91+
return {}
92+
93+
features = payload.get("features") if isinstance(payload, dict) else None
94+
if not isinstance(features, list) or not features:
95+
cache.set(cache_key, {}, timeout=_PHOTON_ADDRESS_CACHE_TTL_SECONDS)
96+
return {}
97+
98+
first_feature = features[0]
99+
if not isinstance(first_feature, dict):
100+
cache.set(cache_key, {}, timeout=_PHOTON_ADDRESS_CACHE_TTL_SECONDS)
101+
return {}
102+
103+
result = _photon_address_parts_from_feature(first_feature) or {}
104+
cache.set(cache_key, result, timeout=_PHOTON_ADDRESS_CACHE_TTL_SECONDS)
105+
return result

astra_app/core/admin.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@
5050
MembershipCSVImportResource,
5151
)
5252
from core.organization_claim import make_organization_claim_token
53+
from core.organization_csv_import import (
54+
_ORG_COLUMN_FIELDS,
55+
OrganizationCSVConfirmImportForm,
56+
OrganizationCSVImportForm,
57+
OrganizationCSVImportResource,
58+
iter_representative_selection_items,
59+
optional_organization_csv_columns,
60+
required_organization_csv_columns,
61+
)
5362
from core.protected_resources import protected_freeipa_group_cns
5463
from core.user_labels import user_choice, user_choice_with_fallback, user_choices_from_users
5564
from core.views_utils import _normalize_str
@@ -81,6 +90,7 @@
8190
MembershipType,
8291
MembershipTypeCategory,
8392
Organization,
93+
OrganizationCSVImportLink,
8494
VotingCredential,
8595
)
8696

@@ -1831,6 +1841,137 @@ def changelist_view(self, request: HttpRequest, extra_context: dict[str, Any] |
18311841
return redirect("admin:core_membershipcsvimportlink_import")
18321842

18331843

1844+
@admin.register(OrganizationCSVImportLink)
1845+
class OrganizationCSVImportLinkAdmin(ImportMixin, admin.ModelAdmin):
1846+
"""Admin entry for the organization CSV importer (django-import-export)."""
1847+
1848+
import_form_class = OrganizationCSVImportForm
1849+
confirm_form_class = OrganizationCSVConfirmImportForm
1850+
import_template_name = "admin/core/organization_csv_import.html"
1851+
resource_classes = [OrganizationCSVImportResource]
1852+
1853+
@override
1854+
def has_add_permission(self, request: HttpRequest) -> bool:
1855+
return False
1856+
1857+
@override
1858+
def get_import_formats(self) -> list[type[base_formats.Format]]:
1859+
return [base_formats.CSV]
1860+
1861+
@override
1862+
def has_delete_permission(self, request: HttpRequest, obj: object | None = None) -> bool:
1863+
return False
1864+
1865+
@override
1866+
def has_change_permission(self, request: HttpRequest, obj: object | None = None) -> bool:
1867+
return False
1868+
1869+
@override
1870+
def has_view_permission(self, request: HttpRequest, obj: object | None = None) -> bool:
1871+
return bool(request.user.is_active and request.user.is_staff)
1872+
1873+
@override
1874+
def get_model_perms(self, request: HttpRequest) -> dict[str, bool]:
1875+
if not self.has_view_permission(request):
1876+
return {}
1877+
return {"view": True}
1878+
1879+
@override
1880+
def get_confirm_form_initial(self, request: HttpRequest, import_form: forms.Form) -> dict[str, Any]:
1881+
initial = super().get_confirm_form_initial(request, import_form)
1882+
if import_form is None:
1883+
return initial
1884+
1885+
for key in _ORG_COLUMN_FIELDS:
1886+
value = import_form.cleaned_data.get(key, "")
1887+
if value:
1888+
initial[key] = value
1889+
return initial
1890+
1891+
@override
1892+
def get_import_resource_kwargs(self, request: HttpRequest, **kwargs: Any) -> dict[str, Any]:
1893+
form = kwargs.get("form")
1894+
if form is None:
1895+
raise ValueError("Missing import form")
1896+
1897+
cleaned_data = getattr(form, "cleaned_data", None)
1898+
extra: dict[str, Any] = {}
1899+
if isinstance(cleaned_data, dict):
1900+
for key in _ORG_COLUMN_FIELDS:
1901+
value = cleaned_data.get(key, "")
1902+
if value:
1903+
extra[key] = value
1904+
1905+
representative_selections = iter_representative_selection_items(request.POST.items())
1906+
1907+
return {
1908+
"actor_username": request.user.get_username(),
1909+
"representative_selections": representative_selections,
1910+
**extra,
1911+
}
1912+
1913+
@override
1914+
def import_action(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
1915+
response = super().import_action(request, *args, **kwargs)
1916+
1917+
if not isinstance(response, TemplateResponse) or response.context_data is None:
1918+
return response
1919+
1920+
response.context_data["required_csv_columns"] = required_organization_csv_columns()
1921+
response.context_data["optional_csv_columns"] = optional_organization_csv_columns()
1922+
1923+
result = response.context_data.get("result")
1924+
confirm_form = response.context_data.get("confirm_form")
1925+
if result is None or confirm_form is None:
1926+
return response
1927+
1928+
valid_rows_obj = getattr(result, "valid_rows", None)
1929+
if callable(valid_rows_obj):
1930+
valid_rows = list(valid_rows_obj() or [])
1931+
else:
1932+
valid_rows = list(valid_rows_obj or [])
1933+
1934+
matches: list[Any] = []
1935+
skipped: list[Any] = []
1936+
1937+
for idx, row_result in enumerate(valid_rows, start=1):
1938+
instance = row_result.instance if hasattr(row_result, "instance") else None
1939+
import_type = row_result.import_type if hasattr(row_result, "import_type") else ""
1940+
if import_type:
1941+
is_match = import_type != "skip"
1942+
elif instance is not None and hasattr(instance, "decision"):
1943+
is_match = instance.decision == "IMPORT"
1944+
else:
1945+
is_match = False
1946+
1947+
number = getattr(row_result, "number", None)
1948+
if number is None:
1949+
number = idx
1950+
try:
1951+
row_result.astra_row_number = int(number)
1952+
except (TypeError, ValueError):
1953+
row_result.astra_row_number = idx
1954+
1955+
if is_match:
1956+
matches.append(row_result)
1957+
else:
1958+
skipped.append(row_result)
1959+
1960+
response.context_data["preview_summary"] = {
1961+
"total": len(valid_rows),
1962+
"to_import": len(matches),
1963+
"skipped": len(skipped),
1964+
}
1965+
response.context_data["matches_page_obj"] = Paginator(matches, 50).get_page(request.GET.get("matches_page") or "1")
1966+
response.context_data["skipped_page_obj"] = Paginator(skipped, 50).get_page(request.GET.get("skipped_page") or "1")
1967+
1968+
return response
1969+
1970+
@override
1971+
def changelist_view(self, request: HttpRequest, extra_context: dict[str, Any] | None = None) -> HttpResponse:
1972+
return redirect("admin:core_organizationcsvimportlink_import")
1973+
1974+
18341975
@admin.register(Organization)
18351976
class OrganizationAdmin(admin.ModelAdmin):
18361977
class OrganizationAdminForm(forms.ModelForm):

astra_app/core/csv_import_utils.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import csv
2+
import datetime
3+
import io
4+
5+
from django.core.files.uploadedfile import UploadedFile
6+
7+
from core.views_utils import _normalize_str
8+
9+
10+
def norm_csv_header(value: str) -> str:
11+
return "".join(ch for ch in value.strip().lower() if ch.isalnum())
12+
13+
14+
def normalize_csv_email(value: object) -> str:
15+
return _normalize_str(value).lower()
16+
17+
18+
def normalize_csv_name(value: object) -> str:
19+
raw = _normalize_str(value).lower()
20+
return "".join(ch for ch in raw if ch.isalnum())
21+
22+
23+
def parse_csv_bool(value: object) -> bool:
24+
normalized = _normalize_str(value).lower()
25+
if not normalized:
26+
return False
27+
return normalized in {"1", "y", "yes", "true", "t", "active", "activemember", "active member"}
28+
29+
30+
def parse_csv_date(value: object) -> datetime.datetime | None:
31+
raw = _normalize_str(value)
32+
if not raw:
33+
return None
34+
35+
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y", "%m/%d/%y"):
36+
try:
37+
day = datetime.datetime.strptime(raw, fmt).date()
38+
except ValueError:
39+
continue
40+
return datetime.datetime.combine(day, datetime.time(0, 0, 0), tzinfo=datetime.UTC)
41+
42+
try:
43+
day = datetime.date.fromisoformat(raw)
44+
except ValueError:
45+
return None
46+
47+
return datetime.datetime.combine(day, datetime.time(0, 0, 0), tzinfo=datetime.UTC)
48+
49+
50+
def extract_csv_headers_from_uploaded_file(uploaded: UploadedFile) -> list[str]:
51+
uploaded.seek(0)
52+
sample = uploaded.read(64 * 1024)
53+
uploaded.seek(0)
54+
55+
try:
56+
text = sample.decode("utf-8-sig")
57+
except UnicodeDecodeError:
58+
text = sample.decode("utf-8", errors="replace")
59+
60+
if not text.strip():
61+
return []
62+
63+
try:
64+
dialect = csv.Sniffer().sniff(text, delimiters=",;\t|")
65+
except Exception:
66+
dialect = csv.excel
67+
68+
reader = csv.reader(io.StringIO(text), dialect)
69+
headers = next(reader, [])
70+
return [h.strip() for h in headers if str(h).strip()]

0 commit comments

Comments
 (0)