Skip to content

Commit a56093d

Browse files
Merge pull request #2543 from IFRCGo/project/localunit-feedbacks
Project/localunit feedbacks
2 parents fad2a4a + b3aff25 commit a56093d

File tree

12 files changed

+194
-47
lines changed

12 files changed

+194
-47
lines changed

deploy/helm/ifrcgo-helm/values.yaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,8 @@ cronjobs:
272272
schedule: '0 0 * * 0'
273273
- command: 'ingest_icrc'
274274
schedule: '0 3 * * 0'
275-
# NOTE: Disabling local unit email notification for now
276-
# - command: 'notify_validators'
277-
# schedule: '0 0 * * *'
275+
- command: 'notify_validators'
276+
schedule: '0 0 * * *'
278277
# https://github.com/jazzband/django-oauth-toolkit/blob/master/docs/management_commands.rst#cleartokens
279278
- command: 'oauth_cleartokens'
280279
schedule: '0 1 * * *'

local_units/bulk_upload.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from django.core.files.base import ContentFile
88
from django.db import transaction
9+
from rest_framework.exceptions import ErrorDetail
910
from rest_framework.serializers import Serializer
1011

1112
from local_units.models import HealthData, LocalUnit, LocalUnitBulkUpload, LocalUnitType
@@ -27,16 +28,69 @@ class BulkUploadError(Exception):
2728
class ErrorWriter:
2829
def __init__(self, fieldnames: list[str]):
2930
self._fieldnames = ["upload_status"] + fieldnames
31+
self._rows: list[dict[str, str]] = []
3032
self._output = io.StringIO()
3133
self._writer = csv.DictWriter(self._output, fieldnames=self._fieldnames)
3234
self._writer.writeheader()
35+
self._has_errors = False
36+
37+
def _format_errors(self, errors: dict) -> dict[str, list[str]]:
38+
"""Format serializer errors into field_name and list of messages."""
39+
error = {}
40+
for key, value in errors.items():
41+
if isinstance(value, dict):
42+
for inner_key, inner_value in self._format_errors(value).items():
43+
error[inner_key] = inner_value
44+
elif isinstance(value, list):
45+
error[key] = [self._clean_message(v) for v in value]
46+
else:
47+
error[key] = [self._clean_message(value)]
48+
return error
49+
50+
def _clean_message(self, msg: Any) -> str:
51+
"""Convert ErrorDetail or other objects into normal text."""
52+
if isinstance(msg, ErrorDetail):
53+
return str(msg)
54+
return str(msg)
55+
56+
def _update_csv_header_with_errors(self):
57+
"""Update the CSV with updated headers when new error columns are introduced."""
58+
self._output.seek(0)
59+
self._output.truncate()
60+
self._writer = csv.DictWriter(self._output, fieldnames=self._fieldnames)
61+
self._writer.writeheader()
62+
for row in self._rows:
63+
self._writer.writerow(row)
3364

3465
def write(
35-
self, row: dict[str, str], status: Literal[LocalUnitBulkUpload.Status.SUCCESS, LocalUnitBulkUpload.Status.FAILED]
66+
self,
67+
row: dict[str, str],
68+
status: Literal[LocalUnitBulkUpload.Status.SUCCESS, LocalUnitBulkUpload.Status.FAILED],
69+
error_detail: dict | None = None,
3670
) -> None:
37-
self._writer.writerow({"upload_status": status.name, **row})
38-
if status == LocalUnitBulkUpload.Status.FAILED:
39-
self._has_errors = True
71+
row_copy = {col: row.get(col, "") for col in self._fieldnames}
72+
row_copy["upload_status"] = status.name
73+
added_error_column = False
74+
75+
if status == LocalUnitBulkUpload.Status.FAILED and error_detail:
76+
formatted_errors = self._format_errors(error_detail)
77+
for field, messages in formatted_errors.items():
78+
error_col = f"{field}_error"
79+
80+
if error_col not in self._fieldnames:
81+
if field in self._fieldnames:
82+
col_idx = self._fieldnames.index(field)
83+
self._fieldnames.insert(col_idx + 1, error_col)
84+
else:
85+
self._fieldnames.append(error_col)
86+
87+
added_error_column = True
88+
row_copy[error_col] = "; ".join(messages)
89+
self._rows.append(row_copy)
90+
if added_error_column:
91+
self._update_csv_header_with_errors()
92+
else:
93+
self._writer.writerow(row_copy)
4094

