Skip to content

Commit 14be734

Browse files
Merge pull request #2597 from IFRCGo/feature/fix-surge-alert-csv-export
Surge Alert CSV fix – same columns across paginated requests
2 parents 596b1f2 + 1c0199b commit 14be734

File tree

2 files changed

+183
-0
lines changed

2 files changed

+183
-0
lines changed

notifications/drf_views.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .models import Subscription, SurgeAlert
1414
from .serializers import ( # UnauthenticatedSurgeAlertSerializer,
1515
SubscriptionSerializer,
16+
SurgeAlertCsvSerializer,
1617
SurgeAlertSerializer,
1718
)
1819

@@ -80,6 +81,9 @@ class SurgeAlertViewset(viewsets.ReadOnlyModelViewSet):
8081
) # for /docs
8182

8283
def get_serializer_class(self):
84+
# Use CSV-friendly serializer for CSV format to ensure consistent column count across pages
85+
if self.request.query_params.get("format") == "csv":
86+
return SurgeAlertCsvSerializer
8387
# if self.request.user.is_authenticated:
8488
# return SurgeAlertSerializer
8589
# return UnauthenticatedSurgeAlertSerializer

notifications/serializers.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,185 @@ class Meta:
4646
)
4747

4848

49+
class SurgeAlertCsvSerializer(ModelSerializer):
50+
"""CSV-friendly serializer with flattened fields for consistent column count across pages"""
51+
52+
atype_display = serializers.CharField(source="get_atype_display", read_only=True)
53+
molnix_status_display = serializers.CharField(source="get_molnix_status_display", read_only=True)
54+
category_display = serializers.CharField(source="get_category_display", read_only=True)
55+
56+
# Flattened country fields
57+
country_id = serializers.IntegerField(source="country.id", read_only=True)
58+
country_name = serializers.CharField(source="country.name", read_only=True)
59+
country_iso = serializers.CharField(source="country.iso", read_only=True)
60+
country_iso3 = serializers.CharField(source="country.iso3", read_only=True)
61+
country_society_name = serializers.CharField(source="country.society_name", read_only=True)
62+
country_region = serializers.IntegerField(source="country.region.id", read_only=True)
63+
64+
# Flattened event fields
65+
event_id = serializers.IntegerField(source="event.id", read_only=True)
66+
event_name = serializers.CharField(source="event.name", read_only=True)
67+
event_dtype_id = serializers.IntegerField(source="event.dtype.id", read_only=True)
68+
event_dtype_name = serializers.CharField(source="event.dtype.name", read_only=True)
69+
event_glide = serializers.CharField(source="event.glide", read_only=True)
70+
event_disaster_start_date = serializers.DateTimeField(source="event.disaster_start_date", read_only=True)
71+
event_auto_generated = serializers.BooleanField(source="event.auto_generated", read_only=True)
72+
event_ifrc_severity_level = serializers.IntegerField(source="event.ifrc_severity_level", read_only=True)
73+
event_num_affected = serializers.IntegerField(source="event.num_affected", read_only=True)
74+
event_parent_event = serializers.IntegerField(source="event.parent_event.id", read_only=True)
75+
event_summary = serializers.SerializerMethodField()
76+
event_tab_one_title = serializers.CharField(source="event.tab_one_title", read_only=True)
77+
event_tab_two_title = serializers.CharField(source="event.tab_two_title", read_only=True)
78+
event_tab_three_title = serializers.CharField(source="event.tab_three_title", read_only=True)
79+
event_translation_module_original_language = serializers.CharField(
80+
source="event.translation_module_original_language", read_only=True
81+
)
82+
event_updated_at = serializers.DateTimeField(source="event.updated_at", read_only=True)
83+
84+
# Comma-separated lists for many-to-many relationships
85+
event_countries = serializers.SerializerMethodField()
86+
event_appeals = serializers.SerializerMethodField()
87+
molnix_tags_names = serializers.SerializerMethodField()
88+
molnix_tags_ids = serializers.SerializerMethodField()
89+
molnix_tags_types = serializers.SerializerMethodField()
90+
91+
class Meta:
92+
model = SurgeAlert
93+
fields = (
94+
"id",
95+
"molnix_id",
96+
"created_at",
97+
"operation",
98+
"message",
99+
"deployment_needed",
100+
"is_private",
101+
"atype",
102+
"atype_display",
103+
"category",
104+
"category_display",
105+
"molnix_status",
106+
"molnix_status_display",
107+
"opens",
108+
"closes",
109+
"start",
110+
"end",
111+
# Country fields (flattened)
112+
"country_id",
113+
"country_name",
114+
"country_iso",
115+
"country_iso3",
116+
"country_society_name",
117+
"country_region",
118+
# Event fields (flattened)
119+
"event_id",
120+
"event_name",
121+
"event_dtype_id",
122+
"event_dtype_name",
123+
"event_glide",
124+
"event_disaster_start_date",
125+
"event_auto_generated",
126+
"event_ifrc_severity_level",
127+
"event_num_affected",
128+
"event_parent_event",
129+
"event_summary",
130+
"event_tab_one_title",
131+
"event_tab_two_title",
132+
"event_tab_three_title",
133+
"event_translation_module_original_language",
134+
"event_updated_at",
135+
"event_countries",
136+
"event_appeals",
137+
# Molnix tags (comma-separated)
138+
"molnix_tags_names",
139+
"molnix_tags_ids",
140+
"molnix_tags_types",
141+
)
142+
143+
@staticmethod
144+
def get_event_summary(obj):
145+
"""Return HTML-stripped first 300 characters of event summary with empty lines removed"""
146+
if obj.event and obj.event.summary:
147+
import re
148+
from html import unescape
149+
150+
text = obj.event.summary
151+
152+
# Remove Microsoft Office table styles and metadata (before HTML stripping)
153+
# Match content between /* Style Definitions */ and the closing brace
154+
text = re.sub(r"/\*\s*Style Definitions\s*\*/.*?}", "", text, flags=re.DOTALL)
155+
# Remove other common MS Office artifacts
156+
text = re.sub(r"Normal\s+0\s+\d+\s+false\s+false\s+false\s+[A-Z-]+(?:\s+[A-Z-]+)*", "", text)
157+
text = re.sub(r"table\.MsoNormalTable\s*{[^}]*}", "", text, flags=re.DOTALL)
158+
159+
# Strip HTML tags
160+
text = re.sub(r"<[^>]+>", "", text)
161+
# Decode HTML entities (&eacute; -> é, etc.)
162+
text = unescape(text)
163+
# Remove lines that contain only whitespace or are empty
164+
lines = [line.strip() for line in text.split("\n") if line.strip()]
165+
# Remove lines that only contain "DISASTER OVERVIEW" or "Summary"
166+
lines = [line for line in lines if line.strip().upper() != "DISASTER OVERVIEW" and line.strip().upper() != "SUMMARY"]
167+
# Join remaining lines with newline
168+
text = "\n".join(lines)
169+
# Return first 300 characters
170+
return text[:300]
171+
return ""
172+
173+
@staticmethod
174+
def get_event_countries(obj):
175+
"""Return structured list of countries with name, fdrs, iso, iso3 (separated by |)"""
176+
if obj.event and obj.event.countries.exists():
177+
country_data = []
178+
for country in obj.event.countries.all():
179+
# Format: name;fdrs;iso;iso3
180+
parts = [country.name or "", country.fdrs or "", country.iso or "", country.iso3 or ""]
181+
country_data.append(";".join(parts))
182+
return " | ".join(country_data)
183+
return ""
184+
185+
@staticmethod
186+
def get_event_appeals(obj):
187+
"""Return structured list of appeals with all details (separated by |)
188+
in a format code;amount_funded;amount_requested;atype;atype_display;start_date;end_date;
189+
num_beneficiaries;sector;status_display
190+
"""
191+
if obj.event:
192+
appeals = obj.event.appeals.all()
193+
if appeals:
194+
appeal_data = []
195+
for appeal in appeals:
196+
parts = [
197+
appeal.code or "",
198+
str(appeal.amount_funded) if appeal.amount_funded is not None else "",
199+
str(appeal.amount_requested) if appeal.amount_requested is not None else "",
200+
str(appeal.atype) if appeal.atype is not None else "",
201+
appeal.get_atype_display() if hasattr(appeal, "get_atype_display") else "",
202+
str(appeal.start_date) if appeal.start_date else "",
203+
str(appeal.end_date) if appeal.end_date else "",
204+
str(appeal.num_beneficiaries) if appeal.num_beneficiaries is not None else "",
205+
appeal.sector or "",
206+
appeal.get_status_display() if hasattr(appeal, "get_status_display") else "",
207+
]
208+
appeal_data.append(";".join(parts))
209+
return " | ".join(appeal_data)
210+
return ""
211+
212+
@staticmethod
213+
def get_molnix_tags_names(obj):
214+
"""Return comma-separated list of molnix tag names"""
215+
return ", ".join([tag.name for tag in obj.molnix_tags.all()])
216+
217+
@staticmethod
218+
def get_molnix_tags_ids(obj):
219+
"""Return comma-separated list of molnix tag IDs"""
220+
return ", ".join([str(tag.molnix_id) for tag in obj.molnix_tags.all()])
221+
222+
@staticmethod
223+
def get_molnix_tags_types(obj):
224+
"""Return comma-separated list of molnix tag types"""
225+
return ", ".join([tag.tag_type for tag in obj.molnix_tags.all()])
226+
227+
49228
# class UnauthenticatedSurgeAlertSerializer(ModelSerializer):
50229
# event = MiniEventSerializer()
51230
# atype_display = serializers.CharField(source='get_atype_display', read_only=True)

0 commit comments

Comments
 (0)