# -*- mode: python -*-
# -*- coding: utf-8 -*-

##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
#   Emmanuel Viennet      emmanuel.viennet@viennet.net
#
##############################################################################

"""Exportation des résultats des étudiants vers Apogée.

Ce code a été au départ inspiré par les travaux de Damien Mascré, scodoc2apogee (en Java).

A utiliser en fin de semestre, après les jury.

On communique avec Apogée via des fichiers CSV.

Le fichier CSV, champs séparés par des tabulations, a la structure suivante:

 <pre>
 XX-APO_TITRES-XX
 apoC_annee	2007/2008
 apoC_cod_dip	VDTCJ
 apoC_Cod_Exp	1
 apoC_cod_vdi	111
 apoC_Fichier_Exp	VDTCJ_V1CJ.txt
 apoC_lib_dip	DUT CJ
 apoC_Titre1	Export Apogée du 13/06/2008 à 14:29
 apoC_Titre2

 XX-APO_COLONNES-XX
 apoL_a01_code	Type Objet	Code	Version	Année	Session	Admission/Admissibilité	Type Rés.			Etudiant	Numéro
 apoL_a02_nom										1	Nom
 apoL_a03_prenom										1	Prénom
 apoL_a04_naissance									Session	Admissibilité	Naissance
 APO_COL_VAL_DEB
 apoL_c0001	VET	V1CJ	111	2007	0	1	N	V1CJ - DUT CJ an1	0	1	Note
 apoL_c0002	VET	V1CJ	111	2007	0	1	B		0	1	Barème
 apoL_c0003	VET	V1CJ	111	2007	0	1	R		0	1	Résultat
 APO_COL_VAL_FIN
 apoL_c0030	APO_COL_VAL_FIN

 XX-APO_VALEURS-XX
 apoL_a01_code	apoL_a02_nom	apoL_a03_prenom	apoL_a04_naissance	apoL_c0001	apoL_c0002	apoL_c0003	apoL_c0004	apoL_c0005	apoL_c0006	apoL_c0007	apoL_c0008	apoL_c0009	apoL_c0010	apoL_c0011	apoL_c0012	apoL_c0013	apoL_c0014	apoL_c0015	apoL_c0016	apoL_c0017	apoL_c0018	apoL_c0019	apoL_c0020	apoL_c0021	apoL_c0022	apoL_c0023	apoL_c0024	apoL_c0025	apoL_c0026	apoL_c0027	apoL_c0028	apoL_c0029
 10601232	AARIF	MALIKA	 22/09/1986	18	20	ADM	18	20	ADM	18	20	ADM	18	20	ADM	18	20	ADM	18	20	18	20	ADM	18	20	ADM	18	20	ADM	18	20	ADM
 </pre>

 
 On récupère nos éléments pédagogiques dans la section XX-APO-COLONNES-XX et
 notre liste d'étudiants dans la section XX-APO_VALEURS-XX. Les champs de la
 section XX-APO_VALEURS-XX sont décrits par les lignes successives de la
 section XX-APO_COLONNES-XX.

 Le fichier CSV correspond à une étape, qui est récupérée sur la ligne
 <pre>
 apoL_c0001	VET	V1CJ ...
 </pre>


XXX A vérifier:
 AJAC car 1 sem. validé et pas de NAR 
 
"""

import collections
import datetime
from functools import reduce
import functools
import io
import os
import pprint
import re
import time
from zipfile import ZipFile

from flask import send_file
import numpy as np

# Pour la détection auto de l'encodage des fichiers Apogée:
from chardet import detect as chardet_detect

from app import log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, Identite
from app.models.config import ScoDocSiteConfig
import app.scodoc.sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError, ScoFormatError
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.codes_cursus import code_semestre_validant
from app.scodoc.codes_cursus import (
    DEF,
    DEM,
    NAR,
    RAT,
)
from app.scodoc import sco_cursus
from app.scodoc import sco_formsemestre
from app.scodoc import sco_etud

APO_PORTAL_ENCODING = (
    "utf8"  # encodage du fichier CSV Apogée (était 'ISO-8859-1' avant jul. 2016)
)
APO_INPUT_ENCODING = "ISO-8859-1"  #
APO_OUTPUT_ENCODING = APO_INPUT_ENCODING  # encodage des fichiers Apogee générés
APO_DECIMAL_SEP = ","  # separateur décimal: virgule
APO_SEP = "\t"
APO_NEWLINE = "\r\n"


def _apo_fmt_note(note, fmt="%3.2f"):
    "Formatte une note pour Apogée (séparateur décimal: ',')"
    # if not note and isinstance(note, float): changé le 31/1/2022, étrange ?
    #    return ""
    try:
        val = float(note)
    except ValueError:
        return ""
    if np.isnan(val):
        return ""
    return (fmt % val).replace(".", APO_DECIMAL_SEP)


def guess_data_encoding(text, threshold=0.6):
    """Guess string encoding, using chardet heuristics.
    Returns encoding, or None if detection failed (confidence below threshold)
    """
    r = chardet_detect(text)
    if r["confidence"] < threshold:
        return None
    else:
        return r["encoding"]


def fix_data_encoding(
    text: bytes,
    default_source_encoding=APO_INPUT_ENCODING,
    dest_encoding=APO_INPUT_ENCODING,
) -> tuple[bytes, str]:
    """Try to ensure that text is using dest_encoding
    returns converted text, and a message describing the conversion.

    Raises UnicodeEncodeError en cas de problème, en général liée à
    une auto-détection errornée.
    """
    message = ""
    detected_encoding = guess_data_encoding(text)
    if not detected_encoding:
        if default_source_encoding != dest_encoding:
            message = f"converting from {default_source_encoding} to {dest_encoding}"
            text = text.decode(default_source_encoding).encode(dest_encoding)
    else:
        if detected_encoding != dest_encoding:
            message = (
                f"converting from detected {default_source_encoding} to {dest_encoding}"
            )
            text = text.decode(detected_encoding).encode(dest_encoding)
    return text, message


class StringIOFileLineWrapper(object):
    def __init__(self, data: str):
        self.f = io.StringIO(data)
        self.lineno = 0

    def close(self):
        return self.f.close()

    def readline(self):
        self.lineno += 1
        return self.f.readline()


