Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
fe4709a
feat(eap): Add DevelopmentRegistration EAP model
susilnem Nov 4, 2025
7b662d8
feat(eap): Add DevelopmentRegistrationEAP Endpoint
susilnem Nov 4, 2025
4dd13d0
feat(eap): Add EAP type and status for EAP Registration
susilnem Nov 5, 2025
c6737a3
chore(eap): Remove disaster type and national society filters from admin
susilnem Nov 5, 2025
9fc9cc8
chore(eap): Add eap enums in global enums
susilnem Nov 5, 2025
435d83c
feat(eap): Add Simplified EAP model
susilnem Nov 5, 2025
a72e4c7
feat(eap): Add Base Model and serializer
susilnem Nov 6, 2025
3b05423
feat(eap): Add simplified model, operational, actions
susilnem Nov 6, 2025
3a7b866
feat(eap): Add test cases for eap registration and simplified
susilnem Nov 7, 2025
ce643ff
feat(eap): Add Simplified Admin, FilterSet, Status update endpoints
susilnem Nov 8, 2025
d393357
feat(eap): Add validations, multiple file upload
susilnem Nov 11, 2025
31b4c29
feat(eap): Add status transition validations and permissions
susilnem Nov 12, 2025
a41bf66
feat(eap): Add status transition, timeline and validated budget file
susilnem Nov 13, 2025
82ca4f0
feat(eap): Upload review checklist and active-eap endpoint
susilnem Nov 14, 2025
9a4d4e4
feat(eap): Add snapshot feature on simplified eap
susilnem Nov 19, 2025
0023216
feat(eap): Add snapshot feature and validation checks on status update
susilnem Nov 20, 2025
059c533
feat(eap): add simplified eap to global pdf export
sudip-khanal Nov 19, 2025
e4784aa
feat(eap): Add validation on operation timeframe and time_value
susilnem Nov 25, 2025
3775eef
feat(eap): update schema on updating eap file instance
susilnem Nov 25, 2025
1424f72
feat(eap): add full eap model
sudip-khanal Nov 20, 2025
bd31384
feat(eap): Update changes on Full EAP
susilnem Nov 21, 2025
2fd6280
chore(eap): Update filters on eap and update migration file
susilnem Nov 24, 2025
946805e
feat(full_eap): Add snapshot feature and update on active EAPs
susilnem Nov 24, 2025
88542e2
feat(full-eap): Add test cases for full-eap
susilnem Nov 25, 2025
1642021
fix(eap): Update test cases for simplified eap generate pdf
susilnem Nov 26, 2025
c3edbf0
feat(eap): Add full eap export pdf
susilnem Nov 26, 2025
8c0c957
feat(eap): Update full eap fields and add new fields
susilnem Nov 26, 2025
a949202
feat(eap): add test cases for full eap, snapshot, active-eap
susilnem Nov 27, 2025
cf28616
Merge pull request #2595 from IFRCGo/feat/add-full-eap-model
susilnem Dec 3, 2025
5b69d2b
chore(assest): Update asset commit head
susilnem Dec 4, 2025
5a61f9b
feat(full-eap): Add new fields on full eap
susilnem Dec 5, 2025
eb939dd
feat(full-eap): Add new status and update on status transition
susilnem Dec 10, 2025
69ac5e3
feat(full-eap): Add new field forecast table file
susilnem Dec 10, 2025
0dc73fe
chore(eap): Update on active eaps endpoint
susilnem Dec 11, 2025
b0b9289
feat(eap): Add multiple validation checks for files
susilnem Dec 12, 2025
0dc85f3
fix(eap-export): Update Export url for EAP
susilnem Dec 4, 2025
41251c9
feat(eap): Add diff and version tracking for pdf export
susilnem Dec 5, 2025
b9cd852
feat(eap): Update on Export url for eaps
susilnem Dec 12, 2025
1a24178
fix(eap): typing issue on eap actiona and source information
susilnem Dec 12, 2025
28e59da
fix(eap): Replace update checklist file to EAPFile
susilnem Dec 15, 2025
5b7ee80
Merge pull request #2605 from IFRCGo/feature/add-new-field-full-eap
susilnem Dec 12, 2025
d5616b4
Merge pull request #2606 from IFRCGo/fix/export-url-eap
susilnem Dec 15, 2025
cc5a111
fix(eap): Update export url on eap
susilnem Dec 15, 2025
6944ec2
chore(fulleap): Remove fields from fulleap model (#2614)
susilnem Dec 19, 2025
0a7e2fe
chore(eap-registration): Update fields on eap registration
susilnem Dec 19, 2025
8b87dc0
EAP: Add api to download template files (#2619)
sudip-khanal Dec 29, 2025
812c030
feat(eap): Add diff file and summary file for eap
susilnem Jan 5, 2026
287ee00
refactor(export): Decoupling pdf export of playwright
susilnem Jan 5, 2026
090c287
feat(eap): Add export file generation and retrigger action on adminpanel
susilnem Jan 6, 2026
e76a49b
feat(eap): Add previous_id feature on snapshot creation
susilnem Jan 7, 2026
307200f
Merge pull request #2623 from IFRCGo/feature/eap-export-pdf-generation
susilnem Jan 8, 2026
d31cd06
EAP: email notification setup (#2624)
sudip-khanal Jan 9, 2026
a490607
chore(eap): Update typings on registration and eaps (#2626)
susilnem Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions api/migrations/0227_alter_export_export_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-11-18 05:22

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0226_nsdinitiativescategory_and_more'),
]

operations = [
migrations.AlterField(
model_name='export',
name='export_type',
field=models.CharField(choices=[('dref-applications', 'DREF Application'), ('dref-operational-updates', 'DREF Operational Update'), ('dref-final-reports', 'DREF Final Report'), ('old-dref-final-reports', 'Old DREF Final Report'), ('per', 'Per'), ('simplified-eap', 'Simplified EAP')], max_length=255, verbose_name='Export Type'),
),
]
29 changes: 29 additions & 0 deletions api/migrations/0228_alter_export_export_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 4.2.26 on 2025-11-26 10:03

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("api", "0227_alter_export_export_type"),
]

operations = [
migrations.AlterField(
model_name="export",
name="export_type",
field=models.CharField(
choices=[
("dref-applications", "DREF Application"),
("dref-operational-updates", "DREF Operational Update"),
("dref-final-reports", "DREF Final Report"),
("old-dref-final-reports", "Old DREF Final Report"),
("per", "Per"),
("simplified-eap", "Simplified EAP"),
("full-eap", "Full EAP"),
],
max_length=255,
verbose_name="Export Type",
),
),
]
29 changes: 29 additions & 0 deletions api/migrations/0229_alter_export_export_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 4.2.26 on 2025-12-04 09:06

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("api", "0228_alter_export_export_type"),
]

operations = [
migrations.AlterField(
model_name="export",
name="export_type",
field=models.CharField(
choices=[
("dref-applications", "DREF Application"),
("dref-operational-updates", "DREF Operational Update"),
("dref-final-reports", "DREF Final Report"),
("old-dref-final-reports", "Old DREF Final Report"),
("per", "Per"),
("simplified", "Simplified EAP"),
("full", "Full EAP"),
],
max_length=255,
verbose_name="Export Type",
),
),
]
2 changes: 2 additions & 0 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2560,6 +2560,8 @@ class ExportType(models.TextChoices):
FINAL_REPORT = "dref-final-reports", _("DREF Final Report")
OLD_FINAL_REPORT = "old-dref-final-reports", _("Old DREF Final Report")
PER = "per", _("Per")
SIMPLIFIED_EAP = "simplified", _("Simplified EAP")
FULL_EAP = "full", _("Full EAP")

export_id = models.IntegerField(verbose_name=_("Export Id"))
export_type = models.CharField(verbose_name=_("Export Type"), max_length=255, choices=ExportType.choices)
Expand Down
121 changes: 121 additions & 0 deletions api/playwright.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import json
import pathlib
import tempfile
import time

from django.conf import settings
from django.core.files.base import ContentFile
from playwright.sync_api import sync_playwright

from .utils import DebugPlaywright

footer_template = """
<div class="footer" style="width: 100%;font-size: 8px;color: #FEFEFE; bottom: 10px; position: absolute;">
<div style="float: left; margin-top: 10px; margin-left: 40px;">
Page <span class="pageNumber"></span> / <span class="totalPages"></span>
</div>
<div style="float: right; margin-right: 40px;">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 89.652 89.654"
height="48"
width="48"
>
<path
d="M50.284 18.637a5.14 5.14 0 00-5.136-5.135 5.139 5.139 0 00-5.135 5.135 5.141 5.141 0 005.135 5.138 5.146 5.146 0 005.136-5.138M28.416 63.032a5.143 5.143 0 00-5.138 5.138 5.14 5.14 0 005.138 5.133 5.14 5.14 0 005.136-5.133 5.143 5.143 0 00-5.136-5.138M45.151 34.057a7.021 7.021 0 00-7.02 7.025 7.02 7.02 0 0014.04 0 7.021 7.021 0 00-7.02-7.025M61.883 63.032a5.143 5.143 0 00-5.135 5.138 5.138 5.138 0 005.135 5.133 5.14 5.14 0 005.136-5.133 5.143 5.143 0 00-5.136-5.138"
class="st1"
fill="#F5333F"
/>
<path
d="M61.883 75.769c-4.19 0-7.601-3.41-7.601-7.602 0-2.32 1.05-4.4 2.696-5.794L49.726 50.26a10.205 10.205 0 01-4.575 1.085c-1.648 0-3.196-.397-4.577-1.085l-7.252 12.113a7.571 7.571 0 012.693 5.794c0 4.191-3.408 7.602-7.599 7.602-4.19 0-7.601-3.41-7.601-7.602 0-4.19 3.41-7.601 7.601-7.601.984 0 1.926.196 2.791.54l7.303-12.2a10.236 10.236 0 01-3.63-7.827c0-5.254 3.947-9.58 9.038-10.189v-4.762c-3.606-.59-6.368-3.72-6.368-7.49 0-4.192 3.41-7.602 7.601-7.602s7.599 3.41 7.599 7.601c0 3.77-2.762 6.9-6.366 7.49v4.763c5.093.611 9.038 4.935 9.038 10.19a10.23 10.23 0 01-3.633 7.826l7.306 12.2a7.544 7.544 0 012.791-.54c4.191 0 7.599 3.41 7.599 7.601s-3.41 7.602-7.602 7.602m-49.286-34.65c0-5.485 3.44-10.057 9.194-10.057 4.194 0 7.715 2.236 8.226 6.562h-3.281c-.32-2.524-2.524-3.818-4.945-3.818-4.117 0-5.834 3.627-5.834 7.313s1.717 7.313 5.834 7.313c3.44.056 5.32-2.016 5.376-5.268h-5.106v-2.556h8.173v10.11h-2.151l-.51-2.257c-1.803 2.043-3.44 2.715-5.78 2.715-5.754 0-9.196-4.57-9.196-10.057M44.826 0C20.07 0 0 20.069 0 44.828c0 24.755 20.071 44.826 44.826 44.826 24.757 0 44.826-20.071 44.826-44.826C89.652 20.068 69.582 0 44.826 0"
class="st1"
fill="#F5333F"
/>
</svg>
</div>
</div>
""" # noqa


def build_storage_state(tmp_dir, user, token, language="en"):
temp_file = pathlib.Path(tmp_dir, "storage_state.json")
temp_file.touch()

state = {
"origins": [
{
"origin": settings.GO_WEB_INTERNAL_URL + "/",
"localStorage": [
{
"name": "user",
"value": json.dumps(
{
"id": user.id,
"username": user.username,
"firstName": user.first_name,
"lastName": user.last_name,
"token": token.key,
}
),
},
{"name": "language", "value": json.dumps(language)},
],
}
]
}
with open(temp_file, "w") as f:
json.dump(state, f)
return temp_file


def render_pdf_from_url(
*,
url: str,
user,
token,
language: str = "en",
timeout: int = 300_000,
):
"""
Renders a URL to PDF using Playwright.
Returns a Django ContentFile.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
storage_state = build_storage_state(
tmp_dir=tmp_dir,
user=user,
token=token,
language=language,
)

with sync_playwright() as playwright:
browser = playwright.chromium.connect(settings.PLAYWRIGHT_SERVER_URL)

try:
context = browser.new_context(storage_state=storage_state)
page = context.new_page()

if settings.DEBUG_PLAYWRIGHT:
DebugPlaywright.debug(page)

page.goto(url, timeout=timeout)
time.sleep(5)
# NOTE: Use wait_for_load_state instead of sleep?
# page.wait_for_load_state("networkidle", timeout=timeout)
page.wait_for_selector(
"#pdf-preview-ready",
state="attached",
timeout=timeout,
)

pdf_bytes = page.pdf(
display_header_footer=True,
prefer_css_page_size=True,
print_background=True,
footer_template=footer_template,
header_template="<p></p>",
)
finally:
browser.close()

return ContentFile(pdf_bytes)
71 changes: 66 additions & 5 deletions api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
from rest_framework import serializers

# from api.utils import pdf_exporter
from api.tasks import generate_url
from api.utils import CountryValidator, RegionValidator
from api.tasks import generate_export_pdf
from api.utils import CountryValidator, RegionValidator, generate_eap_export_url
from deployments.models import EmergencyProject, Personnel, PersonnelDeployment
from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate
from eap.models import EAPRegistration, FullEAP, SimplifiedEAP
from lang.models import String
from lang.serializers import ModelSerializer
from local_units.models import DelegationOffice
Expand Down Expand Up @@ -2543,6 +2544,13 @@ class ExportSerializer(serializers.ModelSerializer):
status_display = serializers.CharField(source="get_status_display", read_only=True)
# NOTE: is_pga is used to determine if the export contains PGA or not
is_pga = serializers.BooleanField(default=False, required=False, write_only=True)
# NOTE: diff is used to determine if the export is requested for diff view or not
# Currently only used for EAP exports
diff = serializers.BooleanField(default=False, required=False, write_only=True, help_text="Only applicable for EAP exports")
# NOTE: Version of a EAP export being requested, only applicable for full and simplified EAP exports
version = serializers.IntegerField(required=False, write_only=True, help_text="Only applicable for EAP exports")
# NOTE: Only for FUll eap export
summary = serializers.BooleanField(default=False, required=False, write_only=True, help_text="Only applicable for FUll EAP")

class Meta:
model = Export
Expand All @@ -2554,10 +2562,12 @@ def validate_pdf_file(self, pdf_file):
return pdf_file

def create(self, validated_data):
language = django_get_language()
export_id = validated_data.get("export_id")
export_type = validated_data.get("export_type")
country_id = validated_data.get("per_country")
version = validated_data.pop("version", None)
diff = validated_data.pop("diff", False)
summary = validated_data.pop("summary", False)
if export_type == Export.ExportType.DREF:
title = Dref.objects.filter(id=export_id).first().title
elif export_type == Export.ExportType.OPS_UPDATE:
Expand All @@ -2567,17 +2577,67 @@ def create(self, validated_data):
elif export_type == Export.ExportType.PER:
overview = Overview.objects.filter(id=export_id).first()
title = f"{overview.country.name}-preparedness-{overview.get_phase_display()}"
elif export_type == Export.ExportType.SIMPLIFIED_EAP:
if version:
simplified_eap = SimplifiedEAP.objects.filter(
eap_registration=export_id,
version=version,
).first()
if not simplified_eap:
raise serializers.ValidationError("No Simplified EAP found for the given EAP Registration ID and version")
else:
eap_registration = EAPRegistration.objects.filter(id=export_id).first()
if not eap_registration:
raise serializers.ValidationError("No EAP Registration found for the given ID")

simplified_eap = eap_registration.latest_simplified_eap
if not simplified_eap:
serializers.ValidationError("No Simplified EAP found for the given EAP Registration ID")

title = (
f"{simplified_eap.eap_registration.national_society.name}-{simplified_eap.eap_registration.disaster_type.name}"
)
elif export_type == Export.ExportType.FULL_EAP:
if version:
full_eap = FullEAP.objects.filter(
eap_registration=export_id,
version=version,
).first()
if not full_eap:
raise serializers.ValidationError("No Full EAP found for the given EAP Registration ID and version")
else:
eap_registration = EAPRegistration.objects.filter(id=export_id).first()
if not eap_registration:
raise serializers.ValidationError("No EAP Registration found for the given ID")

full_eap = eap_registration.latest_full_eap
if not full_eap:
serializers.ValidationError("No Full EAP found for the given EAP Registration ID")

title = f"{full_eap.eap_registration.national_society.name}-{full_eap.eap_registration.disaster_type.name}"
else:
title = "Export"
user = self.context["request"].user

if export_type == Export.ExportType.PER:
validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/countries/{country_id}/{export_type}/{export_id}/export/"

elif export_type in [
Export.ExportType.SIMPLIFIED_EAP,
Export.ExportType.FULL_EAP,
]:
validated_data["url"] = generate_eap_export_url(
registration_id=export_id,
version=version,
diff=diff,
summary=summary,
)

else:
validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/{export_type}/{export_id}/export/"

# Adding is_pga to the url
is_pga = validated_data.pop("is_pga")
is_pga = validated_data.pop("is_pga", False)
if is_pga:
validated_data["url"] += "?is_pga=true"
validated_data["requested_by"] = user
Expand All @@ -2587,7 +2647,8 @@ def create(self, validated_data):
export.requested_at = timezone.now()
export.save(update_fields=["status", "requested_at"])

transaction.on_commit(lambda: generate_url.delay(export.url, export.id, user.id, title, language))
language = django_get_language()
transaction.on_commit(lambda: generate_export_pdf.delay(export.id, title, language))
return export

def update(self, instance, validated_data):
Expand Down
Loading
Loading