4195
def to_content_file(self) -> ContentFile:
4296
return ContentFile(self._output.getvalue().encode("utf-8"))
@@ -70,17 +124,17 @@ def process_row(self, data: Dict[str, Any]) -> bool:
70124
if serializer.is_valid():
71125
self.bulk_manager.add(LocalUnit(**serializer.validated_data))
72126
return True
127+
self.error_detail = serializer.errors
73128
return False
74129

75130
def run(self) -> None:
76131
with self.bulk_upload.file.open("rb") as csv_file:
77132
file = io.TextIOWrapper(csv_file, encoding="utf-8")
78133
csv_reader = csv.DictReader(file)
79134
fieldnames = csv_reader.fieldnames or []
80-
81135
try:
82136
self._validate_csv(fieldnames)
83-
except ValueError as e:
137+
except BulkUploadError as e:
84138
self.bulk_upload.status = LocalUnitBulkUpload.Status.FAILED
85139
self.bulk_upload.error_message = str(e)
86140
self.bulk_upload.save(update_fields=["status", "error_message"])
@@ -99,7 +153,9 @@ def run(self) -> None:
99153
self.error_writer.write(row_data, status=LocalUnitBulkUpload.Status.SUCCESS)
100154
else:
101155
self.failed_count += 1
102-
self.error_writer.write(row_data, status=LocalUnitBulkUpload.Status.FAILED)
156+
self.error_writer.write(
157+
row_data, status=LocalUnitBulkUpload.Status.FAILED, error_detail=self.error_detail
158+
)
103159
logger.warning(f"[BulkUpload:{self.bulk_upload.pk}] Row '{row_index}' failed")
104160

105161
if self.failed_count > 0:
@@ -175,7 +231,7 @@ def _validate_csv(self, fieldnames) -> None:
175231
raise ValueError("Invalid local unit type")
176232

177233
if present_health_fields and local_unit_type.name.strip().lower() != "health care":
178-
raise ValueError(f"Health data are not allowed for this type: {local_unit_type.name}.")
234+
raise BulkUploadError(f"Health data are not allowed for this type: {local_unit_type.name}.")
179235

180236

181237
class BulkUploadHealthData(BaseBulkUpload[LocalUnitUploadContext]):

local_units/dev_views.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,65 @@ class LocalUnitsEmailPreview(APIView):
99

1010
def get(self, request):
1111
type_param = request.GET.get("type")
12-
param_types = {"new", "update", "validate", "revert", "deprecate", "regional_validator", "global_validator"}
12+
param_types = {
13+
"new",
14+
"update",
15+
"validate",
16+
"revert",
17+
"deprecate",
18+
"regional_validator",
19+
"global_validator",
20+
}
1321

1422
if type_param not in param_types:
15-
return HttpResponse(f"Invalid type parameter. Please use one of the following values: {', '.join(param_types)}.")
23+
return HttpResponse(f"Invalid 'type' parameter. Please use one of the following values: {', '.join(param_types)}.")
1624

