Skip to content

Commit 006c237

Browse files
Merge pull request #2346 from IFRCGo/project/localunit
Local Units Edit Process Flow Update
2 parents d918172 + fd194cb commit 006c237

26 files changed

+1295
-62
lines changed

deploy/helm/ifrcgo-helm/templates/config/configmap.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,8 @@ data:
2424
DJANGO_READ_ONLY: {{ .Values.env.DJANGO_READ_ONLY | quote }}
2525
SENTRY_SAMPLE_RATE: {{ .Values.env.SENTRY_SAMPLE_RATE | quote }}
2626
SENTRY_DSN: {{ .Values.env.SENTRY_DSN | quote }}
27+
28+
# Additional configs
29+
{{- range $name, $value := .Values.envAdditional }}
30+
{{ $name }}: {{ $value | quote }}
31+
{{- end }}

deploy/helm/ifrcgo-helm/templates/config/secret.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,8 @@ stringData:
5454
AZURE_OPENAI_DEPLOYMENT_NAME: "{{ .Values.env.AZURE_OPENAI_DEPLOYMENT_NAME}}"
5555
AZURE_OPENAI_ENDPOINT: "{{ .Values.env.AZURE_OPENAI_ENDPOINT}}"
5656
AZURE_OPENAI_API_KEY: "{{ .Values.env.AZURE_OPENAI_API_KEY}}"
57+
58+
# Additional secrets
59+
{{- range $name, $value := .Values.secretsAdditional }}
60+
{{ $name }}: {{ $value | quote }}
61+
{{- end }}

deploy/helm/ifrcgo-helm/values.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,24 @@ env:
6363
AZURE_OPENAI_ENDPOINT: ''
6464
AZURE_OPENAI_API_KEY: ''
6565

66+
# NOTE: Used to pass additional configs to api/worker containers
67+
# NOTE: Not used by azure vault
68+
envAdditional:
69+
# Additional configs
70+
# EXAMPLE: MY_CONFIG: "my-value"
71+
6672
secrets:
6773
API_TLS_CRT: ''
6874
API_TLS_KEY: ''
6975
API_ADDITIONAL_DOMAIN_TLS_CRT: ''
7076
API_ADDITIONAL_DOMAIN_TLS_KEY: ''
7177

78+
# NOTE: Used to pass additional secrets to api/worker containers
79+
# NOTE: Not used by azure vault
80+
secretsAdditional:
81+
# Additional secrets
82+
# EXAMPLE: MY_SECRET: "my-secret-value"
83+
7284
api:
7385
domain: "go-staging.ifrc.org"
7486
tls:
@@ -155,6 +167,8 @@ cronjobs:
155167
schedule: '0 0 * * 0'
156168
- command: 'ingest_icrc'
157169
schedule: '0 3 * * 0'
170+
- command: 'notify_validators'
171+
schedule: '0 0 * * *'
158172

159173

160174
elasticsearch:

local_units/admin.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from django.core.exceptions import ValidationError
44
from reversion_compare.admin import CompareVersionAdmin
55

6+
from dref.admin import ReadOnlyMixin
7+
68
from .models import (
79
Affiliation,
810
BloodService,
@@ -14,6 +16,7 @@
1416
HealthData,
1517
HospitalType,
1618
LocalUnit,
19+
LocalUnitChangeRequest,
1720
LocalUnitLevel,
1821
LocalUnitType,
1922
PrimaryHCC,
@@ -49,6 +52,10 @@ class LocalUnitAdmin(CompareVersionAdmin, admin.OSMGeoAdmin):
4952
"level",
5053
"health",
5154
)
55+
readonly_fields = (
56+
"validated",
57+
"is_locked",
58+
)
5259
list_filter = (
5360
AutocompleteFilterFactory("Country", "country"),
5461
AutocompleteFilterFactory("Type", "type"),
@@ -64,6 +71,36 @@ def save_model(self, request, obj, form, change):
6471
super().save_model(request, obj, form, change)
6572

6673

74+
@admin.register(LocalUnitChangeRequest)
75+
class LocalUnitChangeRequestAdmin(ReadOnlyMixin, admin.ModelAdmin):
76+
autocomplete_fields = (
77+
"local_unit",
78+
"triggered_by",
79+
)
80+
search_fields = (
81+
"local_unit__id",
82+
"local_unit__english_branch_name",
83+
"local_unit__local_branch_name",
84+
)
85+
list_filter = ("status",)
86+
list_display = (
87+
"local_unit",
88+
"status",
89+
"current_validator",
90+
)
91+
ordering = ("id",)
92+
93+
def get_queryset(self, request):
94+
return (
95+
super()
96+
.get_queryset(request)
97+
.select_related(
98+
"local_unit",
99+
"triggered_by",
100+
)
101+
)
102+
103+
67104
@admin.register(DelegationOffice)
68105
class DelegationOfficeAdmin(admin.OSMGeoAdmin):
69106
search_fields = ("name", "city", "country__name")

local_units/dev_views.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from django.http import HttpResponse
2+
from django.template import loader
3+
from rest_framework import permissions
4+
from rest_framework.views import APIView
5+
6+
7+
class LocalUnitsEmailPreview(APIView):
8+
permission_classes = [permissions.IsAuthenticated]
9+
10+
def get(self, request):
11+
type_param = request.GET.get("type")
12+
param_types = {"new", "update", "validate", "revert", "deprecate", "regional_validator", "global_validator"}
13+
14+
if type_param not in param_types:
15+
return HttpResponse(f"Invalid type parameter. Please use one of the following values: {', '.join(param_types)}.")
16+
17+
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"},
25+
}
26+
27+
context = context_mapping.get(type_param)
28+
if context is None:
29+
return HttpResponse("No context found for the email preview.")
30+
31+
context["local_branch_name"] = "Test Local Branch"
32+
template = loader.get_template("email/local_units/local_unit.html")
33+
return HttpResponse(template.render(context, request))