class DictCol(dict):
    "A dict, where we can add attributes"
    pass


class ApoElt(object):
    """Definition d'un Element Apogee
    sur plusieurs colonnes du fichier CSV
    """

    def __init__(self, cols):
        assert len(cols) > 0
        assert len(set([c["Code"] for c in cols])) == 1  # colonnes de meme code
        assert len(set([c["Type Objet"] for c in cols])) == 1  # colonnes de meme type
        self.cols = cols
        self.code = cols[0]["Code"]
        self.version = cols[0]["Version"]
        self.type_objet = cols[0]["Type Objet"]

    def append(self, col):
        assert col["Code"] == self.code
        if col["Type Objet"] != self.type_objet:
            log(
                "Warning: ApoElt: duplicate id %s (%s and %s)"
                % (self.code, self.type_objet, col["Type Objet"])
            )
            self.type_objet = col["Type Objet"]
        self.cols.append(col)

    def __repr__(self):
        return "ApoElt(code='%s', cols=%s)" % (self.code, pprint.pformat(self.cols))


class EtuCol(object):
    """Valeurs colonnes d'un element pour un etudiant"""

    def __init__(self, nip, apo_elt, init_vals):
        pass  # XXX


ETUD_OK = "ok"
ETUD_ORPHELIN = "orphelin"
ETUD_NON_INSCRIT = "non_inscrit"

VOID_APO_RES = dict(N="", B="", J="", R="", M="")