1725
context_mapping = {
18-
"new": {"new_local_unit": True, "validator_email": "Test Validator", "full_name": "Test User"},
19-
"update": {"update_local_unit": True, "validator_email": "Test Validator", "full_name": "Test User"},
20-
"validate": {"validate_success": True, "full_name": "Test User"},
21-
"revert": {"revert_reason": "Test Reason", "full_name": "Test User"},
22-
"deprecate": {"deprecate_local_unit": True, "deprecate_reason": "Test Deprecate Reason", "full_name": "Test User"},
23-
"regional_validator": {"is_validator_regional_admin": True, "full_name": "Regional User"},
24-
"global_validator": {"is_validator_global_admin": True, "full_name": "Global User"},
26+
"new": {
27+
"new_local_unit": True,
28+
"validator_email": "Test Validator",
29+
"full_name": "Test User",
30+
"country": "Test Country",
31+
},
32+
"update": {
33+
"update_local_unit": True,
34+
"update_reason_overview": "Test update reason",
35+
"validator_email": "Test Validator",
36+
"full_name": "Test User",
37+
"country": "Test Country",
38+
"country_id": 1,
39+
},
40+
"validate": {
41+
"validate_success": True,
42+
"full_name": "Test User",
43+
"country": "Test Country",
44+
"country_id": 1,
45+
},
46+
"revert": {
47+
"revert_reason": "Test Reason",
48+
"full_name": "Test User",
49+
"country": "Test Country",
50+
"country_id": 1,
51+
},
52+
"deprecate": {
53+
"deprecate_local_unit": True,
54+
"deprecate_reason": "Test Deprecate Reason",
55+
"full_name": "Test User",
56+
"country": "Test Country",
57+
"country_id": 1,
58+
},
59+
"regional_validator": {
60+
"is_validator_regional_admin": True,
61+
"full_name": "Regional User",
62+
"country": "Test Country",
63+
"country_id": 1,
64+
},
65+
"global_validator": {
66+
"is_validator_global_admin": True,
67+
"full_name": "Global User",
68+
"country": "Test Country",
69+
"country_id": 1,
70+
},
2571
}
2672

2773
context = context_mapping.get(type_param)

local_units/management/commands/notify_validators.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datetime import timedelta
22

3+
from django.conf import settings
34
from django.core.management.base import BaseCommand
45
from django.template.loader import render_to_string
56
from django.utils import timezone
@@ -21,21 +22,28 @@ class Command(BaseCommand):
2122
@monitor(monitor_slug=SentryMonitor.NOTIFY_VALIDATORS)
2223
def handle(self, *args, **options):
2324
self.stdout.write(self.style.NOTICE("Notifying the validators..."))
24-
25+
# NOTE: In production use standard email notification time(7days/14days),shorter delays(1day/2days) elsewhere for testing
26+
production = settings.GO_ENVIRONMENT == "production"
27+
if production:
28+
regional_email_notification_days = 7
29+
global_email_notification_days = 14
30+
else:
31+
regional_email_notification_days = 1
32+
global_email_notification_days = 2
2533
# Regional Validators: 14 days
2634
queryset_for_regional_validators = LocalUnit.objects.filter(
2735
status__in=[LocalUnit.Status.UNVALIDATED, LocalUnit.Status.PENDING_EDIT_VALIDATION],
2836
is_deprecated=False,
2937
last_sent_validator_type=Validator.LOCAL,
30-
created_at__lte=timezone.now() - timedelta(days=7),
38+
created_at__lte=timezone.now() - timedelta(days=regional_email_notification_days),
3139
)
3240

3341
# Global Validators: 28 days
3442
queryset_for_global_validators = LocalUnit.objects.filter(
3543
status__in=[LocalUnit.Status.UNVALIDATED, LocalUnit.Status.PENDING_EDIT_VALIDATION],
3644
is_deprecated=False,
3745
last_sent_validator_type=Validator.REGIONAL,
38-
created_at__lte=timezone.now() - timedelta(days=14),
46+
created_at__lte=timezone.now() - timedelta(days=global_email_notification_days),
3947
)
4048

4149
for local_unit in queryset_for_regional_validators:

local_units/serializers.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ class PrivateLocalUnitSerializer(serializers.ModelSerializer):
446446
status_details = serializers.CharField(source="get_status_display", read_only=True)
447447
modified_by_details = LocalUnitMiniUserSerializer(source="modified_by", read_only=True)
448448
is_locked = serializers.BooleanField(read_only=True)
449+
update_reason_overview = serializers.CharField(read_only=True)
449450

450451
class Meta:
451452
model = LocalUnit
@@ -473,6 +474,7 @@ class Meta:
473474
"is_locked",
474475
"is_new_local_unit",
475476
"bulk_upload",
477+
"update_reason_overview",
476478
)
477479