local_units/enums.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from . import models
2+
3+
enum_register = {
4+
"deprecate_reason": models.LocalUnit.DeprecateReason,
5+
"validation_status": models.LocalUnitChangeRequest.Status,
6+
"validators": models.Validator,
7+
}

local_units/filterset.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class Meta:
1313
"type__code",
1414
"draft",
1515
"validated",
16+
"is_locked",
1617
)
1718

1819

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import logging
2+
3+
from django.contrib.auth.models import Group, Permission
4+
from django.contrib.contenttypes.models import ContentType
5+
from django.core.management.base import BaseCommand
6+
7+
from local_units.models import LocalUnit
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class Command(BaseCommand):
13+
help = "Create standard local unit global validator permission class and group"
14+
15+
def handle(self, *args, **options):
16+
logger.info("Creating/Updating permissions/groups for local unit global validator")
17+
print("- Creating/Updating permissions/groups for local unit global validator")
18+
codename = "local_unit_global_validator"
19+
content_type = ContentType.objects.get_for_model(LocalUnit)
20+
permission, created = Permission.objects.get_or_create(
21+
codename=codename,
22+
name="Local Unit Global Validator",
23+
content_type=content_type,
24+
)
25+
26+
# If it's a new permission, create a group for it
27+
group, created = Group.objects.get_or_create(name="Local Unit Global Validators")
28+
group.permissions.add(permission)
29+
logger.info("Local unit global validator permission and group created")
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from datetime import timedelta
2+
3+
from django.core.management.base import BaseCommand
4+
from django.template.loader import render_to_string
5+
from django.utils import timezone
6+
from sentry_sdk.crons import monitor
7+
8+
from local_units.models import LocalUnit, Validator
9+
from local_units.utils import (
10+
get_email_context,
11+
get_global_validators,
12+
get_region_admins,
13+
)
14+
from main.sentry import SentryMonitor
15+
from notifications.notification import send_notification
16+
17+
18+
class Command(BaseCommand):
19+
help = "Notify validators for the pending local units in different period of time"
20+
21+
@monitor(monitor_slug=SentryMonitor.NOTIFY_VALIDATORS)
22+
def handle(self, *args, **options):
23+
self.stdout.write(self.style.NOTICE("Notifying the validators..."))
24+
25+
# Regional Validators: 14 days
26+
queryset_for_regional_validators = LocalUnit.objects.filter(
27+
validated=False,
28+
is_deprecated=False,
29+
last_sent_validator_type=Validator.LOCAL,
30+
created_at__lte=timezone.now() - timedelta(days=14),
31+
)
32+
33+
# Global Validators: 28 days
34+
queryset_for_global_validators = LocalUnit.objects.filter(
35+
validated=False,
36+
is_deprecated=False,
37+
last_sent_validator_type=Validator.REGIONAL,
38+
created_at__lte=timezone.now() - timedelta(days=28),
39+
)
40+
41+
for local_unit in queryset_for_regional_validators:
42+
self.stdout.write(self.style.NOTICE(f"Notifying regional validators for local unit pk:({local_unit.id})"))
43+
email_context = get_email_context(local_unit)
44+
email_context["is_validator_regional_admin"] = True
45+
email_subject = "Action Required: Local Unit Pending Validation"
46+
email_type = "Local Unit"
47+
48+
for region_admin_validator in get_region_admins(local_unit):
49+
try:
50+
email_context["full_name"] = region_admin_validator.get_full_name()
51+
email_body = render_to_string("email/local_units/local_unit.html", email_context)
52+
send_notification(email_subject, region_admin_validator.email, email_body, email_type)
53+
local_unit.last_sent_validator_type = Validator.REGIONAL
54+
local_unit.save(update_fields=["last_sent_validator_type"])
55+
except Exception as e:
56+
self.stdout.write(
57+
self.style.WARNING(
58+
f"Failed to notify regional validator {region_admin_validator.get_full_name()} for local unit pk:({local_unit.id}): {e}" # noqa
59+
)
60+
)
61+
continue
62+
63+
for local_unit in queryset_for_global_validators:
64+
self.stdout.write(self.style.NOTICE(f"Notifying global validators for local unit pk:({local_unit.id})"))
65+
email_context = get_email_context(local_unit)
66+
email_context["is_validator_global_admin"] = True
67+
email_subject = "Action Required: Local Unit Pending Validation"
68+
email_type = "Local Unit"
69+
70+
for global_validator in get_global_validators():
71+
try:
72+
email_context["full_name"] = global_validator.get_full_name()
73+
email_body = render_to_string("email/local_units/local_unit.html", email_context)
74+
send_notification(email_subject, global_validator.email, email_body, email_type)
75+
local_unit.last_sent_validator_type = Validator.GLOBAL
76+
local_unit.save(update_fields=["last_sent_validator_type"])
77+
except Exception as e:
78+
self.stdout.write(
79+
self.style.WARNING(
80+
f"Failed to notify global validator {global_validator.get_full_name()} for local unit pk:({local_unit.id}): {e}" # noqa
81+
)
82+
)
83+
continue
84+
85+
self.stdout.write(self.style.SUCCESS("Successfully sent the notifications to the validators"))
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Generated by Django 4.2.16 on 2024-12-11 09:28
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
("local_units", "0017_alter_healthdata_other_medical_heal"),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="localunit",
18+
name="deprecated_reason",
19+
field=models.IntegerField(
20+
blank=True,
21+
choices=[
22+
(1, "Non-existent local unit"),
23+
(2, "Incorrectly added local unit"),
24+
(3, "Security concerns"),
25+
(4, "Other"),
26+
],
27+
null=True,
28+
verbose_name="deprecated reason",
29+
),
30+
),
31+
migrations.AddField(
32+
model_name="localunit",
33+
name="deprecated_reason_overview",
34+
field=models.TextField(blank=True, null=True, verbose_name="Explain the reason why the local unit is being deleted"),
35+
),
36+
migrations.AddField(
37+
model_name="localunit",
38+
name="is_deprecated",
39+
field=models.BooleanField(default=False, verbose_name="Is deprecated?"),
40+
),
41+
migrations.AddField(
42+
model_name="localunit",
43+
name="is_locked",
44+
field=models.BooleanField(default=False, verbose_name="Is locked?"),
45+
),
46+
migrations.CreateModel(
47+
name="LocalUnitChangeRequest",
48+
fields=[
49+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
50+
("previous_data", models.JSONField(default=dict, verbose_name="Previous data")),
51+
(
52+
"status",
53+
models.IntegerField(
54+
choices=[(1, "Pending"), (2, "Approved"), (3, "Revert")], default=1, verbose_name="status"
55+
),
56+
),
57+
(
58+
"current_validator",
59+
models.IntegerField(
60+
choices=[(1, "Local"), (2, "Regional"), (3, "Global")], default=1, verbose_name="Current validator"
61+
),
62+
),
63+
("triggered_at", models.DateTimeField(auto_now_add=True, verbose_name="Triggered at")),
64+
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated at")),
65+
("rejected_data", models.JSONField(default=dict, verbose_name="Rejected data")),
66+
("rejected_reason", models.TextField(blank=True, null=True, verbose_name="Rejected reason")),
67+
(
68+
"local_unit",
69+
models.ForeignKey(
70+
on_delete=django.db.models.deletion.CASCADE,
71+
related_name="local_unit_change_request",
72+
to="local_units.localunit",
73+
verbose_name="Local Unit",
74+
),
75+
),
76+
(
77+
"triggered_by",
78+
models.ForeignKey(
79+
null=True,
80+
on_delete=django.db.models.deletion.SET_NULL,
81+
related_name="tiggered_by_local_unit",
82+
to=settings.AUTH_USER_MODEL,
83+
verbose_name="triggered by",
84+
),
85+
),
86+
(
87+
"updated_by",
88+
models.ForeignKey(
89+
null=True,
90+
on_delete=django.db.models.deletion.SET_NULL,
91+
related_name="updated_by_local_unit",
92+
to=settings.AUTH_USER_MODEL,
93+
verbose_name="updated by",
94+
),
95+
),
96+
],
97+
options={
98+
"ordering": ("id",),
99+
},
100+
),
101+
]

0 commit comments

Comments
 (0)