class ApoEtud(dict):
    """Étudiant Apogee:"""

    def __init__(
        self,
        nip="",
        nom="",
        prenom="",
        naissance="",
        cols={},
        export_res_etape=True,
        export_res_sem=True,
        export_res_ues=True,
        export_res_modules=True,
        export_res_sdj=True,
        export_res_rat=True,
    ):
        self["nip"] = nip
        self["nom"] = nom
        self["prenom"] = prenom
        self["naissance"] = naissance
        self.cols = cols
        "{ col_id : value }  colid = 'apoL_c0001'"
        self.is_apc = None
        "Vrai si BUT"
        self.col_elts = {}
        "{'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}}"
        self.new_cols = {}  # { col_id : value to record in csv }
        self.etud: Identite = None
        "etudiant ScoDoc associé"
        self.etat = None  # ETUD_OK, ...
        self.is_NAR = False
        "True si NARé dans un semestre"
        self.log = []
        self.has_logged_no_decision = False
        self.export_res_etape = export_res_etape  # VET, ...
        self.export_res_sem = export_res_sem  # elt_sem_apo
        self.export_res_ues = export_res_ues
        self.export_res_modules = export_res_modules
        self.export_res_sdj = export_res_sdj  # export meme si pas de decision de jury
        self.export_res_rat = export_res_rat
        self.fmt_note = functools.partial(
            _apo_fmt_note, fmt=ScoDocSiteConfig.get_code_apo("NOTES_FMT") or "3.2f"
        )

    def __repr__(self):
        return f"""ApoEtud( nom='{self["nom"]}', nip='{self["nip"]}' )"""

    def lookup_scodoc(self, etape_formsemestre_ids):
        """Cherche l'étudiant ScoDoc associé à cet étudiant Apogée.
        S'il n'est pas trouvé (état "orphelin", dans Apo mais pas chez nous),
        met .etud à None.
        Sinon, cherche le semestre, et met l'état à ETUD_OK ou ETUD_NON_INSCRIT.
        """

        # futur: #WIP
        # etud: Identite = Identite.query.filter_by(code_nip=self["nip"]).first()
        # self.etud = etud
        etuds = sco_etud.get_etud_info(code_nip=self["nip"], filled=True)
        if not etuds:
            # pas dans ScoDoc
            self.etud = None
            self.log.append("non inscrit dans ScoDoc")
            self.etat = ETUD_ORPHELIN
        else:
            # futur: #WIP
            # formsemestre_ids = {
            #    ins.formsemestre_id for ins in etud.formsemestre_inscriptions
            # }
            # in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids)
            self.etud = etuds[0]
            # cherche le semestre ScoDoc correspondant à l'un de ceux de l'etape:
            formsemestre_ids = {s["formsemestre_id"] for s in self.etud["sems"]}
            in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids)
            if not in_formsemestre_ids:
                self.log.append(
                    "connu dans ScoDoc, mais pas inscrit dans un semestre de cette étape"
                )
                self.etat = ETUD_NON_INSCRIT
            else:
                self.etat = ETUD_OK

    def associate_sco(self, apo_data: "ApoData"):
        """Recherche les valeurs des éléments Apogée pour cet étudiant
        Set .new_cols
        """
        self.col_elts = {}  # {'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}}
        if self.etat is None:
            self.lookup_scodoc(apo_data.etape_formsemestre_ids)
        if self.etat != ETUD_OK:
            self.new_cols = (
                self.cols
            )  # etudiant inconnu, recopie les valeurs existantes dans Apo
        else:
            sco_elts = {}  # valeurs trouvées dans ScoDoc   code : { N, B, J, R }
            for col_id in apo_data.col_ids[4:]:
                code = apo_data.cols[col_id]["Code"]  # 'V1RT'
                el = sco_elts.get(
                    code, None
                )  # {'R': ADM, 'J': '', 'B': 20, 'N': '12.14'}
                if el is None:  # pas déjà trouvé
                    cur_sem, autre_sem = self.etud_semestres_de_etape(apo_data)
                    for sem in apo_data.sems_etape:
                        el = self.search_elt_in_sem(code, sem, cur_sem, autre_sem)
                        if el is not None:
                            sco_elts[code] = el
                            break
                self.col_elts[code] = el
                if el is None:
                    self.new_cols[col_id] = self.cols[col_id]
                else:
                    try:
                        self.new_cols[col_id] = sco_elts[code][
                            apo_data.cols[col_id]["Type Rés."]
                        ]
                    except KeyError as exc:
                        log(
                            f"associate_sco: missing key, etud={self}\ncode='{code}'\netape='{apo_data.etape_apogee}'"
                        )
                        raise ScoValueError(
                            f"""L'élément {code} n'a pas de résultat: peut-être une erreur
                            dans les codes sur le programme pédagogique
                            (vérifier qu'il est bien associé à une UE ou semestre)?"""
                        ) from exc
            # recopie les 4 premieres colonnes (nom, ..., naissance):
            for col_id in apo_data.col_ids[:4]:
                self.new_cols[col_id] = self.cols[col_id]

    # def unassociated_codes(self, apo_data):
    #     "list of apo elements for this student without a value in ScoDoc"
    #     codes = set([apo_data.cols[col_id].code for col_id in apo_data.col_ids])
    #     return codes - set(sco_elts)

    def search_elt_in_sem(self, code, sem, cur_sem, autre_sem) -> dict:
        """
        VET code jury etape
        ELP élément pédagogique: UE, module
        Autres éléments: résultats du semestre ou de l'année scolaire:
        => VRTW1: code additionnel au semestre ("code élement semestre", elt_sem_apo)
        => VRT1A: le même que le VET: ("code élement annuel", elt_annee_apo)
        Attention, si le semestre couvre plusieurs étapes, indiquer les codes des éléments,
        séparés par des virgules.

        Args:
           code (str): code apo de l'element cherché
           sem (dict): semestre dans lequel on cherche l'élément
           cur_sem (dict): semestre "courant" pour résultats annuels (VET)
           autre_sem (dict): autre semestre utilisé pour calculé les résultats annuels (VET)

        Returns:
           dict: with N, B, J, R keys, ou None si elt non trouvé
        """
        etudid = self.etud["etudid"]
        formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
        nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)

        if etudid not in nt.identdict:
            return None  # etudiant non inscrit dans ce semestre

        decision = nt.get_etud_decision_sem(etudid)
        if not self.export_res_sdj and not decision:
            # pas de decision de jury, on n'enregistre rien
            # (meme si démissionnaire)
            if not self.has_logged_no_decision:
                self.log.append("Pas de decision")
                self.has_logged_no_decision = True
            return VOID_APO_RES

        if decision and decision["code"] == NAR:
            self.is_NAR = True

        # Element etape (annuel ou non):
        if sco_formsemestre.sem_has_etape(sem, code) or (
            code in {x.strip() for x in sem["elt_annee_apo"].split(",")}
        ):
            export_res_etape = self.export_res_etape
            if (not export_res_etape) and cur_sem:
                # exporte toujours le résultat de l'étape si l'étudiant est diplômé
                Se = sco_cursus.get_situation_etud_cursus(
                    self.etud, cur_sem["formsemestre_id"]
                )
                export_res_etape = Se.all_other_validated()

            if export_res_etape:
                return self.comp_elt_annuel(etudid, cur_sem, autre_sem)
            else:
                return VOID_APO_RES

        # Element semestre:
        if code in {x.strip() for x in sem["elt_sem_apo"].split(",")}:
            if self.export_res_sem:
                return self.comp_elt_semestre(nt, decision, etudid)
            else:
                return VOID_APO_RES

        # Elements UE
        decisions_ue = nt.get_etud_decision_ues(etudid)
        for ue in nt.get_ues_stat_dict():
            if ue["code_apogee"] and code in {
                x.strip() for x in ue["code_apogee"].split(",")
            }:
                if self.export_res_ues:
                    if decisions_ue and ue["ue_id"] in decisions_ue:
                        ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"])
                        code_decision_ue = decisions_ue[ue["ue_id"]]["code"]
                        return dict(
                            N=self.fmt_note(ue_status["moy"] if ue_status else ""),
                            B=20,
                            J="",
                            R=ScoDocSiteConfig.get_code_apo(code_decision_ue),
                            M="",
                        )
                    else:
                        return VOID_APO_RES
                else:
                    return VOID_APO_RES

        # Elements Modules
        modimpls = nt.get_modimpls_dict()
        module_code_found = False
        for modimpl in modimpls:
            module = modimpl["module"]
            if module["code_apogee"] and code in {
                x.strip() for x in module["code_apogee"].split(",")
            }:
                n = nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid)
                if n != "NI" and self.export_res_modules:
                    return dict(N=self.fmt_note(n), B=20, J="", R="")
                else:
                    module_code_found = True
        if module_code_found:
            return VOID_APO_RES
        #
        return None  # element Apogee non trouvé dans ce semestre

    def comp_elt_semestre(self, nt, decision, etudid):
        """Calcul résultat apo semestre"""
        if self.is_apc:
            # pas de code semestre en APC !
            return dict(N="", B=20, J="", R="", M="")
        if decision is None:
            etud = Identite.query.get(etudid)
            nomprenom = etud.nomprenom if etud else "(inconnu)"
            raise ScoValueError(
                f"decision absente pour l'étudiant {nomprenom} ({etudid})"
            )
        # resultat du semestre
        decision_apo = ScoDocSiteConfig.get_code_apo(decision["code"])
        note = nt.get_etud_moy_gen(etudid)
        if decision_apo == "DEF" or decision["code"] == DEM or decision["code"] == DEF:
            note_str = "0,01"  # note non nulle pour les démissionnaires
        else:
            note_str = self.fmt_note(note)
        return dict(N=note_str, B=20, J="", R=decision_apo, M="")

    def comp_elt_annuel(self, etudid, cur_sem, autre_sem):
        """Calcul resultat annuel (VET) à partir du semestre courant
        et de l'autre (le suivant ou le précédent complétant l'année scolaire)
        """
        # Code annuel:
        #  - Note: moyenne des moyennes générales des deux semestres (pas vraiment de sens, mais faute de mieux)
        #    on pourrait aussi bien prendre seulement la note du dernier semestre (S2 ou S4). Paramétrable ?
        #  - Résultat jury:
        #      si l'autre est validé, code du semestre courant (ex: S1 (ADM), S2 (AJ) => année AJ)
        #      si l'autre n'est pas validé ou est DEF ou DEM, code de l'autre
        #
        #    XXX cette règle est discutable, à valider

        # print 'comp_elt_annuel cur_sem=%s autre_sem=%s' % (cur_sem['formsemestre_id'], autre_sem['formsemestre_id'])
        if not cur_sem:
            # l'étudiant n'a pas de semestre courant ?!
            log(f"comp_elt_annuel: etudid {etudid} has no cur_sem")
            return VOID_APO_RES
        cur_formsemestre = FormSemestre.query.get_or_404(cur_sem["formsemestre_id"])
        cur_nt: NotesTableCompat = res_sem.load_formsemestre_results(cur_formsemestre)
        cur_decision = cur_nt.get_etud_decision_sem(etudid)
        if not cur_decision:
            # pas de decision => pas de résultat annuel
            return VOID_APO_RES

        if (cur_decision["code"] == RAT) and not self.export_res_rat:
            # ne touche pas aux RATs
            return VOID_APO_RES

        if not autre_sem:
            # formations monosemestre, ou code VET semestriel,
            # ou jury intermediaire et etudiant non redoublant...
            return self.comp_elt_semestre(cur_nt, cur_decision, etudid)

        decision_apo = ScoDocSiteConfig.get_code_apo(cur_decision["code"])

        autre_formsemestre = FormSemestre.query.get_or_404(autre_sem["formsemestre_id"])
        autre_nt: NotesTableCompat = res_sem.load_formsemestre_results(
            autre_formsemestre
        )
        autre_decision = autre_nt.get_etud_decision_sem(etudid)
        if not autre_decision:
            # pas de decision dans l'autre => pas de résultat annuel
            return VOID_APO_RES
        autre_decision_apo = ScoDocSiteConfig.get_code_apo(autre_decision["code"])
        if (
            autre_decision_apo == "DEF"
            or autre_decision["code"] == DEM
            or autre_decision["code"] == DEF
        ) or (
            decision_apo == "DEF"
            or cur_decision["code"] == DEM
            or cur_decision["code"] == DEF
        ):
            note_str = "0,01"  # note non nulle pour les démissionnaires
        else:
            note = cur_nt.get_etud_moy_gen(etudid)
            autre_note = autre_nt.get_etud_moy_gen(etudid)
            # print 'note=%s autre_note=%s' % (note, autre_note)
            try:
                moy_annuelle = (note + autre_note) / 2
            except TypeError:
                moy_annuelle = ""
            note_str = self.fmt_note(moy_annuelle)

        if code_semestre_validant(autre_decision["code"]):
            decision_apo_annuelle = decision_apo
        else:
            decision_apo_annuelle = autre_decision_apo

        return dict(N=note_str, B=20, J="", R=decision_apo_annuelle, M="")

    def etud_semestres_de_etape(self, apo_data):
        """
        Lorsqu'on a une formation semestrialisée mais avec un code étape annuel,
        il faut considérer les deux semestres ((S1,S2) ou (S3,S4)) pour calculer
        le code annuel (VET ou VRT1A (voir elt_annee_apo)).

        Pour les jurys intermediaires (janvier, S1 ou S3):  (S2 ou S4) de la même
        étape lors d'une année précédente ?

        Renvoie le semestre "courant" et l'autre semestre, ou None s'il n'y en a pas.
        """
        # Cherche le semestre "courant":
        cur_sems = [
            sem
            for sem in self.etud["sems"]
            if (
                (sem["semestre_id"] == apo_data.cur_semestre_id)
                and (apo_data.etape in sem["etapes"])
                and (
                    sco_formsemestre.sem_in_semestre_scolaire(
                        sem,
                        apo_data.annee_scolaire,
                        0,  # annee complete
                    )
                )
            )
        ]
        if not cur_sems:
            cur_sem = None
        else:
            # prend le plus recent avec decision
            cur_sem = None
            for sem in cur_sems:
                formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
                nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
                decision = nt.get_etud_decision_sem(self.etud["etudid"])
                if decision:
                    cur_sem = sem
                    break
            if cur_sem is None:
                cur_sem = cur_sems[0]  # aucun avec decison, prend le plus recent

        if apo_data.cur_semestre_id <= 0:
            return (
                cur_sem,
                None,
            )  # "autre_sem" non pertinent pour sessions sans semestres

        if apo_data.jury_intermediaire:  # jury de janvier
            # Le semestre suivant: exemple 2 si on est en jury de S1
            autre_semestre_id = apo_data.cur_semestre_id + 1
        else:
            # Le précédent (S1 si on est en S2)
            autre_semestre_id = apo_data.cur_semestre_id - 1

        # L'autre semestre DOIT être antérieur au courant indiqué par apo_data
        if apo_data.periode is not None:
            if apo_data.periode == 1:
                courant_annee_debut = apo_data.annee_scolaire
                courant_mois_debut = 9  # periode = 1 (sept-jan)
            elif apo_data.periode == 2:
                courant_annee_debut = apo_data.annee_scolaire + 1
                courant_mois_debut = 1  # ou 2 (fev-jul)
            else:
                raise ValueError("invalid pediode value !")  # bug ?
            courant_date_debut = "%d-%02d-01" % (
                courant_annee_debut,
                courant_mois_debut,
            )
        else:
            courant_date_debut = "9999-99-99"

        # etud['sems'] est la liste des semestres de l'étudiant, triés par date,
        # le plus récemment effectué en tête.
        # Cherche les semestres (antérieurs) de l'indice autre de la même étape apogée
        # s'il y en a plusieurs, choisit le plus récent ayant une décision

        autres_sems = []
        for sem in self.etud["sems"]:
            if (
                sem["semestre_id"] == autre_semestre_id
                and apo_data.etape_apogee in sem["etapes"]
            ):
                if (
                    sem["date_debut_iso"] < courant_date_debut
                ):  # on demande juste qu'il ait démarré avant
                    autres_sems.append(sem)
        if not autres_sems:
            autre_sem = None
        elif len(autres_sems) == 1:
            autre_sem = autres_sems[0]
        else:
            autre_sem = None
            for sem in autres_sems:
                formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
                nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
                decision = nt.get_etud_decision_sem(self.etud["etudid"])
                if decision:
                    autre_sem = sem
                    break
            if autre_sem is None:
                autre_sem = autres_sems[0]  # aucun avec decision, prend le plus recent

        return cur_sem, autre_sem