478480
def get_location_geojson(self, unit) -> dict:
@@ -697,6 +699,8 @@ class Meta:
697699
def validate_file(self, file):
698700
if not file.name.endswith(".csv"):
699701
raise serializers.ValidationError(gettext("File must be a CSV file."))
702+
if file.size > 10 * 1024 * 1024:
703+
raise serializers.ValidationError(gettext("File must be less than 10 MB."))
700704
return file
701705

702706
def get_file_name(self, obj):
@@ -903,7 +907,7 @@ def validate_level(self, value):
903907
level_name = value.strip().lower()
904908
level_id = self.level_map.get(level_name)
905909
if not level_id:
906-
raise serializers.ValidationError({"Level": gettext("Level '{value}' is not valid")})
910+
raise serializers.ValidationError(gettext("Level is not valid"))
907911
return level_id
908912

909913
def validate(self, validated_data):

local_units/tasks.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ def send_local_unit_email(local_unit_id: int, new: bool = True):
4545

4646
for user in users:
4747
# NOTE: Adding the validator email to the context
48+
if not user.email:
49+
logger.warning(f"Email address not found for validator: {user.get_full_name()}.")
50+
return None
4851
email_context["validator_email"] = user.email
4952
email_context["full_name"] = user.get_full_name()
5053
email_body = render_to_string("email/local_units/local_unit.html", email_context)
@@ -58,6 +61,14 @@ def send_validate_success_email(local_unit_id: int, message: str = ""):
5861

5962
instance = LocalUnit.objects.get(id=local_unit_id)
6063
user = instance.created_by
64+
if not user:
65+
logger.warning(f"Email not sent for Local Unit:{local_unit_id} because creator is unknown.")
66+
return None
67+
elif not user.email:
68+
logger.warning(
69+
f"Email not sent for Local Unit:{local_unit_id} because creator: {user.get_full_name()} has no email address."
70+
)
71+
return None
6172
email_context = get_email_context(instance)
6273
email_context["full_name"] = user.get_full_name()
6374
email_context["validate_success"] = True
@@ -76,6 +87,14 @@ def send_revert_email(local_unit_id: int, change_request_id: int):
7687
instance = LocalUnit.objects.get(id=local_unit_id)
7788
change_request_instance = LocalUnitChangeRequest.objects.get(id=change_request_id)
7889
user = instance.created_by
90+
if not user:
91+
logger.warning(f"Email not sent for Local Unit:{local_unit_id} because creator is unknown.")
92+
return None
93+
elif not user.email:
94+
logger.warning(
95+
f"Email not sent for Local Unit:{local_unit_id} because creator: {user.get_full_name()} has no email address."
96+
)
97+
return None
7998
email_context = get_email_context(instance)
8099
email_context["full_name"] = user.get_full_name()
81100
email_context["revert_reason"] = change_request_instance.rejected_reason
@@ -94,6 +113,15 @@ def send_deprecate_email(local_unit_id: int):
94113

95114
instance = LocalUnit.objects.get(id=local_unit_id)
96115
user = instance.created_by
116+
if not user:
117+
logger.warning(f"Email not sent for Local Unit:{local_unit_id} because creator is unknown.")
118+
return None
119+
elif not user.email:
120+
logger.warning(
121+
f"Email not sent for Local Unit:{local_unit_id} because creator: {user.get_full_name()} has no email address."
122+
)
123+
return None
124+
97125
email_context = get_email_context(instance)
98126
email_context["full_name"] = user.get_full_name()
99127
email_context["deprecate_local_unit"] = True
@@ -110,7 +138,7 @@ def process_bulk_upload_local_unit(bulk_upload_id: int) -> None:
110138
bulk_upload: LocalUnitBulkUpload | None = LocalUnitBulkUpload.objects.filter(id=bulk_upload_id).first()
111139

