diff --git a/src/flightforms/fillers/_datetime.py b/src/flightforms/fillers/_datetime.py new file mode 100644 index 0000000..e219f5e --- /dev/null +++ b/src/flightforms/fillers/_datetime.py @@ -0,0 +1,24 @@ +"""Date/time conversion shared by the form fillers. + +Requests always carry UTC. A form that prints the airport's wall clock +("Heure Locale") declares ``time_reference: "local"`` and a ``time_zone`` +in its mapping. +""" + +from datetime import datetime +from zoneinfo import ZoneInfo + + +def utc_to_local(date_str: str, time_str: str, tz_name: str) -> tuple[str, str]: + """Convert a YYYY-MM-DD / HH:MM pair from UTC to *tz_name*. + + Date and time must convert together: a UTC evening can land on the next + day locally (23:50Z on 1 June is 01:50 on 2 June in Paris), so printing a + converted time against the original UTC date would misdate the flight by + a day on a customs pre-notification. + + The date comes back as YYYY-MM-DD, for the caller to format. + """ + dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M") + local = dt.replace(tzinfo=ZoneInfo("UTC")).astimezone(ZoneInfo(tz_name)) + return local.strftime("%Y-%m-%d"), local.strftime("%H:%M") diff --git a/src/flightforms/fillers/french_customs_filler.py b/src/flightforms/fillers/french_customs_filler.py index e7b4510..922fb20 100644 --- a/src/flightforms/fillers/french_customs_filler.py +++ b/src/flightforms/fillers/french_customs_filler.py @@ -8,12 +8,12 @@ from datetime import datetime from io import BytesIO from pathlib import Path -from zoneinfo import ZoneInfo from pypdf import PdfReader, PdfWriter from ..api.models import GenerateRequest from ..registry import FormMapping +from ._datetime import utc_to_local def _parse_date(date_str: str, fmt: str) -> str: @@ -21,14 +21,6 @@ def _parse_date(date_str: str, fmt: str) -> str: return dt.strftime(fmt) -def _utc_to_local(time_str: str, date_str: str, tz_name: str) -> str: - """Convert HH:MM UTC to local time in the given timezone.""" - dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M") - dt_utc = dt.replace(tzinfo=ZoneInfo("UTC")) - dt_local = dt_utc.astimezone(ZoneInfo(tz_name)) - return dt_local.strftime("%H:%M") - - def _suffix(index: int) -> str: """Row 0 = no suffix, row 1 = '_2', row 2 = '_3', etc.""" if index == 0: @@ -51,15 +43,20 @@ def fill_french_customs( observations = request.observations or mapping.default_observations or "" - # Use local time conversion only when time_reference is "local" + # Use local time conversion only when time_reference is "local". The date + # converts with the time: a late-evening UTC slot falls on the next day + # locally, and the form would otherwise print that local time against the + # UTC date. if mapping.time_reference == "local" and mapping.time_zone: - dep_time = _utc_to_local( - request.flight.departure_time_utc, request.flight.departure_date, mapping.time_zone + dep_date, dep_time = utc_to_local( + request.flight.departure_date, request.flight.departure_time_utc, mapping.time_zone ) - arr_time = _utc_to_local( - request.flight.arrival_time_utc, request.flight.arrival_date, mapping.time_zone + arr_date, arr_time = utc_to_local( + request.flight.arrival_date, request.flight.arrival_time_utc, mapping.time_zone ) else: + dep_date = request.flight.departure_date + arr_date = request.flight.arrival_date dep_time = request.flight.departure_time_utc arr_time = request.flight.arrival_time_utc @@ -78,8 +75,8 @@ def fill_french_customs( "destination": request.flight.destination, "registration": request.aircraft.registration, "aircraft_type": request.aircraft.type, - "departure_date": _parse_date(request.flight.departure_date, mapping.date_format), - "arrival_date": _parse_date(request.flight.arrival_date, mapping.date_format), + "departure_date": _parse_date(dep_date, mapping.date_format), + "arrival_date": _parse_date(arr_date, mapping.date_format), "departure_time": dep_time, "arrival_time": arr_time, "contact": contact, diff --git a/src/flightforms/fillers/pdf_filler.py b/src/flightforms/fillers/pdf_filler.py index 0dfa9f0..3acf991 100644 --- a/src/flightforms/fillers/pdf_filler.py +++ b/src/flightforms/fillers/pdf_filler.py @@ -8,10 +8,17 @@ from pathlib import Path from pypdf import PdfReader, PdfWriter -from pypdf.generic import NameObject +from pypdf.generic import ( + ArrayObject, + DecodedStreamObject, + DictionaryObject, + FloatObject, + NameObject, +) from ..api.models import GenerateRequest from ..registry import FormMapping +from ._datetime import utc_to_local # Characters that don't decompose via NFKD but have obvious Latin base letters. @@ -89,15 +96,28 @@ def fill_pdf( # Build values dict for simple fields observations = request.observations or mapping.default_observations or "" + # Request dates and times are always UTC. Forms that ask for the + # airport's wall clock ("Heure Locale") declare time_reference "local" + # plus a time_zone; every date/time pair below goes through _when() so the + # whole form stays in one reference — and so a date never gets printed + # against a time from the other side of midnight. + def _when(date_str: str, time_str: str) -> tuple[str, str]: + if mapping.time_reference == "local" and mapping.time_zone: + return utc_to_local(date_str, time_str, mapping.time_zone) + return date_str, time_str + + dep_date, dep_time = _when(request.flight.departure_date, request.flight.departure_time_utc) + arr_date, arr_time = _when(request.flight.arrival_date, request.flight.arrival_time_utc) + # Direction-aware date/time: resolves to arrival or departure based on direction - local_date = request.flight.arrival_date if is_arrival else request.flight.departure_date - local_time = request.flight.arrival_time_utc if is_arrival else request.flight.departure_time_utc + local_date = arr_date if is_arrival else dep_date + local_time = arr_time if is_arrival else dep_time values = { - "flight.departure_date": _parse_date(request.flight.departure_date, mapping.date_format), - "flight.arrival_date": _parse_date(request.flight.arrival_date, mapping.date_format), - "flight.departure_time_utc": request.flight.departure_time_utc, - "flight.arrival_time_utc": request.flight.arrival_time_utc, + "flight.departure_date": _parse_date(dep_date, mapping.date_format), + "flight.arrival_date": _parse_date(arr_date, mapping.date_format), + "flight.departure_time_utc": dep_time, + "flight.arrival_time_utc": arr_time, "flight.date": _parse_date(local_date, mapping.date_format), "flight.time": local_time, "flight.origin": request.flight.origin, @@ -125,7 +145,7 @@ def fill_pdf( # page with differently-named fields (e.g. gendec + passenger manifest). "manifest.operator": request.aircraft.owner or "", "manifest.registration": request.aircraft.registration, - "manifest.date": _parse_date(request.flight.departure_date, mapping.date_format), + "manifest.date": _parse_date(dep_date, mapping.date_format), "airport.name": airport_resolver.get_name(request.airport), "airport.icao": request.airport, # Direction-dependent text marks (e.g. "X" on the right side) @@ -138,21 +158,29 @@ def fill_pdf( # arrival/departure sections fill only the relevant side. if is_arrival: values.update({ - "arrival.date": _parse_date(request.flight.arrival_date, mapping.date_format), - "arrival.time": request.flight.arrival_time_utc, + "arrival.date": _parse_date(arr_date, mapping.date_format), + "arrival.time": arr_time, "arrival.registration": request.aircraft.registration, "arrival.type": request.aircraft.type, "arrival.owner": request.aircraft.owner or "", "arrival.nature": request.flight.nature, + # The other end of the leg (where the flight came from) + "arrival.remote": remote_icao, + "arrival.remote_name": airport_resolver.get_name(remote_icao), + "arrival.remote_country": remote_country, }) else: values.update({ - "departure.date": _parse_date(request.flight.departure_date, mapping.date_format), - "departure.time": request.flight.departure_time_utc, + "departure.date": _parse_date(dep_date, mapping.date_format), + "departure.time": dep_time, "departure.registration": request.aircraft.registration, "departure.type": request.aircraft.type, "departure.owner": request.aircraft.owner or "", "departure.nature": request.flight.nature, + # The other end of the leg (where the flight is going) + "departure.remote": remote_icao, + "departure.remote_name": airport_resolver.get_name(remote_icao), + "departure.remote_country": remote_country, }) # Add extra fields @@ -172,10 +200,12 @@ def fill_pdf( cf = request.connecting_flight values["connecting.origin"] = cf.origin values["connecting.destination"] = cf.destination - values["connecting.departure_date"] = _parse_date(cf.departure_date, mapping.date_format) - values["connecting.departure_time_utc"] = cf.departure_time_utc - values["connecting.arrival_date"] = _parse_date(cf.arrival_date, mapping.date_format) - values["connecting.arrival_time_utc"] = cf.arrival_time_utc + cf_dep_date, cf_dep_time = _when(cf.departure_date, cf.departure_time_utc) + cf_arr_date, cf_arr_time = _when(cf.arrival_date, cf.arrival_time_utc) + values["connecting.departure_date"] = _parse_date(cf_dep_date, mapping.date_format) + values["connecting.departure_time_utc"] = cf_dep_time + values["connecting.arrival_date"] = _parse_date(cf_arr_date, mapping.date_format) + values["connecting.arrival_time_utc"] = cf_arr_time # Airport-centric leg values: for forms that show both an arrival and a # departure section at the target airport (e.g. Jersey GenDec). The @@ -184,25 +214,27 @@ def fill_pdf( if is_arrival: values["airport.arrival.from"] = request.flight.origin values["airport.arrival.from_name"] = airport_resolver.get_name(request.flight.origin) - values["airport.arrival.date"] = _parse_date(request.flight.arrival_date, mapping.date_format) - values["airport.arrival.time"] = request.flight.arrival_time_utc + values["airport.arrival.date"] = _parse_date(arr_date, mapping.date_format) + values["airport.arrival.time"] = arr_time if request.connecting_flight: cf = request.connecting_flight values["airport.departure.to"] = cf.destination values["airport.departure.to_name"] = airport_resolver.get_name(cf.destination) - values["airport.departure.date"] = _parse_date(cf.departure_date, mapping.date_format) - values["airport.departure.time"] = cf.departure_time_utc + cf_date, cf_time = _when(cf.departure_date, cf.departure_time_utc) + values["airport.departure.date"] = _parse_date(cf_date, mapping.date_format) + values["airport.departure.time"] = cf_time else: values["airport.departure.to"] = request.flight.destination values["airport.departure.to_name"] = airport_resolver.get_name(request.flight.destination) - values["airport.departure.date"] = _parse_date(request.flight.departure_date, mapping.date_format) - values["airport.departure.time"] = request.flight.departure_time_utc + values["airport.departure.date"] = _parse_date(dep_date, mapping.date_format) + values["airport.departure.time"] = dep_time if request.connecting_flight: cf = request.connecting_flight values["airport.arrival.from"] = cf.origin values["airport.arrival.from_name"] = airport_resolver.get_name(cf.origin) - values["airport.arrival.date"] = _parse_date(cf.arrival_date, mapping.date_format) - values["airport.arrival.time"] = cf.arrival_time_utc + cf_date, cf_time = _when(cf.arrival_date, cf.arrival_time_utc) + values["airport.arrival.date"] = _parse_date(cf_date, mapping.date_format) + values["airport.arrival.time"] = cf_time # Fill fields updates = {} @@ -236,13 +268,17 @@ def fill_pdf( updates[pdf_field] = f"/{radio_state}" continue - # Handle enum-to-checkbox (e.g. flight.nature.private) + # Handle enum-to-checkbox (e.g. flight.nature.private). Several enum + # values can share one box via "|" (e.g. flight.nature.private|business + # for a form whose only distinction is passengers vs cargo) — mapping + # them as separate entries would make the later one overwrite the + # earlier one's "on" with "off". if len(parts) == 3 and parts[0] + "." + parts[1] in values: enum_key = parts[0] + "." + parts[1] enum_val = values[enum_key].lower() - check_val = parts[2].lower() + check_vals = parts[2].lower().split("|") on_val = checkbox_on_values.get(pdf_field, mapping.checkbox_on) - updates[pdf_field] = on_val if enum_val == check_val else mapping.checkbox_off + updates[pdf_field] = on_val if enum_val in check_vals else mapping.checkbox_off continue # Handle aircraft.airplane / aircraft.helicopter checkboxes @@ -284,10 +320,12 @@ def fill_pdf( for page in writer.pages: writer.update_page_form_field_values(page, updates, auto_regenerate=flatten) - # Fix auto-size fields: pypdf uses field height as font size instead of - # calculating a size that fits the text width. We rewrite the appearance - # stream for any field whose original /DA had font size 0. - _fix_autosize_fields(writer, updates) + # Draw text that fits: pypdf renders auto-size fields at a flat 12pt, and + # leaves a fixed size alone even when it overflows a short box (which puts + # the baseline below the box, clipping the value). We rebuild the + # appearance for both cases. Sizes come from the template, because filling + # rewrites an inherited auto-size /DA to a concrete "12 Tf". + _fix_text_appearances(writer, updates, _template_font_sizes(reader)) output = BytesIO() writer.write(output) @@ -301,27 +339,75 @@ def fill_pdf( _PADDING = 4 # 2px each side -def _fix_autosize_fields(writer: PdfWriter, updates: dict): - """Rewrite appearance streams for fields whose template DA had font size 0.""" - # Get form-level DA as fallback for fields that inherit it - acroform = writer._root_object.get("/AcroForm") +def _widget_field_name(annot, parent): + """Field name for a widget, looking through to the parent field node.""" + name = annot.get("/T") + if name is None and parent is not None: + name = parent.get("/T") + return name + + +def _template_font_sizes(reader: PdfReader) -> dict: + """Font size each field's /DA asks for in the template; 0 means auto-size. + + Fields whose /DA declares no size at all are left out, so callers skip + them rather than guessing. + + Must be read from the template: filling replaces an inherited auto-size + /DA with a concrete "/Helv 12 Tf" on the field node, so the same lookup + against the filled document would report every field as fixed-size. + """ + acroform = reader.trailer["/Root"].get("/AcroForm") form_da = "" + if acroform is not None: + form_da = acroform.get_object().get("/DA", "") + + sizes: dict[str, float] = {} + for page in reader.pages: + for annot_ref in page.get("/Annots", []) or []: + annot = annot_ref.get_object() + if annot.get("/Subtype") != "/Widget": + continue + parent = annot.get("/Parent") + parent = parent.get_object() if parent is not None else None + name = _widget_field_name(annot, parent) + if not name: + continue + da = annot.get("/DA", "") + if not da and parent is not None: + da = parent.get("/DA", "") + da = da or form_da + match = re.search(r"([\d.]+)\s+Tf\b", da or "") + if match: + sizes[name] = float(match.group(1)) + return sizes + + +def _fix_text_appearances(writer: PdfWriter, updates: dict, template_sizes: dict): + """Rebuild appearance streams for text that would not fit its field box.""" + acroform = writer._root_object.get("/AcroForm") + form_resources = None if acroform: af = acroform.get_object() if hasattr(acroform, "get_object") else acroform - form_da = af.get("/DA", "") + dr = af.get("/DR") + if dr is not None: + form_resources = dr.get_object() for page in writer.pages: annots = page.get("/Annots", []) for annot_ref in annots: annot = annot_ref.get_object() - field_name = annot.get("/T") + + # A widget may carry the field name itself, or be the kid of a + # separate field node that holds /T (how LibreOffice emits some + # fields). Look through to the parent in that case. + parent = annot.get("/Parent") + parent = parent.get_object() if parent is not None else None + field_name = _widget_field_name(annot, parent) if not field_name or field_name not in updates: continue - - # Check if this field's DA specifies auto-size (font size 0) - # Fall back to form-level DA if the annotation has none - da = annot.get("/DA", "") or form_da - if not re.search(r"\b0\s+Tf\b", da): + declared_size = template_sizes.get(field_name) + if declared_size is None: continue text = updates[field_name] @@ -339,24 +425,27 @@ def _fix_autosize_fields(writer: PdfWriter, updates: dict): if field_width <= 0 or field_height <= 0: continue - # Calculate font size that fits the text width, capped at a - # sensible default (Acrobat auto-size typically picks ~12pt for - # standard form fields, never larger than the box allows). + # A fixed size that already fits its box is left as authored. + if 0 < declared_size <= field_height - 2: + continue + + # Calculate font size that fits the text width, capped by the box + # height and by the authored size (auto-size fields cap at 12pt, + # what Acrobat typically picks for a standard form field). usable_width = field_width - _PADDING size_by_width = usable_width / (len(text) * _HELV_AVG_WIDTH_RATIO) - max_size = min(field_height - 2, 12) # cap at 12pt + max_size = min(field_height - 2, declared_size or 12) font_size = min(size_by_width, max_size) font_size = max(font_size, 4) # floor at 4pt - # Rebuild the appearance stream + # Rebuild the appearance stream. Fields authored without one + # (again, common from LibreOffice) get a fresh stream rather than + # being left to whatever the viewer decides to draw. ap = annot.get("/AP") - if not ap or "/N" not in ap: - continue - - stream_obj = ap["/N"].get_object() - try: - data = stream_obj.get_data().decode("latin-1") - except Exception: + stream_obj = ap["/N"].get_object() if ap and "/N" in ap else None + # A button's /N is a dictionary of appearance states keyed by + # value, not a text stream — nothing to re-draw. + if stream_obj is not None and not hasattr(stream_obj, "set_data"): continue # Build a clean appearance stream from scratch. pypdf encodes @@ -384,7 +473,22 @@ def _fix_autosize_fields(writer: PdfWriter, updates: dict): "Q\n" ) - stream_obj.set_data(new_data.encode("latin-1")) + if stream_obj is not None: + stream_obj.set_data(new_data.encode("latin-1")) + else: + stream_obj = DecodedStreamObject() + stream_obj.set_data(new_data.encode("latin-1")) + stream_obj[NameObject("/Type")] = NameObject("/XObject") + stream_obj[NameObject("/Subtype")] = NameObject("/Form") + stream_obj[NameObject("/BBox")] = ArrayObject( + [FloatObject(0), FloatObject(0), + FloatObject(field_width), FloatObject(field_height)] + ) + if form_resources is not None: + stream_obj[NameObject("/Resources")] = form_resources + annot[NameObject("/AP")] = DictionaryObject( + {NameObject("/N"): writer._add_object(stream_obj)} + ) def _fill_person_fields( diff --git a/src/flightforms/mappings/lfrm.json b/src/flightforms/mappings/lfrm.json new file mode 100644 index 0000000..59c0bdc --- /dev/null +++ b/src/flightforms/mappings/lfrm.json @@ -0,0 +1,90 @@ +{ + "icao": "LFRM", + "label": "Préavis Douane (Le Mans Arnage)", + "template": "lfrm_customs.pdf", + "type": "pdf_acroform", + "version": "1.0", + "time_reference": "local", + "time_zone": "Europe/Paris", + "max_crew": 4, + "max_passengers": 7, + "send_to": "preavis-vols-douane-le-mans@douane.finances.gouv.fr", + "email_overrides": { + "LFRM": {"cc": ["codt-idf@douane.finances.gouv.fr"]} + }, + "default_observations": "Rien à déclarer", + "date_format": "%d/%m/%Y", + "checkbox_on": "X", + "checkbox_off": "", + "has_connecting_flight": false, + "extra_fields": [], + "required_fields": { + "flight": ["origin", "destination", "departure_date", "departure_time_utc", "arrival_date", "arrival_time_utc"], + "aircraft": ["registration", "type"], + "crew": ["first_name", "last_name", "nationality", "id_number"], + "passengers": ["first_name", "last_name", "nationality", "id_number"] + }, + "field_map": { + "direction.arrival_mark": "DEMANDE ARRIVEE ARRIVAL REQUEST", + "direction.departure_mark": "DEMANDE DEPART DEPARTURE REQUEST", + + "arrival.date": "ARRIVAL_DATE", + "arrival.time": "ARRIVAL_LOCAL_TIME", + "arrival.owner": "ARRIVAL_OWNER", + "arrival.registration": "ARRIVAL_REGISTRATION", + "arrival.type": "ARRIVAL_AIRCRAFT_TYPE", + "arrival.remote": "ARRIVAL_ORIGIN", + "arrival.remote_name": "ARRIVAL_AIRPORT", + "arrival.remote_country": "ARRIVAL_COUNTRY", + "arrival.nature": "ARRIVAL_FLIGHT_TYPE", + + "departure.date": "DEPARTURE_DATE", + "departure.time": "DEPARTURE_LOCAL_TIME", + "departure.owner": "DEPARTURE_OWNER", + "departure.registration": "DEPARTURE_REGISTRATION", + "departure.type": "DEPARTURE_AIRCRAFT_TYPE", + "departure.remote": "DEPARTURE_ORIGIN", + "departure.remote_name": "DEPARTURE_AIRPORT", + "departure.remote_country": "DEPARTURE_COUNTRY", + "departure.nature": "DEPARTURE_FLIGHT_TYPE", + + "flight.nature.private|business|commercial|other": "PASSAGERS PASSENGERS", + "flight.nature.fret": "FRET CARGO", + + "flight.observations": "OBSERVATIONS", + + "crew[{i}].last_name": "NOM NAMERow{n}", + "crew[{i}].first_name": "PRENOM FIRST NAMERow{n}", + "crew[{i}].nationality": "NATIONALITE NATIONALITYRow{n}", + "crew[{i}].id_number": "N PASSEPORTCI PASSPORTRow{n}", + + "passengers[0].last_name": "NOM NAMERow1_2", + "passengers[0].first_name": "PRENOM FIRST NAMERow1_2", + "passengers[0].nationality": "NATIONALITE NATIONALITYRow1_2", + "passengers[0].id_number": "N PASSEPORTCI PASSPORTRow1_2", + "passengers[1].last_name": "NOM NAMERow2_2", + "passengers[1].first_name": "PRENOM FIRST NAMERow2_2", + "passengers[1].nationality": "NATIONALITE NATIONALITYRow2_2", + "passengers[1].id_number": "N PASSEPORTCI PASSPORTRow2_2", + "passengers[2].last_name": "NOM NAMERow3_2", + "passengers[2].first_name": "PRENOM FIRST NAMERow3_2", + "passengers[2].nationality": "NATIONALITE NATIONALITYRow3_2", + "passengers[2].id_number": "N PASSEPORTCI PASSPORTRow3_2", + "passengers[3].last_name": "NOM NAMERow4_2", + "passengers[3].first_name": "PRENOM FIRST NAMERow4_2", + "passengers[3].nationality": "NATIONALITE NATIONALITYRow4_2", + "passengers[3].id_number": "N PASSEPORTCI PASSPORTRow4_2", + "passengers[4].last_name": "NOM NAMERow5", + "passengers[4].first_name": "PRENOM FIRST NAMERow5", + "passengers[4].nationality": "NATIONALITE NATIONALITYRow5", + "passengers[4].id_number": "N PASSEPORTCI PASSPORTRow5", + "passengers[5].last_name": "NOM NAMERow6", + "passengers[5].first_name": "PRENOM FIRST NAMERow6", + "passengers[5].nationality": "NATIONALITE NATIONALITYRow6", + "passengers[5].id_number": "N PASSEPORTCI PASSPORTRow6", + "passengers[6].last_name": "NOM NAMERow7", + "passengers[6].first_name": "PRENOM FIRST NAMERow7", + "passengers[6].nationality": "NATIONALITE NATIONALITYRow7", + "passengers[6].id_number": "N PASSEPORTCI PASSPORTRow7" + } +} diff --git a/src/flightforms/preview.py b/src/flightforms/preview.py index a7733a9..6a2e14d 100644 --- a/src/flightforms/preview.py +++ b/src/flightforms/preview.py @@ -181,6 +181,7 @@ def _find_mapping_by_id(registry: MappingRegistry, form_id: str) -> FormMapping "lsgs": "LSGS", "french_customs": "LFAC", "lfqa": "LFQA", + "lfrm": "LFRM", "gar": "EGKA", "gendec_form": "DEST", "gendec_icao": "DEST", @@ -191,7 +192,7 @@ def _find_mapping_by_id(registry: MappingRegistry, form_id: str) -> FormMapping } # Forms where arrival vs departure changes what appears on the form -DIRECTION_AWARE_FORMS = {"lsgs", "french_customs", "lfqa", "gar", "jersey_gendec"} +DIRECTION_AWARE_FORMS = {"lsgs", "french_customs", "lfqa", "lfrm", "gar", "jersey_gendec"} def generate_preview( diff --git a/src/flightforms/templates/lfrm_customs.pdf b/src/flightforms/templates/lfrm_customs.pdf new file mode 100644 index 0000000..1ee88bf Binary files /dev/null and b/src/flightforms/templates/lfrm_customs.pdf differ diff --git a/tests/snapshots/lfrm.json b/tests/snapshots/lfrm.json new file mode 100644 index 0000000..4893017 --- /dev/null +++ b/tests/snapshots/lfrm.json @@ -0,0 +1,32 @@ +{ + "ARRIVAL_AIRCRAFT_TYPE": "AcType", + "ARRIVAL_AIRPORT": "OriginCity", + "ARRIVAL_COUNTRY": "OriginCountry", + "ARRIVAL_DATE": "15/01/2099", + "ARRIVAL_FLIGHT_TYPE": "private", + "ARRIVAL_LOCAL_TIME": "11:45", + "ARRIVAL_ORIGIN": "ORIG", + "ARRIVAL_OWNER": "AcOwner", + "ARRIVAL_REGISTRATION": "AcReg", + "DEMANDE ARRIVEE ARRIVAL REQUEST": "X", + "DEMANDE DEPART DEPARTURE REQUEST": "", + "FRET CARGO": "", + "N PASSEPORTCI PASSPORTRow1": "CrewId1", + "N PASSEPORTCI PASSPORTRow1_2": "PaxId1", + "N PASSEPORTCI PASSPORTRow2": "CrewId2", + "N PASSEPORTCI PASSPORTRow2_2": "PaxId2", + "NATIONALITE NATIONALITYRow1": "CrewNat1", + "NATIONALITE NATIONALITYRow1_2": "PaxNat1", + "NATIONALITE NATIONALITYRow2": "CrewNat2", + "NATIONALITE NATIONALITYRow2_2": "PaxNat2", + "NOM NAMERow1": "CrewLast1", + "NOM NAMERow1_2": "PaxLast1", + "NOM NAMERow2": "CrewLast2", + "NOM NAMERow2_2": "PaxLast2", + "OBSERVATIONS": "PreviewObs", + "PASSAGERS PASSENGERS": "X", + "PRENOM FIRST NAMERow1": "CrewFirst1", + "PRENOM FIRST NAMERow1_2": "PaxFirst1", + "PRENOM FIRST NAMERow2": "CrewFirst2", + "PRENOM FIRST NAMERow2_2": "PaxFirst2" +} diff --git a/tests/snapshots/lfrm_departure.json b/tests/snapshots/lfrm_departure.json new file mode 100644 index 0000000..19ea2e5 --- /dev/null +++ b/tests/snapshots/lfrm_departure.json @@ -0,0 +1,32 @@ +{ + "DEMANDE ARRIVEE ARRIVAL REQUEST": "", + "DEMANDE DEPART DEPARTURE REQUEST": "X", + "DEPARTURE_AIRCRAFT_TYPE": "AcType", + "DEPARTURE_AIRPORT": "DestCity", + "DEPARTURE_COUNTRY": "DestCountry", + "DEPARTURE_DATE": "15/01/2099", + "DEPARTURE_FLIGHT_TYPE": "private", + "DEPARTURE_LOCAL_TIME": "09:30", + "DEPARTURE_ORIGIN": "DEST", + "DEPARTURE_OWNER": "AcOwner", + "DEPARTURE_REGISTRATION": "AcReg", + "FRET CARGO": "", + "N PASSEPORTCI PASSPORTRow1": "CrewId1", + "N PASSEPORTCI PASSPORTRow1_2": "PaxId1", + "N PASSEPORTCI PASSPORTRow2": "CrewId2", + "N PASSEPORTCI PASSPORTRow2_2": "PaxId2", + "NATIONALITE NATIONALITYRow1": "CrewNat1", + "NATIONALITE NATIONALITYRow1_2": "PaxNat1", + "NATIONALITE NATIONALITYRow2": "CrewNat2", + "NATIONALITE NATIONALITYRow2_2": "PaxNat2", + "NOM NAMERow1": "CrewLast1", + "NOM NAMERow1_2": "PaxLast1", + "NOM NAMERow2": "CrewLast2", + "NOM NAMERow2_2": "PaxLast2", + "OBSERVATIONS": "PreviewObs", + "PASSAGERS PASSENGERS": "X", + "PRENOM FIRST NAMERow1": "CrewFirst1", + "PRENOM FIRST NAMERow1_2": "PaxFirst1", + "PRENOM FIRST NAMERow2": "CrewFirst2", + "PRENOM FIRST NAMERow2_2": "PaxFirst2" +} diff --git a/tests/unit/test_fillers.py b/tests/unit/test_fillers.py index b2f26f9..dff8b96 100644 --- a/tests/unit/test_fillers.py +++ b/tests/unit/test_fillers.py @@ -436,3 +436,92 @@ def test_no_extra_fields(self, registry, resolver): registry.get_template_path(mapping), mapping, request, resolver ) assert len(result) > 0 + + +class TestLocalTimeConversion: + """Forms printing local time must convert the date along with the time. + + A UTC evening slot falls on the next day in Europe/Paris, and a customs + pre-notification carrying a local time against the UTC date misdates the + flight by a day. + """ + + @pytest.fixture + def registry(self): + return MappingRegistry(str(MAPPINGS_DIR), str(TEMPLATES_DIR)) + + @pytest.fixture + def resolver(self): + return StubAirportResolver() + + def _late_flight(self): + """Arrives 23:50Z on 1 June — 01:50 on 2 June in Paris (CEST).""" + flight = make_flight(origin="ZZZZ", destination="LFRM") + flight.departure_date = "2099-06-01" + flight.departure_time_utc = "22:00" + flight.arrival_date = "2099-06-01" + flight.arrival_time_utc = "23:50" + return flight + + def _fill_lfrm(self, registry, resolver, flight): + mapping = registry.get_form("LFRM", "lfrm") + request = GenerateRequest( + airport="LFRM", + form="lfrm", + flight=flight, + aircraft=make_aircraft(), + crew=[make_pilot()], + ) + pdf_bytes = fill_pdf( + registry.get_template_path(mapping), mapping, request, resolver + ) + return PdfReader(BytesIO(pdf_bytes)).get_fields() or {} + + def test_date_rolls_with_time_across_midnight(self, registry, resolver): + fields = self._fill_lfrm(registry, resolver, self._late_flight()) + assert fields["ARRIVAL_LOCAL_TIME"].get("/V") == "01:50" + assert fields["ARRIVAL_DATE"].get("/V") == "02/06/2099" + + def test_date_unchanged_when_conversion_stays_in_day(self, registry, resolver): + # 10:45Z on 15 June is 12:45 the same day in Paris + fields = self._fill_lfrm( + registry, resolver, make_flight(origin="ZZZZ", destination="LFRM") + ) + assert fields["ARRIVAL_LOCAL_TIME"].get("/V") == "12:45" + assert fields["ARRIVAL_DATE"].get("/V") == "15/06/2099" + + def test_french_customs_date_rolls_with_time(self, registry, resolver): + mapping = registry.get_form("LFRM", "french_customs") + request = GenerateRequest( + airport="LFRM", + form="french_customs", + flight=self._late_flight(), + aircraft=make_aircraft(), + crew=[make_pilot()], + ) + pdf_bytes = fill_french_customs( + registry.get_template_path(mapping), mapping, request, resolver + ) + fields = PdfReader(BytesIO(pdf_bytes)).get_fields() or {} + assert fields["Zone de texte 12"].get("/V") == "01:50" + assert fields["Zone de texte 10"].get("/V") == "2 June 2099" + + def test_utc_form_leaves_date_and_time_alone(self, registry, resolver): + """A mapping without time_reference "local" must not shift anything.""" + mapping = registry.get_form("LFQA", "lfqa") + assert mapping.time_reference == "utc" + flight = self._late_flight() + flight.destination = "LFQA" + request = GenerateRequest( + airport="LFQA", + form="lfqa", + flight=flight, + aircraft=make_aircraft(), + crew=[make_pilot()], + ) + pdf_bytes = fill_pdf( + registry.get_template_path(mapping), mapping, request, resolver + ) + fields = PdfReader(BytesIO(pdf_bytes)).get_fields() or {} + assert fields["ARRIVALFLIGHTTIME"].get("/V") == "23:50" + assert fields["ARRIVALFLIGHTDATE"].get("/V") == "01/06/2099"