class ApoData(object):
    def __init__(
        self,
        data: str,
        periode=None,
        export_res_etape=True,
        export_res_sem=True,
        export_res_ues=True,
        export_res_modules=True,
        export_res_sdj=True,
        export_res_rat=True,
        orig_filename=None,
    ):
        """Lecture du fichier CSV Apogée
        Regroupe les élements importants d'un fichier CSV Apogée
        periode = 1 (sept-jan) ou 2 (fev-jul), mais cette info n'est pas
         (toujours) présente dans les CSV Apogée et doit être indiquée par l'utilisateur
        Laisser periode à None si etape en 1 semestre (LP, décalés, ...)
        """
        self.export_res_etape = export_res_etape  # VET, ...
        self.export_res_sem = export_res_sem  # elt_sem_apo
        self.export_res_ues = export_res_ues
        self.export_res_modules = export_res_modules
        self.export_res_sdj = export_res_sdj
        self.export_res_rat = export_res_rat
        self.orig_filename = orig_filename
        self.periode = periode  #
        self.is_apc = None
        "Vrai si BUT"
        try:
            self.read_csv(data)
        except ScoFormatError as e:
            # essaie de retrouver le nom du fichier pour enrichir le message d'erreur
            filename = ""
            if self.orig_filename is None:
                if hasattr(self, "titles"):
                    filename = self.titles.get("apoC_Fichier_Exp", filename)
            else:
                filename = self.orig_filename
            raise ScoFormatError(
                "<h3>Erreur lecture du fichier Apogée <tt>%s</tt></h3><p>" % filename
                + e.args[0]
                + "</p>"
            ) from e
        self.etape_apogee = self.get_etape_apogee()  #  'V1RT'
        self.vdi_apogee = self.get_vdi_apogee()  # '111'
        self.etape = ApoEtapeVDI(etape=self.etape_apogee, vdi=self.vdi_apogee)
        self.cod_dip_apogee = self.get_cod_dip_apogee()
        self.annee_scolaire = self.get_annee_scolaire()
        self.jury_intermediaire = (
            False  # True si jury à mi-étape, eg jury de S1 dans l'étape (S1, S2)
        )

        log(
            "ApoData( periode=%s, annee_scolaire=%s )"
            % (self.periode, self.annee_scolaire)
        )

    def set_periode(self, periode):  # currently unused
        self.periode = periode

    def setup(self):
        """Recherche semestres ScoDoc concernés"""
        self.sems_etape = comp_apo_sems(self.etape_apogee, self.annee_scolaire)
        self.formsemestres_etape = [
            FormSemestre.query.get_or_404(s["formsemestre_id"]) for s in self.sems_etape
        ]
        apcs = {
            formsemestre.formation.is_apc() for formsemestre in self.formsemestres_etape
        }
        if len(apcs) != 1:
            raise ScoValueError(
                "l'ensemble mixe des semestres BUT (APC) et des semestres classiques !"
            )
        self.is_apc = apcs.pop()
        self.etape_formsemestre_ids = {s["formsemestre_id"] for s in self.sems_etape}
        if self.periode is not None:
            self.sems_periode = [
                s
                for s in self.sems_etape
                if (s["periode"] == self.periode) or s["semestre_id"] < 0
            ]
            if not self.sems_periode:
                log("** Warning: ApoData.setup: sems_periode is empty")
                log(
                    "**  (periode=%s, sems_etape [periode]=%s)"
                    % (self.periode, [s["periode"] for s in self.sems_etape])
                )
                self.sems_periode = None
                self.cur_semestre_id = -1  # ?
            else:
                self.cur_semestre_id = self.sems_periode[0]["semestre_id"]
                # Les semestres de la période ont le même indice, n'est-ce pas ?
                if not all(
                    self.cur_semestre_id == s["semestre_id"] for s in self.sems_periode
                ):
                    # debugging information
                    log(
                        f"""*** ApoData.set() error !
                        ApoData( periode={self.periode}, annee_scolaire={self.annee_scolaire
                        }, cur_semestre_id={self.cur_semestre_id} )
                        {len(self.sems_periode)} semestres dans la periode:
                        """
                    )
                    for s in self.sems_periode:
                        log(pprint.pformat(s))

                    raise ScoValueError(
                        f"""Incohérence détectée !
                        
                        Les semestres de la période n'ont pas tous le même indice.

                        Période: {self.periode}. Indice courant: {self.cur_semestre_id}

                        (au besoin, contacter l'assistance sur {scu.SCO_DISCORD_ASSISTANCE})
                        """
                    )
            # Cette condition sera inadaptée si semestres décalés
            # (mais ils n'ont pas d'étape annuelle, espérons!)
            if self.cur_semestre_id >= 0:  # non pertinent pour sessions sans semestres
                self.jury_intermediaire = (self.cur_semestre_id % 2) != 0
        else:
            self.sems_periode = None

    def read_csv(self, data: str):
        if not data:
            raise ScoFormatError("Fichier Apogée vide !")
        f = StringIOFileLineWrapper(data)  # pour traiter comme un fichier
        # check that we are at the begining of Apogee CSV
        line = f.readline().strip()
        if line != "XX-APO_TITRES-XX":
            raise ScoFormatError("format incorrect: pas de XX-APO_TITRES-XX")

        # 1-- En-tête: du début jusqu'à la balise XX-APO_VALEURS-XX
        try:
            idx = data.index("XX-APO_VALEURS-XX")
        except ValueError as exc:
            raise ScoFormatError("format incorrect: pas de XX-APO_VALEURS-XX") from exc
        self.header = data[:idx]

        # 2-- Titres:
        #   on va y chercher apoC_Fichier_Exp qui donnera le nom du fichier
        #   ainsi que l'année scolaire et le code diplôme.
        self.titles = _apo_read_TITRES(f)

        # 3-- La section XX-APO_TYP_RES-XX est ignorée:
        line = f.readline().strip()
        if line != "XX-APO_TYP_RES-XX":
            raise ScoFormatError("format incorrect: pas de XX-APO_TYP_RES-XX")
        _apo_skip_section(f)

        # 4-- Définition de colonnes: (on y trouve aussi l'étape)
        line = f.readline().strip()
        if line != "XX-APO_COLONNES-XX":
            raise ScoFormatError("format incorrect: pas de XX-APO_COLONNES-XX")
        self.cols = _apo_read_cols(f)
        self.apo_elts = self._group_elt_cols(self.cols)

        # 5-- Section XX-APO_VALEURS-XX
        # Lecture des étudiants et de leurs résultats
        while True:  # skip
            line = f.readline()
            if not line:
                raise ScoFormatError("format incorrect: pas de XX-APO_VALEURS-XX")
            if line.strip() == "XX-APO_VALEURS-XX":
                break
        self.column_titles = f.readline()
        self.col_ids = self.column_titles.strip().split()
        self.etuds = self.apo_read_etuds(f)
        self.etud_by_nip = {e["nip"]: e for e in self.etuds}

    def get_etud_by_nip(self, nip):
        "returns ApoEtud with a given NIP code"
        return self.etud_by_nip[nip]

    def _group_elt_cols(self, cols):
        """Return ordered dict of ApoElt from list of ApoCols.
        Clé: id apogée, eg 'V1RT', 'V1GE2201', ...
        Valeur: ApoElt, avec les attributs code, type_objet

        Si les id Apogée ne sont pas uniques (ce n'est pas garanti), garde le premier
        """
        elts = collections.OrderedDict()
        for col_id in sorted(list(cols.keys()), reverse=True):
            col = cols[col_id]
            if col["Code"] in elts:
                elts[col["Code"]].append(col)
            else:
                elts[col["Code"]] = ApoElt([col])
        return elts  # { code apo : ApoElt }

    def apo_read_etuds(self, f) -> list[ApoEtud]:
        """Lecture des etudiants (et resultats) du fichier CSV Apogée"""
        L = []
        while True:
            line = f.readline()
            if not line:
                break
            if not line.strip():
                continue  # silently ignore blank lines
            line = line.strip(APO_NEWLINE)
            fs = line.split(APO_SEP)
            cols = {}  # { col_id : value }
            for i in range(len(fs)):
                cols[self.col_ids[i]] = fs[i]
            L.append(
                ApoEtud(
                    nip=fs[0],  # id etudiant
                    nom=fs[1],
                    prenom=fs[2],
                    naissance=fs[3],
                    cols=cols,
                    export_res_etape=self.export_res_etape,
                    export_res_sem=self.export_res_sem,
                    export_res_ues=self.export_res_ues,
                    export_res_modules=self.export_res_modules,
                    export_res_sdj=self.export_res_sdj,
                    export_res_rat=self.export_res_rat,
                )
            )

        return L

    def get_etape_apogee(self):
        """Le code etape: 'V1RT', donné par le code de l'élément VET"""
        for elt in self.apo_elts.values():
            if elt.type_objet == "VET":
                return elt.code
        raise ScoValueError("Pas de code etape Apogee (manque élément VET)")

    def get_vdi_apogee(self):
        """le VDI (version de diplôme), stocké dans l'élément VET
        (note: on pourrait peut-être aussi bien le récupérer dans l'en-tête XX-APO_TITRES-XX apoC_cod_vdi)
        """
        for elt in self.apo_elts.values():
            if elt.type_objet == "VET":
                return elt.version
        raise ScoValueError("Pas de VDI Apogee (manque élément VET)")

    def get_cod_dip_apogee(self):
        """Le code diplôme, indiqué dans l'en-tête de la maquette
        exemple: VDTRT
        Retourne '' si absent.
        """
        return self.titles.get("apoC_cod_dip", "")

    def get_annee_scolaire(self):
        """Annee scolaire du fichier Apogee: un integer
        = annee du mois de septembre de début
        """
        m = re.match("[12][0-9]{3}", self.titles["apoC_annee"])
        if not m:
            raise ScoFormatError(
                'Annee scolaire (apoC_annee) invalide: "%s"' % self.titles["apoC_annee"]
            )
        return int(m.group(0))

    def write_header(self, f):
        """write apo CSV header on f
        (beginning of CSV until columns titles just after XX-APO_VALEURS-XX line)
        """
        f.write(self.header)
        f.write(APO_NEWLINE)
        f.write("XX-APO_VALEURS-XX" + APO_NEWLINE)
        f.write(self.column_titles)

    def write_etuds(self, f):
        """write apo CSV etuds on f"""
        for e in self.etuds:
            fs = []  #  e['nip'], e['nom'], e['prenom'], e['naissance'] ]
            for col_id in self.col_ids:
                try:
                    fs.append(str(e.new_cols[col_id]))
                except KeyError:
                    log(
                        "Error: %s %s missing column key %s"
                        % (e["nip"], e["nom"], col_id)
                    )
                    log("Details:\ne = %s" % pprint.pformat(e))
                    log("col_ids=%s" % pprint.pformat(self.col_ids))
                    log("etudiant ignore.\n")

            f.write(APO_SEP.join(fs) + APO_NEWLINE)

    def list_unknown_elements(self):
        """Liste des codes des elements Apogee non trouvés dans ScoDoc
        (après traitement de tous les étudiants)
        """
        s = set()
        for e in self.etuds:
            ul = [code for code in e.col_elts if e.col_elts[code] is None]
            s.update(ul)
        L = list(s)
        L.sort()
        return L

    def list_elements(self):
        """Liste les codes des elements Apogée de la maquette
        et ceux des semestres ScoDoc associés
        Retourne deux ensembles
        """
        try:
            maq_elems = {self.cols[col_id]["Code"] for col_id in self.col_ids[4:]}
        except KeyError:
            # une colonne déclarée dans l'en-tête n'est pas présente
            declared = self.col_ids[4:]  # id des colones dans l'en-tête
            present = sorted(self.cols.keys())  # colones presentes
            log("Fichier Apogee invalide:")
            log("Colonnes declarees: %s" % declared)
            log("Colonnes presentes: %s" % present)
            raise ScoFormatError(
                """Fichier Apogee invalide<br>Colonnes declarees: <tt>%s</tt>
            <br>Colonnes presentes: <tt>%s</tt>"""
                % (declared, present)
            )
        # l'ensemble de tous les codes des elements apo des semestres:
        sem_elems = reduce(set.union, list(self.get_codes_by_sem().values()), set())

        return maq_elems, sem_elems

    def get_codes_by_sem(self):
        """Pour chaque semestre associé, donne l'ensemble des codes de cette maquette Apogée
        qui s'y trouvent (dans le semestre, les UE ou les modules).
        Return: { formsemestre_id : { 'code1', 'code2', ... }}
        """
        codes_by_sem = {}
        for sem in self.sems_etape:
            formsemestre: FormSemestre = FormSemestre.query.get_or_404(
                sem["formsemestre_id"]
            )
            # L'ensemble des codes apo associés aux éléments:
            codes_semestre = formsemestre.get_codes_apogee()
            codes_modules = set().union(
                *[
                    modimpl.module.get_codes_apogee()
                    for modimpl in formsemestre.modimpls
                ]
            )
            codes_ues = set().union(
                *[
                    ue.get_codes_apogee()
                    for ue in formsemestre.query_ues(with_sport=True)
                ]
            )
            s = set()
            codes_by_sem[sem["formsemestre_id"]] = s
            for col_id in self.col_ids[4:]:
                code = self.cols[col_id]["Code"]  # 'V1RT'
                # associé à l'étape, l'année ou le semestre:
                if code in codes_semestre:
                    s.add(code)
                    continue
                # associé à une UE:
                if code in codes_ues:
                    s.add(code)
                    continue
                # associé à un module:
                if code in codes_modules:
                    s.add(code)
        # log('codes_by_sem=%s' % pprint.pformat(codes_by_sem))
        return codes_by_sem

    def build_cr_table(self):
        """Table compte rendu des décisions"""
        CR = []  # tableau compte rendu des decisions
        for e in self.etuds:
            cr = {
                "NIP": e["nip"],
                "nom": e["nom"],
                "prenom": e["prenom"],
                "est_NAR": e.is_NAR,
                "commentaire": "; ".join(e.log),
            }
            if e.col_elts and e.col_elts[self.etape_apogee] != None:
                cr["etape"] = e.col_elts[self.etape_apogee].get("R", "")
                cr["etape_note"] = e.col_elts[self.etape_apogee].get("N", "")
            else:
                cr["etape"] = ""
                cr["etape_note"] = ""
            CR.append(cr)

        columns_ids = ["NIP", "nom", "prenom"]
        columns_ids.extend(("etape", "etape_note", "est_NAR", "commentaire"))

        T = GenTable(
            columns_ids=columns_ids,
            titles=dict(zip(columns_ids, columns_ids)),
            rows=CR,
            xls_sheet_name="Decisions ScoDoc",
        )
        return T