112140
if not bulk_upload:
113-
logger.error(f"BulkUploadLocalUnit:'{bulk_upload_id}' Not found.", exc_info=True)
141+
logger.warning(f"BulkUploadLocalUnit:'{bulk_upload_id}' Not found.", exc_info=True)
114142
return
115143
try:
116144
if (
@@ -121,6 +149,6 @@ def process_bulk_upload_local_unit(bulk_upload_id: int) -> None:
121149
BaseBulkUploadLocalUnit(bulk_upload).run()
122150

123151
except Exception as exc:
124-
logger.error(f"BulkUploadLocalUnit:'{bulk_upload_id}' Failed with exception: {exc}", exc_info=True)
152+
logger.warning(f"BulkUploadLocalUnit:'{bulk_upload_id}' Failed with exception: {exc}", exc_info=True)
125153
bulk_upload.update_status(LocalUnitBulkUpload.Status.FAILED)
126154
raise exc

local_units/utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ def get_email_context(instance):
1414
email_context = {
1515
"id": local_unit_data["id"],
1616
"local_branch_name": local_unit_data["local_branch_name"],
17-
"frontend_url": settings.FRONTEND_URL,
17+
"country": local_unit_data["country_details"]["name"],
18+
"country_id": local_unit_data["country_details"]["id"],
19+
"update_reason_overview": local_unit_data["update_reason_overview"],
20+
"frontend_url": settings.GO_WEB_URL,
1821
}
1922
return email_context
2023

local_units/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ class ExternallyManagedLocalUnitViewSet(
401401
class LocalUnitBulkUploadViewSet(
402402
mixins.CreateModelMixin,
403403
mixins.ListModelMixin,
404+
mixins.RetrieveModelMixin,
404405
viewsets.GenericViewSet,
405406
):
406407
queryset = LocalUnitBulkUpload.objects.select_related("country", "local_unit_type", "triggered_by").order_by("-triggered_at")

main/sentry.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,7 @@ class SentryMonitor(models.TextChoices):
128128
INGEST_NS_DOCUMENT = "ingest_ns_document", "0 0 * * 0"
129129
INGEST_NS_INITIATIVES = "ingest_ns_initiatives", "0 0 * * 0"
130130
INGEST_ICRC = "ingest_icrc", "0 3 * * 0"
131-
# NOTE: Disabling local unit email notification for now
132-
# NOTIFY_VALIDATORS = "notify_validators", "0 0 * * *"
131+
NOTIFY_VALIDATORS = "notify_validators", "0 0 * * *"
133132
OAUTH_CLEARTOKENS = "oauth_cleartokens", "0 1 * * *"
134133

135134
@staticmethod

notifications/templates/design/foot1.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
<table width="100%" border="0" cellspacing="0" cellpadding="0">
99
<tr>
1010
<td class="text-default td-smaller-font-style text-right pl-10 pr-10" style="color: #000000;font-family:'Lato', Arial, sans-serif;border-right:1px solid #000;font-size: 16px;text-align: right;padding-left: 10px;padding-right: 10px;">
11-
<a href="https://go.ifrc.org/login" target="_blank" class="link" style="font-family: 'Lato', Arial, sans-serif;text-decoration: underline;color: #000;">Login to IFRC GO</a>
11+
<a href="{{ frontend_url }}/login" target="_blank" class="link" style="font-family: 'Lato', Arial, sans-serif;text-decoration: underline;color: #000;">Login to IFRC GO</a>
1212
</td>
1313
{% if not hide_preferences %}
1414
<td class="text-default td-smaller-font-style pl-10 pr-10" style="color: #000000;font-family:'Lato', Arial, sans-serif;font-size: 16px;padding-left: 10px;padding-right: 10px;">
15-
<a href="https://go.ifrc.org/account#notifications" target="_blank" class="link" style="font-family: 'Lato', Arial, sans-serif;text-decoration: underline;color: #000;">Unsubscribe</a>
15+
<a href="{{ frontend_url }}/account#notifications" target="_blank" class="link" style="font-family: 'Lato', Arial, sans-serif;text-decoration: underline;color: #000;">Unsubscribe</a>
1616
</td>
1717
{% endif %}
1818
</tr>

0 commit comments

Comments
 (0)