def _apo_read_cols(f):
    """Lecture colonnes apo :
    Démarre après la balise XX-APO_COLONNES-XX
    et s'arrête après la balise APO_COL_VAL_FIN

    Colonne Apogee: les champs sont données par la ligne
    apoL_a01_code de la section XX-APO_COLONNES-XX
    col_id est apoL_c0001, apoL_c0002, ...

    :return: { col_id : { title : value } }
    Example: { 'apoL_c0001' : { 'Type Objet' : 'VET', 'Code' : 'V1IN', ... }, ... }
    """
    line = f.readline().strip(" " + APO_NEWLINE)
    fs = line.split(APO_SEP)
    if fs[0] != "apoL_a01_code":
        raise ScoFormatError("invalid line: %s (expecting apoL_a01_code)" % line)
    col_keys = fs

    while True:  # skip premiere partie (apoL_a02_nom, ...)
        line = f.readline().strip(" " + APO_NEWLINE)
        if line == "APO_COL_VAL_DEB":
            break
    # après APO_COL_VAL_DEB
    cols = {}
    i = 0
    while True:
        line = f.readline().strip(" " + APO_NEWLINE)
        if line == "APO_COL_VAL_FIN":
            break
        i += 1
        fs = line.split(APO_SEP)
        # print fs[0], len(fs)
        # sanity check
        col_id = fs[0]  # apoL_c0001, ...
        if col_id in cols:
            raise ScoFormatError("duplicate column definition: %s" % col_id)
        m = re.match(r"^apoL_c([0-9]{4})$", col_id)
        if not m:
            raise ScoFormatError(
                "invalid column id: %s (expecting apoL_c%04d)" % (line, col_id)
            )
        if int(m.group(1)) != i:
            raise ScoFormatError("invalid column id: %s for index %s" % (col_id, i))

        cols[col_id] = DictCol(list(zip(col_keys, fs)))
        cols[col_id].lineno = f.lineno  # for debuging purpose

    return cols


def _apo_read_TITRES(f):
    "Lecture section TITRES du fichier Apogée, renvoie dict"
    d = {}
    while True:
        line = f.readline().strip(
            " " + APO_NEWLINE
        )  # ne retire pas le \t (pour les clés vides)
        if not line.strip():  # stoppe sur ligne  pleines de \t
            break

        fields = line.split(APO_SEP)
        if len(fields) == 2:
            k, v = fields
        else:
            log("Error read CSV: \nline=%s\nfields=%s" % (line, fields))
            log(dir(f))
            raise ScoFormatError(
                "Fichier Apogee incorrect (section titres, %d champs au lieu de 2)"
                % len(fields)
            )
        d[k] = v
    #
    if not d.get("apoC_Fichier_Exp", None):
        raise ScoFormatError("Fichier Apogee incorrect: pas de titre apoC_Fichier_Exp")
    # keep only basename: may be a windows or unix pathname
    s = d["apoC_Fichier_Exp"].split("/")[-1]
    s = s.split("\\")[-1]  # for DOS paths, eg C:\TEMP\VL4RT_V3ASR.TXT
    d["apoC_Fichier_Exp"] = s
    return d


def _apo_skip_section(f):
    "Saute section Apo: s'arrete apres ligne vide"
    while True:
        line = f.readline().strip()
        if not line:
            break


# -------------------------------------


def comp_apo_sems(etape_apogee, annee_scolaire: int) -> list[dict]:
    """
    :param etape_apogee: etape (string or ApoEtapeVDI)
    :param annee_scolaire: annee (int)
    :return: list of sems for etape_apogee in annee_scolaire
    """
    return sco_formsemestre.list_formsemestre_by_etape(
        etape_apo=str(etape_apogee), annee_scolaire=annee_scolaire
    )


def nar_etuds_table(apo_data, NAR_Etuds):
    """Liste les NAR -> excel table"""
    code_etape = apo_data.etape_apogee
    today = datetime.datetime.today().strftime("%d/%m/%y")
    L = []
    NAR_Etuds.sort(key=lambda k: k["nom"])
    for e in NAR_Etuds:
        L.append(
            {
                "nom": e["nom"],
                "prenom": e["prenom"],
                "c0": "",
                "c1": "AD",
                "etape": code_etape,
                "c3": "",
                "c4": "",
                "c5": "",
                "c6": "N",
                "c7": "",
                "c8": "",
                "NIP": e["nip"],
                "c10": "",
                "c11": "",
                "c12": "",
                "c13": "NAR - Jury",
                "date": today,
            }
        )

    columns_ids = (
        "NIP",
        "nom",
        "prenom",
        "etape",
        "c0",
        "c1",
        "c3",
        "c4",
        "c5",
        "c6",
        "c7",
        "c8",
        "c10",
        "c11",
        "c12",
        "c13",
        "date",
    )
    T = GenTable(
        columns_ids=columns_ids,
        titles=dict(zip(columns_ids, columns_ids)),
        rows=L,
        xls_sheet_name="NAR ScoDoc",
    )
    return T.excel()


def export_csv_to_apogee(
    apo_csv_data: str,
    periode=None,
    dest_zip=None,
    export_res_etape=True,
    export_res_sem=True,
    export_res_ues=True,
    export_res_modules=True,
    export_res_sdj=True,
    export_res_rat=True,
):
    """Genere un fichier CSV Apogée
    à partir d'un fichier CSV Apogée vide (ou partiellement rempli)
    et des résultats ScoDoc.
    Si dest_zip, ajoute les fichiers générés à ce zip
    sinon crée un zip et le publie
    """
    apo_data = ApoData(
        apo_csv_data,
        periode=periode,
        export_res_etape=export_res_etape,
        export_res_sem=export_res_sem,
        export_res_ues=export_res_ues,
        export_res_modules=export_res_modules,
        export_res_sdj=export_res_sdj,
        export_res_rat=export_res_rat,
    )
    apo_data.setup()  # -> .sems_etape

    for e in apo_data.etuds:
        e.is_apc = apo_data.is_apc
        e.lookup_scodoc(apo_data.etape_formsemestre_ids)
        e.associate_sco(apo_data)

    # Ré-écrit le fichier Apogée
    f = io.StringIO()
    apo_data.write_header(f)
    apo_data.write_etuds(f)

    # Table des NAR:
    NAR_Etuds = [e for e in apo_data.etuds if e.is_NAR]
    if NAR_Etuds:
        nar_xls = nar_etuds_table(apo_data, NAR_Etuds)
    else:
        nar_xls = None

    # Journaux & Comptes-rendus
    # Orphelins: etudiants dans fichier Apogée mais pas dans ScoDoc
    Apo_Non_ScoDoc = [e for e in apo_data.etuds if e.etat == ETUD_ORPHELIN]
    # Non inscrits: connus de ScoDoc mais pas inscrit dans l'étape cette année
    Apo_Non_ScoDoc_Inscrits = [e for e in apo_data.etuds if e.etat == ETUD_NON_INSCRIT]
    # CR table
    cr_table = apo_data.build_cr_table()
    cr_xls = cr_table.excel()

    # Create ZIP
    if not dest_zip:
        data = io.BytesIO()
        dest_zip = ZipFile(data, "w")
        my_zip = True
    else:
        my_zip = False
    # Ensure unique filenames
    filename = apo_data.titles["apoC_Fichier_Exp"]
    basename, ext = os.path.splitext(filename)
    csv_filename = filename

    if csv_filename in dest_zip.namelist():
        basename = filename + "-" + apo_data.vdi_apogee
        csv_filename = basename + ext
    nf = 1
    tmplname = basename
    while csv_filename in dest_zip.namelist():
        basename = tmplname + "-%d" % nf
        csv_filename = basename + ext
        nf += 1

    log_filename = "scodoc-" + basename + ".log.txt"
    nar_filename = basename + "-nar" + scu.XLSX_SUFFIX
    cr_filename = basename + "-decisions" + scu.XLSX_SUFFIX

    logf = io.StringIO()
    logf.write("export_to_apogee du %s\n\n" % time.ctime())
    logf.write("Semestres ScoDoc sources:\n")
    for sem in apo_data.sems_etape:
        logf.write("\t%(titremois)s\n" % sem)
    logf.write("Periode: %s\n" % periode)
    logf.write("export_res_etape: %s\n" % int(export_res_etape))
    logf.write("export_res_sem: %s\n" % int(export_res_sem))
    logf.write("export_res_ues: %s\n" % int(export_res_ues))
    logf.write("export_res_modules: %s\n" % int(export_res_modules))
    logf.write("export_res_sdj: %s\n" % int(export_res_sdj))
    logf.write(
        "\nEtudiants Apogee non trouves dans ScoDoc:\n"
        + "\n".join(
            ["%s\t%s\t%s" % (e["nip"], e["nom"], e["prenom"]) for e in Apo_Non_ScoDoc]
        )
    )
    logf.write(
        "\nEtudiants Apogee non inscrits sur ScoDoc dans cette étape:\n"
        + "\n".join(
            [
                "%s\t%s\t%s" % (e["nip"], e["nom"], e["prenom"])
                for e in Apo_Non_ScoDoc_Inscrits
            ]
        )
    )

    logf.write(
        "\n\nElements Apogee inconnus dans ces semestres ScoDoc:\n"
        + "\n".join(apo_data.list_unknown_elements())
    )
    log(logf.getvalue())  # sortie aussi sur le log ScoDoc

    csv_data = f.getvalue().encode(APO_OUTPUT_ENCODING)

    # Write data to ZIP
    dest_zip.writestr(csv_filename, csv_data)
    dest_zip.writestr(log_filename, logf.getvalue())
    if nar_xls:
        dest_zip.writestr(nar_filename, nar_xls)
    dest_zip.writestr(cr_filename, cr_xls)

    if my_zip:
        dest_zip.close()
        data.seek(0)
        return send_file(
            data,
            mimetype="application/zip",
            download_name=scu.sanitize_filename(basename + "-scodoc.zip"),
            as_attachment=True,
        )
    else:
        return None  # zip modified in place