'''
    gestion.models, un module pour SLM
    - définitions des modèles de données et de certaines méthodes de ces
      modèles.

Copyright (c) 2023-2024 Georges Khaznadar <georgesk@debian.org>

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 3 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, see <https://www.gnu.org/licenses/>.

'''
from django.db import models
from django.db.models import DateTimeField, TextField, IntegerField, \
    CharField, FloatField, DateField, BooleanField, ImageField, Count, Q
from django.utils import timezone, safestring
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext as _, gettext_lazy

import sys
import time
import re
import math
import json
from datetime import timedelta, date, datetime
from copy import copy
import random
import Levenshtein
import functools
import operator
import uuid
from unidecode import unidecode
from pypinyin import pinyin
from functools import reduce
from operator import or_
from subprocess import run
from xml.dom import minidom

# DATE111 sert de valeur Not_A_Date
DATE111 = date.fromisoformat("0001-01-01")

LIMITE_ANNEE_SCOLAIRE = 7 # le mois de juillet est entre deux années scolaires

# TIMEZONE111 sert de valeur Not_A_DateTime
TIMEZONE111 = timezone.make_aware(datetime.fromisoformat("2001-01-01"))

# durée de neuf mois (une année scolaire)
NEUF_MOIS = timedelta(days = 275)

NOCLASS = _("PAS_DE_CLASSE")
# ordre chronologique des classes d'abord seconde, puis première et
# enfin terminale et en tout dernier, NOCLASS
# Translators: "P" is the first character of "PAS_DE_CLASSE"
CHRONO_CLASSES = {"2": 0, "1": 1, "T": 2, _("P"): 3}

def formule_tarif(valeur_initiale, anciennete, par_an = 0.2
                  , plancher = 8.0, granularite = 50):
    """
    Définit le tarif à rembourser pour un livre selon son ancienneté et
    son prix initial.
    La formule est : valeur initiale -20% par année
    d'ancienneté du prêt, arrondi à l'euro supérieur, et au minimum : 8€
    @param valeur_initiale le prix d'achat du livre
    @param anciennete une durée, de type timedelta
    @param par_an dépréciation du livre par année (20% par défaut)
    @param granularite détermine l'arrondi (50 centimes par défaut)
    @param plancher tarif minimal
    """
    annee = timedelta(days=365)
    valeur = (1-par_an) ** (math.ceil(anciennete / annee)) * valeur_initiale
    valeur = round(100 / granularite * valeur) / 100 * granularite
    return max(valeur, plancher)

iterusines = {} # dictionnaire global; timecode -> instance d'IterUsine

class IterUsine:
    """
    Gestion d'itérateurs attachés à un modèle et à une session

    paramètres du constructeur
    @param name un nom
    @param queryset une instance de QuerySet
    """

    def __init__(self, name, queryset):
        self.name = name
        self.queryset = queryset
        self.next_item = 0
        self.fini = False
        iterusines[name] = {
            "t": datetime.now(),
            "it": self
        }
        self.count = queryset.count()
        return
    
    def __str__(self):
        return f"IterUsine: next = {self.next_item}, count = {self.count}, name = {self.name}, tstamp = {self.tstamp()}"

    def tstamp(self):
        if self.name in iterusines:
            return iterusines[self.name]["t"]
        else:
            return "perimé"

    @staticmethod
    def more (name, request = None, n = 40):
        """
        Fournit une liste d'au plus max instances de self.parent
        @param name un nom pour récupérer une instance d'IterUsine
        @param request utile durant le débogage pour examiner les variables
           de session, nécessaire pour périmer une iterusine
        @param n (40 par défaut) nombre max d'éléments à renvoyer
        @return (numero, liste) ; quand numéro vaut -1 c'est que l'itérateur
           a été périmé
        """
        result = []
        numero = -1 # résultat si l'itérateur est épuisé
        if name: # l'itérateur est dans la session (non périmé)
            try:
                it = iterusines[name]["it"]
                while it.next_item < it.count and n > 0:
                    result.append(it.queryset[it.next_item])
                    it.next_item += 1
                    n -= 1
                if it.next_item >= it.count:
                    it.perime(request)
                else:
                    numero = it.next_item -1
            except Exception as err:
                print("ERROR =====>", err, " name in iterusines =", name in iterusines,
                      " iterusines =", iterusines,
                      " session variables =", request.session.items())
        return numero, result

    def perime(self, request):
        """
        Périme self
        """
        request.session.pop(self.name)
        iterusines.pop(self.name)
        self.fini = True
        return

def sans_accent(c):
    # chinois -> latin
    c = re.sub(" +", " ", " ".join(( e[0] for e in pinyin(c))))
    # latin -> latin sans accents
    return unidecode(c).replace("'", " ")

les_modeles = {} # pour enregistrer les modèles

def preprocess(val, typedic):
    '''
    Prépare une valeur pour l'utiliser dans la création d'une instance
    de modèle ; ça permet de traiter le cas des chaînes vides qui ne donnent
    pas facilement des entiers, des dates, etc.
    @param val une valeur
    @param typedic un dictionnaire : nom de type -> valeur supplémentaire
    '''
    typestr, supp = list(typedic.items())[0] # seul élément de typedic
    if typestr == "int" and val =="":
        return "-999999999"
    if typestr == "float" and val =="":
        return "-999999999.0"
    if typestr == "date" and val =="":
        return "0001-01-01"
    if typestr == "foreign":
        try:
            result = eval("{supp}.objects.get(pk={val})".\
                          format(supp=supp, val=int(val)))
        except ObjectDoesNotExist:
            result = "ERREUR: Impossible de trouver une instance de " + \
                "{supp} avec une clé {val}".format(supp=supp, val=int(val))
        return result
    # par défaut, on ne change rien à val
    return val

verbose = False # à modifier pour avoir plus de trace dans le terminal

def log(message):
    '''
    Enregistre et date un message d'erreur
    '''
    with open("fromCSV.log", "a") as errorfile:
        errorfile.write("{now}: {message}\n".format(
            now=datetime.now(), message=message))
    if verbose:
        print(message)
    return

def fromCSV(model, filedict):
    '''
    Enrichit la base de données à partir d'un fichier CSV
    @param model un modèle (à voir ci-dessous)
    @param filedict dictionnaire:
      'csvfile'    -> le fichier source du modèle
      'fieldtypes' -> dictionnaire champ -> type du champ
    '''
    filename = filedict['csvfile']
    fieldtypes = filedict['fieldtypes']
    print("Importation des données de {filename}".format(filename=filename))
    max= len(open(filename).readlines()) - 1
    import csv
    dr = csv.DictReader(open(filename), delimiter=",")
    idents = {fn: safeIdent(fn) for fn in dr.fieldnames}
    i = 1
    t0 = time.time()
    for dict in dr:
        dict1 = {idents[fn]: preprocess(val, fieldtypes[idents[fn]]) for \
                 fn, val in dict.items()}
        if verbose:
            print(dict1)
        erreur = {
            ident: val for ident, val in dict1.items() if \
            isinstance(val, str) and \
            val.startswith("ERREUR: Impossible de trouver une instance de")}
        if erreur:
            log("{filename} ligne {lig1} : {erreur}".format(
                filename=filename, lig1=i+1, erreur=erreur))
        else:
            record = model.__class__(**dict1)
            record.save()
        if i%10 == 0:
            t1 = time.time()
            attente = round((max - i) *  (t1-t0) / i)
            sys.stdout.write(
                "{i}/{max}      EOT: {attente} s  DUR: {dur} s\r".\
                format(i=i, max=max, attente=attente, dur=round(t1-t0)))
            sys.stdout.flush()
        i += 1
    print()
    print("Importation des données de {filename} terminée".format(
        filename=filename))
    return

def safeText(text):
    '''
    routine de désaccentuation
    '''
    result=text
    desaccent = {
        r"[àâä]" : "a",
        r"[ÀÂÄ]" : "A",
        r"[éèêë]" : "e",
        r"[ÉÈÊË]" : "E",
        r"[îï]" : "i",
        r"[ÎÏ]" : "I",
        r"[ôö]" : "o",
        r"[ÔÖ]" : "O",
        r"[ùûü]" : "u",
        r"[ÙÛÜ]" : "U",
        r"[ç]" : "c",
        r"[Ç]" : "C",
    }
    for r, repl in desaccent.items():
        result = re.sub(r,repl, result)
    return result
    
def safeIdent(text):
    '''
    nettoyage des identifiants
    '''
    return re.sub(r'''[. ,;!"'?]''', "_", safeText(text).replace("°", ""))

# Create your models here.

etat_NEUF         = 1
etat_BON          = 2
etat_PASSABLE     = 3
etat_MAUVAIS      = 4
etat_TRES_MAUVAIS = 5
etat_INCOMPLET    = 6
etat_AUTRE        = 0

ETAT_MATERIEL_CHOIX = [
    (etat_NEUF,         gettext_lazy("Neuf")),
    (etat_BON,          gettext_lazy("Bon")),
    (etat_PASSABLE,     gettext_lazy("Passable")),
    (etat_MAUVAIS,      gettext_lazy("Mauvais")),
    (etat_TRES_MAUVAIS, gettext_lazy("Très Mauvais")),
    (etat_INCOMPLET,    gettext_lazy("Incomplet")),
    (etat_AUTRE,        gettext_lazy("Autre")),
]
DICT_ETAT_MATERIEL_CHOIX = dict(ETAT_MATERIEL_CHOIX)

def conserve_iterateur(dico, iterateur, demi_heure = timedelta(minutes=30)):
    """
    Conserve un itérateur dans un dictionnaire en vue d'une réutilisation
    prochaine par l'interface web.

    Ces itérateurs consomment de la place en mémoire, si bien qu'on
    les supprime après un délai d'une demi-heure, au moment où on crée
    un itérateur similaire dans le même dictionnaire.
    
    @param dico un dictionnaire timbre à date => itérateur
    @param iterateur l'itérateur à conserver
    @param demiheure une durée (optionnelle, 30 minutes par défaut)
    @ return le timbre à date nouvellement créé
    """
    now = datetime.now()
    # nettoyage des timbres à date anciens
    for t in copy(dico):
        if now - datetime.fromisoformat(t) > demi_heure:
            del (dico[t])
    # enregistrement de l'itérateur
    tstamp = now.strftime("%Y-%m-%d_%H:%M")
    dico[tstamp] = iterateur
    return tstamp

modeles_ordre = [
    'Classes', 'Parametres', 'Disciplines', 'Niveaux',
    'Filieres', 'Series', 'TypeMateriel', 'Courriers',
    'Commandes', 'Personnels', 'Eleves', 'Materiel',
    'Inventaire', 'Details', 'Prets'
]

class Classes(models.Model):
    """
    Ici, la documentation de la classe Classes
    """
    # utilisé par Eleves.classe
    id = IntegerField(primary_key = True)
    code = CharField(max_length=25, verbose_name = _("Code de la Classe") )
    libelle = CharField(max_length=25, verbose_name = _("Libellé de la Classe") )
    compte = IntegerField(
        verbose_name = _("Compte De Noms"), null=True, blank=True, default = 0 )

    def __str__(self):
        return '''{libelle} (effectif : {compte})'''.format(**self.__dict__)

    class Meta:
        verbose_name_plural = "Classes"

    @staticmethod
    def categories():
        """
        Donne les catégories ('1G', '1STMG', '2D', 'TG', 'TMCRH', ..en France)
        en se basant sur les classes importantes, dont on retire le numéro
        final et éventuellement un séparateur (espace, tiret, underscore).
        Renvoie une liste, dans l'ordre de passage chronologique
        """
        # Translators: those are begins of class names in order of age in France
        ordre = json.loads(_('["6", "5", "4", "3", "2", "1", "T", "P"]'))
        c = set()
        for i in Classes.classes_importantes():
            c.add(Classes.to_categorie(i.libelle))
        result0 = {}
        for o in ordre:
            for cl in c:
                if cl.startswith(o):
                    if o in result0:
                        result0[o].append(cl)
                    else:
                        result0[o] = [cl]
        result1 = []
        for o in ordre:
            if o in result0:
                result1 += sorted(result0[o])
        return result1

    @staticmethod
    def to_categorie(nomclasse):
        """
        Partant d'un nom de classe, on infère une catégorie telle que
        '2D', '1G', '1STMG', '2D', 'TG', 'TGF', 'TMCRH', ...
        on retire le numéro final et éventuellement un séparateur
        (espace, tiret, underscore)
        @param nomclasse un nom de classe
        @return le nom d'une catégorie
        """
        result = re.sub(r"[-_\s\d]+$","", nomclasse)
        # cas particulier pour les secondes françaises
        if result == "2NDE":
            result = "2D"
        return result

    @staticmethod
    def classes_de_la_categorie(cat):
        """
        Renvoie un itérateur de classes qui appatiennent à une même catéforie
        @param cat la catégorie
        """
        toutes = Classes.objects.all()
        return [c for c in toutes if Classes.to_categorie(c.libelle) == cat] 
    
    @staticmethod
    def classes_importantes():
        """
        Renvoie une liste de classes importantes pour la coopérative,
        triées dans l'ordre alphabétique
        (première, seconde, terminale ... en France)
        @return un queryset
        """
        # structure : [(regexp de sélection, regexp d'interdiction)]
        # vérifier que json.loads fonctionnera après traduction aussi !
        # Translators: this is a list of regexps to select/unselect important classes
        CLASSE_CAUTION_RE = json.loads(_('[["^2D.*", "^2D00.*"], ["^1.*", ""], ["^TMC.*", ""], ["^TRH.*", ""], ["^TG[0-9]", ""], ["^TGF.*", ""], ["^Pro.*", ""]]'))
        Qsums=[]
        for rselect, rnot in  CLASSE_CAUTION_RE:
            if rnot:
                Qsums.append(
                    Q(libelle__regex = rselect) & ~Q(libelle__regex = rnot))
            else:
                Qsums.append(Q(libelle__regex = rselect))
        Qsum = reduce(or_, Qsums)
        return Classes.objects.filter(Qsum).order_by('libelle')
    
    def recompte(self):
        """
        Réajuste le champ compte, à appeler après une modification
        """
        self.compte = len(Eleves.objects.filter(classe = self))
        self.save()
        return

    @property
    def cartes_a_imprimer(self):
        """
        Détermine le nombre de cartes à imprimer pour la classe
        """
        eleves = Eleves.objects.filter(classe = self)
        return len([e for e in eleves if e.peut_recevoir_carte_membre])
    

les_modeles['Classes'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T02_Classes.csv',
    'fieldtypes': {
        'Id_Classe': {'int': 'id'},
        'Code_Classe': {'text': "code"},
        'Lib_Classe': {'text': "libelle"},
        'CompteDeNom': {'int': "compte"}
    },
}

class Parametres(models.Model):
    """
    Ici, la documentation de la classe Parametres
    """
    NomAcademie = CharField(max_length=25, verbose_name = _("Nom de l'Académie") )
    NomEts = CharField(max_length=25, verbose_name = _("Nom de l'Établissement") )
    CodeRNE = CharField(max_length=25, verbose_name = _("Code RNE") )
    Commune = CharField(max_length=25, verbose_name = _("Commune") )
    Titre_ChefEts = CharField(
        max_length=25, verbose_name = _("Titre du Chef d'Établissement") ,
        blank=True, null=True)
    Civilite_ChefEts = CharField(
        max_length=25, verbose_name = _("Civilité du Chef d'Établissement") )
    NomChefEts = CharField(
        max_length=25, verbose_name = _("Nom du Chef d'Établissement") )
    Civilite_AgentComptable = CharField(
        max_length=25, verbose_name = _("Civilité de l'Agent Comptable") )
    NomAgentComptable = CharField(
        max_length=25, verbose_name = _("Nom de l'Agent Comptable") )
    EtsSupportAgence = CharField(
        max_length=25, verbose_name = _("Agence de support de l'établissement"),
        blank=True, null=True )
    IBAN = CharField(max_length=25, verbose_name = _("IBAN"),
                     blank=True, null=True )
    BIC = CharField(max_length=25, verbose_name = _("BIC"), blank=True, null=True )
    Titre_Gestionnaire = CharField(
        max_length=25, verbose_name = _("Titre  du Gestionnaire"),
        blank=True, null=True )
    Civilite_Gestionnaire = CharField(
        max_length=25, verbose_name = _("Civilité du Gestionnaire"),
        blank=True, null=True )
    NomGestionnaire = CharField(
        max_length=25, verbose_name = _("Nom du Gestionnaire"),
        blank=True, null=True )
    CoordoneesIntendance = CharField(
        max_length=25, verbose_name = _("Coordonnées de l'Intendance"),
        blank=True, null=True )
    Version_Application = CharField(
        max_length=25, verbose_name = _("Version de l'Application"),
        blank=True, null=True )
    AnneeScolaire = CharField(max_length=25, verbose_name = _("Année Scolaire") )
    AnneeScolaireEnPreparation = CharField(
        max_length=25, verbose_name = _("Année Scolaire En Préparation"),
        blank=True, null=True )
    Adresse = CharField(max_length=25, verbose_name = _("Adresse") )
    CodePostal = CharField(max_length=25, verbose_name = _("Code Postal") )
    Telephone = CharField(max_length=25, verbose_name = _("Téléphone") )
    Fax = CharField(
        max_length=25, verbose_name = _("Fax"), blank=True, null=True )
    SiteWeb = CharField(
        max_length=25, verbose_name = _("Site Web"),
        blank=True, null=True )
    Nom_Assistant_social = CharField(
        max_length=25, verbose_name = _("Nom de l'Assistant social"),
        blank=True, null=True )
    Civilite_Assistant_social = CharField(
        max_length=25, verbose_name = _("Civilité de l'Assistant social"),
        blank=True, null=True )
    Logo = ImageField(
        verbose_name = _("Logo"), upload_to='./media', blank=True, null=True )
    signature = CharField(
        max_length=25, verbose_name = _("signature"), blank=True, null=True )

    def __str__(self):
        return '''{NomEts} {CodeRNE} {Commune} {AnneeScolaire}'''.\
            format(**self.__dict__)

    def save(self, *args, **kw):
        """
        Périme les livres à lire qui datent des années précédentes
        """
        Livre_a_lire.del_perimes(self.AnneeScolaire)
        return models.Model.save(self, *args, **kw)
        
    class Meta:
        verbose_name = _("Paramètres")
        verbose_name_plural = "Paramètres"
        unique_together = ("CodeRNE", "AnneeScolaire")

les_modeles['Parametres'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T03_PARAMETRES.csv',
    'fieldtypes': {
        'NomAcadémie': {'text': 'NomAcademie'},
        'NomEts': {'text': None},
        'CodeRNE': {'text': None},
        'Commune': {'text': None},
        'Titre_ChefEts': {'text': None},
        'Civilité_ChefEts': {'text': 'Civilite_ChefEts'},
        'NomChefEts': {'text': None},
        'Civilité_AgentComptable': {'text': 'Civilite_AgentComptable'},
        'NomAgentComptable': {'text': None},
        'EtsSupportAgence': {'text': None},
        'IBAN': {'text': None},
        'BIC': {'text': None},
        'Titre_Gestionnaire': {'text': None},
        'Civilité_Gestionnaire': {'text': 'Civilite_Gestionnaire'},
        'NomGestionnaire': {'text': None},
        'CoordonéesIntendance': {'text': 'CoordoneesIntendance'},
        'Version Application': {'text': 'Version_Application'},
        'AnnéeScolaire': {'text': 'AnneeScolaire'},
        'AnnéeScolaireEnPréparation': {'text': 'AnneeScolaireEnPreparation'},
        'Adresse': {'text': None},
        'CodePostal': {'text': None},
        'Téléphone': {'text': 'Telephone'},
        'Fax': {'text': None},
        'SiteWeb': {'text': None},
        'Nom_Assistant_social': {'text': None},
        'Civilité_Assistant_social': {'text': 'Civilite_Assistant_social'},
        'Logo': {'image': None},
        'signature': {'text': None}},
}

class Disciplines(models.Model):
    """
    Ici, la documentation de la classe Disciplines
    """
    # utilisé dans Materiel.discipline
    id = IntegerField(primary_key = True)
    libelle = CharField(max_length=25, verbose_name = _("Libellé de la Discipline") )
    abrege = CharField(max_length=25, verbose_name = _("Abrégé de la Discipline") )

    def __str__(self):
        return '''{libelle}'''.format(**self.__dict__)

    class Meta:
        verbose_name_plural = "Disciplines"
        
les_modeles['Disciplines'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T103_Discipline.csv',
    'fieldtypes': {
        'Id_Disc': {'int': 'id'},
        'Libellé_Disc': {'text': "libelle"},
        'Abrégé_Disc': {'text': 'abrege'}
    },
}

class Niveaux(models.Model):
    """
    Ici, la documentation de la classe Niveaux
    """
    # utilisé par Materiel.niveau
    id = IntegerField(primary_key = True)
    libelle = CharField(max_length=25, verbose_name = _("Libellé du Niveau") )
    abrege = CharField(max_length=25, verbose_name = _("Abrégé du Niveau") )
    rang = CharField(max_length=25, verbose_name = _("Rang du Niveau") )

    def __str__(self):
        return '''{libelle} ({abrege})'''.format(**self.__dict__)

    class Meta:
        verbose_name_plural = "Niveaux"
        
les_modeles['Niveaux'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T104_Niveau.csv',
    'fieldtypes': {
        'Id_Niveau': {'int': 'id'},
        'Rang_Niveau': {'text': 'rang'},
        'Libellé_Niveau': {'text': 'libelle'},
        'Abrégé_Niveau': {'text': 'abrege'}
    }
}

class Filieres(models.Model):
    """
    Ici, la documentation de la classe Filieres
    """
    # utilisé par Materiel.filiere
    id = IntegerField(primary_key = True)
    nom = CharField(max_length=25, verbose_name = _("Nom de la Filière") )

    def __str__(self):
        return '''{nom}'''.format(**self.__dict__)

    class Meta:
        verbose_name_plural = "Filières"

les_modeles['Filieres'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T1050_Filière.csv',
    'fieldtypes': {
        'Id_Filière': {'int': 'id'},
        'Nom_Filière': {'text': 'nom'}
    }
}

class Series(models.Model):
    """
    Ici, la documentation de la classe Series
    """
    # utilisé par Materiel.serie
    id = IntegerField(primary_key = True)
    libelle_court = CharField(
        max_length=25, verbose_name = _("Libellé Court de la Série") )
    libelle_long = CharField(
        max_length=25, verbose_name = _("Libellé Long de la Série") )

    def __str__(self):
        return '''{libelle_long} ({libelle_court})'''.format(**self.__dict__)

    class Meta:
        verbose_name_plural = "Séries"
        
les_modeles['Series'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T1051_Série.csv',
    'fieldtypes': {
        'Id_Série': {'int': 'id'},
        'LibelléCourt_Série': {'text': 'libelle_court'},
        'LibelléLong_Série': {'text': 'libelle_long'}
    }
}

class TypeMateriel(models.Model):
    """
    Ici, la documentation de la classe TypeMateriel
    """
    # utilisé dans Materiel.type_materiel
    id = IntegerField(primary_key = True)
    libelle_long = CharField(
        max_length=25, verbose_name = _("Libellé Long du Type de Matériel") )
    libelle_court = CharField(
        max_length=25, verbose_name = _("Libellé Court du Type de Matériel") )
    Tarif_Perdu = CharField(
        max_length=25, verbose_name = _("Tarif pour une perte") )
    Tarif_Degradation = CharField(
        max_length=25, verbose_name = _("Tarif pour une dégradation") )
    Choix_Tarif_Degradation = BooleanField(
        verbose_name = _("Appliquer le tarif de dégradation") )
    Appliquer_Demi_Tarif = BooleanField(
        verbose_name = _("Appliquer un demi-tarif") )

    def __str__(self):
        return '''{libelle_long}'''.format(**self.__dict__)

    class Meta:
        verbose_name_plural = "Types de matériel"

les_modeles['TypeMateriel'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T1063_Type_Matériel.csv',
    'fieldtypes': {
        'Id_Type_Mat': {'int': 'id'},
        'Tarif_Perdu': {'text': None},
        'Appliquer_Demi_Tarif': {'bool': None},
        'Libellé_Long_Type_Mat': {'text': 'libelle_long'},
        'Libellé_Court_Type_Mat': {'text': 'libelle_court'},
        'Tarif_Dégradation': {'text': 'Tarif_Degradation'},
        'Choix_Tarif_Dégradation': {'bool': 'Choix_Tarif_Degradation'}
    }
}

class Courriers(models.Model):
    """
    Ici, la documentation de la classe Courriers
    """
    N = IntegerField(verbose_name = _("N°") , primary_key = True)
    Pret_Recommandation = TextField(
        verbose_name = _("Recommandation pour le prêt") )
    Retour_Recommandation = TextField(
        verbose_name = _("Recommandation pour le retour") )
    Pret_Recom_Prof = TextField(
        verbose_name = _("Recommandation pour le prêt professeur") )
    Retour_Recom_Prof = TextField(
        verbose_name = _("Recommandation pour le retour professeur") )
    Memoire = TextField(
        verbose_name = _("Explications pour le « mémoire pour pertes »") )

    def __str__(self):
        return '''Texte de personnalisation : version {N}'''.format(**self.__dict__)

    class Meta:
        verbose_name_plural = "Textes prédéfinis des courriers"

les_modeles['Courriers'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T1080_Personnalisation_Courriers.csv',
    'fieldtypes': {
        'Pret_Recommandation': {'text': None},
        'Retour_Recommandation': {'text': None},
        'Pret_Recom_Prof': {'text': None},
        'Retour_Recom_Prof': {'text': None},
        'N°': {'int': 'N'}
    }
}

class Commandes(models.Model):
    """
    Ici, la documentation de la classe Commandes
    """
    # utilisé par Details.commande
    id = IntegerField(primary_key = True)
    date = CharField(max_length=25, verbose_name = _("Date") )
    commentaire = CharField(max_length=25, verbose_name = _("Commentaire") )

    def __str__(self):
        return '''{date} {commentaire}'''.format(**self.__dict__)

    class Meta:
        verbose_name_plural = "Commandes"
        
les_modeles['Commandes'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T1200_Commande.csv',
    'fieldtypes': {
        'Id_Cmd': {'int': 'id'},
        'Date_Cmd': {'text': "date"},
        'Commentaire_Cmd': {'text': "commentaire"}
    },
}

class Personnels(models.Model):
    """
    Ici, la documentation de la classe Personnels
    """
    civilite = CharField(max_length=25, verbose_name = _("Civilité") )
    nom = CharField(max_length=25, verbose_name = _("Nom de l'Accompagnateur") )
    prenom = CharField(
        max_length=25, verbose_name = _("Prénom de l'Accompagnateur") )
    nomprenom = CharField(
        max_length=25,
        verbose_name = _("Concaténation du Nom et du Prénom de l'Accompagnateur") )
    tel = CharField(max_length=25, verbose_name = _("Téléphone du Domicile") )
    mobile = CharField(
        max_length=25, verbose_name = _("Mobile de l'Accompagnateur") )
    courriel_pro = CharField(
        max_length=25, verbose_name = _("Courriel professionnel de l'accompagnateur") )
    courriel_perso = CharField(
        max_length=25, verbose_name = _("Courriel personnel de l'accompagnateur") )
    discipline = CharField(max_length=25, verbose_name = _("Discipline") )

    def __str__(self):
        return '''Personnel : {nom} {prenom}'''.format(**self.__dict__)

    class Meta:
        verbose_name_plural = "Accompagnateurs"

les_modeles['Personnels'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T1390_Personnels.csv',
    'fieldtypes': {
        'Id_Accompagnateur': {'int': 'ignore'},
        'Nom_Accompagnateur': {'text': 'nom'},
        'Tel_Domicile': {'text': 'tel'},
        'Mobile_Accompagnateur': {'text': 'mobile'},
        'EmailPro_Accompagnateur': {'text': 'courriel_pro'},
        'EmailPerso_Accompagnateur': {'text': 'courriel_perso'},
        'Date_Naissance_Accompagnateur': {'text': 'ignore'},
        'Libellé_Civilité': {'text': 'civilite'},
        'Prénom_Accompagnateur': {'text': 'prenom'},
        'Conca_Nom_Prénom_Accomp': {'text': 'nomprenom'},
        'Libellé_Discipline': {'text': 'discipline'},
        'Concaté_Prénom_Nom_Accomp': {'text': 'ignore'},
        'ConcaNomPrénom_DateNaiss': {'text': 'ignore'}
    }
}

MIN_NO_CAUTION = 15000 # les N° de caution nouveaux sont >= quinze mille

class Eleves_abstract(models.Model):
    """
    Classe de abstraite, dont héritent Eleves et Eleves_de_siecle
    """
    class Meta:
       abstract = True
    # utilisé par Prets.eleve
    id = IntegerField(primary_key = True)
    sexe = CharField(
        max_length=25, verbose_name = _("Sexe"), blank=True, null=True )
    Eleve_No_Etab = IntegerField(
        verbose_name = _("N° d'Élève"), blank=True, null=True, unique=True )
    Num_Eleve_Etab = IntegerField(
        verbose_name = _("N° d'Élève+Étab."), blank=True, null=True, unique=True )
    Nom = CharField(
        max_length=25, verbose_name = _("Nom"), blank=True, null=True )
    Nom_de_famille = CharField(
        max_length=50, verbose_name = _("Nom de famille") )
    Nom_d_usage = CharField(
        max_length=25, verbose_name = _("""Nom d'usage"""), blank=True, null=True )
    Prenom = CharField(
        max_length=25, verbose_name = _("Prénom") )
    Prenom_2 = CharField(
        max_length=25, verbose_name = _("Prénom 2"), blank=True, null=True )
    Prenom_3 = CharField(max_length=25, verbose_name = _("Prénom 3"),
                         blank=True, null=True )
    NomPrenomEleve = CharField(
        max_length=50, verbose_name = _("Concaténation du Nom et du Prénom"),
        blank=True, null=True )
    sans_accent = CharField(
        max_length=50, verbose_name = _("Nom sans accents"),
        blank=True, null=True )
    Date_Naissance = DateField(
        verbose_name = _("""Date de Naissance"""), blank=True, null=True )
    Doublement = CharField(
        max_length=25, verbose_name = _("Doublement"), blank=True, null=True )
    Id_National = CharField(
        max_length=25, verbose_name = _("Id National"), blank=True, null=True )
    Date_Entree = DateField(
        verbose_name = _("Date d' Entrée"), blank=True, null=True )
    Date_Sortie = DateField(
        max_length=25, verbose_name = _("Date de Sortie"), blank=True, null=True )
    Code_Regime = CharField(
        max_length=25, verbose_name = _("Code Régime"), blank=True, null=True )
    Lib_Regime = CharField(
        max_length=25, verbose_name = _("Libellé Régime"), blank=True, null=True )
    Code_MEF = CharField(
        max_length=25, verbose_name = _("Code MEF"), blank=True, null=True )
    Lib_MEF = CharField(
        max_length=35, verbose_name = _("Libellé MEF"),
        blank=True, null=True )
    Code_Structure = CharField(
        max_length=25, verbose_name = _("Code de Structure"),
        blank=True, null=True )
    Type_Structure = CharField(
        max_length=25, verbose_name = _("Type Structure"),
        blank=True, null=True )
    Lib_Structure = CharField(
        max_length=25, verbose_name = _("Libellé Structure"),
        blank=True, null=True )
    Cle_Gestion_Mat_Enseignee_1 = CharField(
        max_length=25, verbose_name = _("Clé Matière Enseignée 1"),
        blank=True, null=True )
    Lib_Mat_Enseignee_1 = CharField(
        max_length=25, verbose_name = _("Matière Enseignée 1"),
        blank=True, null=True )
    Cle_Gestion_Mat_Enseignee_2 = CharField(
        max_length=25, verbose_name = _("Clé Matière Enseignée 2"),
        blank=True, null=True )
    Lib_Mat_Enseignee_2 = CharField(
        max_length=25, verbose_name = _("Matière Enseignée 2"),
        blank=True, null=True )
    Cle_Gestion_Mat_Enseignee_3 = CharField(
        max_length=25, verbose_name = _("Clé Matière Enseignée 3"),
        blank=True, null=True )
    Lib_Mat_Enseignee_3 = CharField(
        max_length=25, verbose_name = _("Matière Enseignée 3"),
        blank=True, null=True )
    Cle_Gestion_Mat_Enseignee_4 = CharField(
        max_length=25, verbose_name = _("Clé Matière Enseignée 4"),
        blank=True, null=True )
    Lib_Mat_Enseignee_4 = CharField(
        max_length=25, verbose_name = _("Matière Enseignée 4"),
        blank=True, null=True )
    Cle_Gestion_Mat_Enseignee_5 = CharField(
        max_length=25, verbose_name = _("Clé Matière Enseignée 5"),
        blank=True, null=True )
    Lib_Mat_Enseignee_5 = CharField(
        max_length=25, verbose_name = _("Matière Enseignée 5"),
        blank=True, null=True )
    Lib_Mat_Enseignee_6 = CharField(
        max_length=25, verbose_name = _("Matière Enseignée 6"),
        blank=True, null=True )
    Lib_Mat_Enseignee_7 = CharField(
        max_length=25, verbose_name = _("Matière Enseignée 7"),
        blank=True, null=True )
    Lib_Mat_Enseignee_8 = CharField(
        max_length=25, verbose_name = _("Matière Enseignée 8"),
        blank=True, null=True )
    Lib_Mat_Enseignee_9 = CharField(
        max_length=25, verbose_name = _("Matière Enseignée 9"),
        blank=True, null=True )
    Lib_Mat_Enseignee_10 = CharField(
        max_length=25, verbose_name = _("Matière Enseignée 10"),
        blank=True, null=True )
    Tel_Personnel = CharField(
        max_length=25, verbose_name = _("Téléphone Personnel"),
        blank=True, null=True )
    Tel_Professionnel = CharField(
        max_length=25, verbose_name = _("Téléphone Professionnel"),
        blank=True, null=True )
    Tel_Portable = CharField(
        max_length=25, verbose_name = _("Téléphone Portable"),
        blank=True, null=True )
    Email = CharField(max_length=25, verbose_name = _("Email"),
                      blank=True, null=True )
    Adresse = TextField(verbose_name =_("Adresse"), blank=True, null=True)
    Civilite_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Civilité du Représentant Légal"),
        blank=True, null=True )
    Nom_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Nom du Représentant Légal"),
        blank=True, null=True )
    Nom_de_famille_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Nom de famille du Représentant Légal"),
        blank=True, null=True )
    Nom_d_usage_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Nom d'usage du Représentant Légal"),
        blank=True, null=True )
    Prenom_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Prénom Représentantant Légal"),
        blank=True, null=True )
    ConcaReprLeg = CharField(
        max_length=25, verbose_name = _("ConcaReprLég"), blank=True, null=True )
    Tel_Personnel_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Tél Personnel Repr Lég"),
        blank=True, null=True )
    Lien_Parente_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Lien Parenté Repr Lég"),
        blank=True, null=True )
    Tel_Portable_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Tél Portable Repr Lég"),
        blank=True, null=True )
    Tel_Professionnel_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Tél Professionnel Repr Lég"),
        blank=True, null=True )
    Email_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Email Repr Lég"), blank=True, null=True )
    Communication_Adresse_postale_et_courriel_Repr_Leg = CharField(
        max_length=25,
        verbose_name = _("Communication Adresse postale et courriel Repr Lég"),
        blank=True, null=True )
    Adresse_Repr_Leg = TextField(
        verbose_name = _("Adresse du représentant légal"), blank=True, null=True)
    Civilite_Autre_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Civilité Autre Repr Lég"),
        blank=True, null=True )
    Nom_Autre_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Nom Autre Repr Lég"),
        blank=True, null=True )
    Nom_de_famille_Autre_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Nom de famille Autre Repr Lég"),
        blank=True, null=True )
    Nom_d_usage_Autre_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Nom d'usage Autre Repr Lég"),
        blank=True, null=True )
    Prenom_Autre_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Prénom Autre Repr Lég"),
        blank=True, null=True )
    ConcaAutreReprLeg = CharField(
        max_length=25, verbose_name = _("ConcaAutreReprLég"),
        blank=True, null=True )
    Tel_Personnel_Autre_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Tél Personnel Autre Repr Lég"),
        blank=True, null=True )
    Lien_Parente_Autre_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Lien Parenté Autre Repr Lég"),
        blank=True, null=True )
    Tel_Portable_Autre_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Tél Portable Autre Repr Lég"),
        blank=True, null=True )
    Tel_Professionnel_Autre_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Tél Professionnel Autre Repr Lég"),
        blank=True, null=True )
    Email_Autre_Repr_Leg = CharField(
        max_length=25, verbose_name = _("Email Autre Repr Lég"),
        blank=True, null=True )
    Communication_Adresse_postale_et_courriel_Autre_Repr_Leg = CharField(
        max_length=25,
        verbose_name = _("Communication Adresse postale et courriel Autre Repr Lég"),
        blank=True, null=True )
    Adresse_Autre_Repr_Leg = TextField(
        verbose_name = _("Adresse d'un autre représentant légal"),
        blank=True, null=True)

    @property
    def mat_enseignees(self):
        """
        @return un ensemble de matières enseignées comme langue ou comme
        option ou comme spécialité
        """
        attribs = ("Lib_Mat_Enseignee_{i}".format(i=i) for i in range(1,11))
        return set((getattr(self,a) for a in attribs if getattr(self,a)))
    
    def adresse_codepostal(self):
        """
        Prépare l'adresse pour imprimer sur une étiquette ;
        découvre le code postal, puis met tout ce qui commence au code postal
        à part.
        @return une paire : (debut, code)
        """
        a = re.sub(r"\sfrance", "", self.Adresse_Repr_Leg,
                   re.MULTILINE, re.IGNORECASE)
        lignes = a.strip().split("\n")
        index_code = None
        debut = a; code = ""
        for i in range(len(lignes)):
            if re.match(r"\d\d\d\d\d", lignes[i]):
                index_code = i
        if index_code is not None: # on connaît la ligne du code postal
            if index_code > 0 : # le code n'est pas en première ligne
                debut = "\n".join(lignes[: index_code -1])
                # si le code postal est tout en dernier alors
                # on inverse les deux dernières lignes
                if index_code == len(lignes) - 1:
                    code = lignes[index_code] + "\n" + lignes[index_code -1]
                else:
                    code = "\n".join(lignes[index_code -1 :])
        return debut, code
    
    def nom_prenom(self):
        return f"{self.Nom_de_famille} {self.Prenom}"

    def calcule_sans_accent(self):
        """
        Crée une version sans accents, ni tirets ni apostrophes, en minuscules
        du nom/prénom, et l'affecte à self.sans_accent
        """
        self.sans_accent = sans_accent(
            self.Nom_de_famille + " " + self.Prenom).lower()
        return

    @staticmethod
    def sans_prets_qs():
        """
        @return un querystring pour des élèves sans livre prêté
        """
        return Eleves.objects.annotate(
            prets_en_cours = Count(
                'prets',
                filter = ~Q(prets__date_retour__gt = DATE111) & \
                ~Q(prets__Date_Declaration_Perte__gt = DATE111)
            )
        ).filter(prets_en_cours = 0)
    
    @staticmethod
    def avec_prets_qs(n = 1):
        """
        @param n nombre de livres prêtés au minimum (1 par défaut)
        @return un querystring pour des élèves avec n livres prêté au moins
        """
        # on annote les élèves en comptant les prêts ni rendus ni payés
        return Eleves.objects.annotate(
            prets_en_cours = Count(
                'prets',
                filter = ~Q(prets__date_retour__gt = DATE111) & \
                Q(prets__inventaire__paye__isnull = True)
            )
            # puis on conserve ceux dont le nombre de livres dus dépasse n-1
        ).filter(prets_en_cours__gt = n - 1)
    
    @staticmethod
    def neighborField(model):
        """
        Renvoie le nom de champ le plus proche d'un modèle
        """
        # import Levenshtein
        fnames = [f.name for f in Eleves._meta.get_fields()]
        result = sorted(fnames, key = lambda x: Levenshtein.distance(x, model))
        return result[0]

    def homonymes(self,elv_list):
        """
        Détecte si l'instance courante a un ou plusieurs homonymes
        dans une liste d'instance d'Eleves
        @param elv_list une liste d'instance d'Eleves
        @return la sous-liste des homonymes
        """
        return [ e for e in elv_list if \
                 e.sans_accent == self.sans_accent ]

    # liste des champs qui sont pris en compte dans un import SIÈCLE
    champs_importants = [
        'Nom_de_famille',
        'Prenom',
        'Lib_Structure',
        'Lib_Mat_Enseignee_1',
        'Lib_Mat_Enseignee_2',
        'Lib_Mat_Enseignee_3',
        'Lib_Mat_Enseignee_4',
        'Lib_Mat_Enseignee_5',
        'Lib_Mat_Enseignee_6',
        'Lib_Mat_Enseignee_7',
        'Lib_Mat_Enseignee_8',
        'Lib_Mat_Enseignee_9',
        'Lib_Mat_Enseignee_10',
        'Tel_Portable',
        'Adresse_Repr_Leg',
        'Nom_de_famille_Repr_Leg',
        'Prenom_Repr_Leg',
        'Tel_Portable_Repr_Leg',
        'Tel_Portable_Autre_Repr_Leg',
    ]
    
    def doit_etre_mis_a_jour(self, other):
        """
        détermine si un élève doit être 
        remis à jour par rapport à un autre.
        @param other une instace de la classe Eleves
        """
        result = functools.reduce(
            operator.or_,
            (getattr(self, c) != getattr(other, c) \
             for c in self.champs_importants)
        )
        return result

class Eleves(Eleves_abstract):
    """
    La classe Eleves définit les élèves gérés par la coop
    """
    classe = models.ForeignKey('Classes', verbose_name=_("Classe"),
                               on_delete=models.CASCADE)
    date_inscription_coop = DateField(
        verbose_name = _("Date d'inscription à la coop"), null=True, blank=True )
    no_caution = IntegerField(
        verbose_name = _("N° de la caution"), default=MIN_NO_CAUTION)
    a_verifier = BooleanField(
        verbose_name = _("À vérifier manuellement"), default=True)
    autorisation_provisoire_inscription = DateTimeField(
        verbose_name = _("Autorisation provisoire de s'inscrire"),
        blank=True, null=True)
    classes_passees = CharField(
        verbose_name = _("Classes passées"), max_length=50, default="")

    class Meta:
        verbose_name_plural = "Élèves"

    @property
    def caution_str(self):
        """
        Affiche le numéro de caution, ou "sans caution", ou "à définir", ou
        "rendue le..." selon le cas de figure
        """
        if self.no_caution > 0:
            cautions = Caution.objects.filter( numero = self.no_caution)
            if cautions:
                caution_instance = cautions[0]
                if caution_instance.somme_payee > 0:
                    return f" n° {self.no_caution}"
                else:
                    return f"sans caution (n° {self.no_caution})"
        return "(0) à définir"

    def __str__(self, livres=False):
        """
        rendu sous forme de chaîne de caractères
        @param livres (False par défaut) ; s'il est True, on affiche aussi
               le nombre de livres dûs s'il y en a.
        """
        # par defaut quand livres == False
        doit =""
        rep = '{Nom_de_famille} {Prenom} ({Lib_Structure}) {caution}{doit}'
        # seulement si livres == True
        if livres:
            livresdus = Prets.faits_a(self)
            if len(livresdus) == 1:
                doit = " doit 1 livre"
            elif livresdus:
                doit = f" doit {len(livresdus)} livres"
            
        return rep.format(caution=self.caution_str, doit=doit, **self.__dict__)

    def dus_codes(self):
        """
        Renvoie une liste des codes d'inventaire des livres dus
        """
        return [p.inventaire.id for p in Prets.faits_a(self)]
        

    @property
    def sans_caution(self):
        """
        Renvoie vrai si on inscrit l'élève sans caution, c'est à dire avec
        une dispense de payer le prix d'une caution
        """
        if self.no_caution == 0 or self.a_verifier:
            return False
        return Caution.objects.get( numero = self.no_caution ).somme_payee == 0

    @property
    def caution_rendue(self):
        """
        Renvoie vrai si la caution a été rendue
        """
        if self.no_caution == 0 or self.a_verifier:
            return False
        return Caution.objects.get(
            numero = self.no_caution).rendue_le > DATE111
        
    @property
    def peut_recevoir_carte_membre(self):
        """
        Decide s'il est opportun d'imprimer la carte de membre.
        Pour cela : l'élèves doit avoir un numéro de caution, et
        l'enregistrement dans le cahier de cautions doit dater de
        moins de neuf mois.
        """
        if self.no_caution < MIN_NO_CAUTION:
            return False
        return self.a_verifier or Caution.objects.get(
            numero = self.no_caution).date + NEUF_MOIS > datetime.now().date()

    @property
    def sorte_de_caution(self):
        """
        si 0 <= no_caution < MIN_NO_CAUTION alors c'est un élève pour lequel
        la caution était gérée manuellement
    
        sinon si no_caution >= MIN_NO_CAUTION, le numéro de caution a été
        attribué automatiquement
        
        sinon si no_caution < 0, il s'agit d'un élève à qui on a rendu
        la caution il sera effacé sauf s'il n'a pas rendu tous ses
        manuels à la prochaine importation siècle
        @return selon le cas, "ordinaire", ou "ancienne", ou "finie"
        """
        if self.no_caution < 0 :
            return "finie"
        elif self.no_caution < MIN_NO_CAUTION :
            return "ancienne"
        else:
            return "ordinaire"
        
    @property
    def peut_avoir_des_manuels(self):
        """
        décide selon la classe de l'élève si on peut lui prêter des
        manuels et faire une inscription dans le livre de cautions
        @return True si on peut prêter des livres, False sinon
        """
        if self.Lib_Structure is None:
            self.Lib_Structure = NOCLASS
        return len(
            Classes.classes_importantes().filter(libelle = self.Lib_Structure))

    @property
    def doit(self):
        """
        appel de __str__ avec livres = True, pour ajouter le nombre de 
        livres dus.
        """
        return self.__str__(livres=True)


    def save(self, *args, **kwargs):
        """
        Avant la sauvegarde automatique, on attribue un numero de
        caution automatique, et on vérifie si le champ NomPrenom est
        à la fois correct et unique, on calcule le champ sans_accent
        puis on calcule le champ Lib_Structure,
        et on met à jour le champ classes_passees; enfin on fait une
        inscription dans le cahier de cautions
        """
        # les numéros de caution des élèves "déjà là" en juin 2023 sont mis
        # à zéro dans la base ...
        # ... ce qui signifie qu'il convient de les traiter de façon spéciale
        #####################################################################
        # On attribue un numéro de caution unique aux élèves nouveaux
        #####################################################################
        # recette lue depuis https://stackoverflow.com/questions/74260809/...
        # ...django-auto-increment-field-non-pk
        #####################################################################
        # self._state.adding signifie que l'enregistrement reste à faire
        if self._state.adding and self.no_caution == MIN_NO_CAUTION:
            # no_caution possède la valeur par défaut, il faut donc
            # la modifier pour faire un nouveau numéro de caution
            #########################################################
            self.no_caution = Caution.next_no_caution()
            self.date_inscription_coop = datetime.now().date()
            ###################################################
            # on s'occupe de NomPrenom
            ###################################################
            nomprenom_actuel = self.NomPrenomEleve
            nomprenom_voulu = self.Nom_de_famille + " " + self.Prenom
            # on ne fait rien si on tombe déjà sur nomprenom_actuel, mais
            # on s'inquiète si ce n'est pas le cas
            nomprenom_nouveau = nomprenom_voulu
            n = 1
            if nomprenom_voulu != nomprenom_actuel:
                deja_la = Eleves.objects.filter(NomPrenomEleve = nomprenom_nouveau)
                # on insiste pour que nomprenom_nouveau soit unique dans la base
                while len(deja_la) > 0:
                    # il y a déjà un enregistrement avec
                    # NomPrenom == nomprenom_nouveau
                    nomprenom_nouveau = nomprenom_voulu + str(n)
                    n +=1
                    deja_la = Eleves.objects.filter(
                        NomPrenomEleve = nomprenom_nouveau)
                # à ce stade, nomprenom_nouveau est unique, on l'utilise donc
                self.NomPrenomEleve = nomprenom_nouveau
        self.calcule_sans_accent()
        # ici on synchronise Lib_Structure sur la clé étrangère classe
        if hasattr(self, "classe") and self.classe:
            self.Lib_Structure = self.classe.libelle
        # puis on s'occupe de classes_passees
        cp = [c for c in str(self.classes_passees).split(",") if c.strip()\
              and c != NOCLASS]
        if self.Lib_Structure not in cp:
            cp.append(str(self.Lib_Structure))
            self.classes_passees = ",".join(cp)
        # il reste à faire une inscription dans le livre des cautions
        # mais pas si l'élève reste à vérifier, ou si sa caution est
        # déjà dans le cahier de cautions.
        # mais aussi, on ne fait ça que pour certaines classes !!!
        if not self.a_verifier and self.no_caution != 0 and \
           self.peut_avoir_des_manuels:
            trouve = Caution.objects.filter(numero = self.no_caution)
            if len(trouve) == 0:
                ligne = Caution(nom = self.Nom_de_famille,
                                prenom = self.Prenom,
                                numero = self.no_caution,
                                sans_accent = self.sans_accent,
                                classe = (self.Lib_Structure or ""),
                                date = datetime.now().date())
                ligne.save()
        super(Eleves, self).save(*args, **kwargs)

        if self.peut_avoir_des_manuels:
            # l'inscription dans le livre des cautions ne se fait que quand
            # l'élève est en mesure est en mesure de recevoir des manuels
            la_caution = Caution.fromEleve(self)
            if not Caution.objects.filter(
                    nom = la_caution.nom, prenom = la_caution.prenom, \
                    numero = la_caution.numero):
                # la caution n'était pas encore dans le livre de cautions
                # il faut donc l'y inscrire
                la_caution.save()
        return
    
    @staticmethod
    def sans_prets_en_cours(classe = ""):
        """
        Détermine les élèves non concernés par un prêt sans date de retour
        et sans date de perte.
        @param classe un des classes possibles (facultatif)
        @return une instance de QuerySet
        """
        filter = Q(prets__date_retour = DATE111) & \
            Q(prets__Date_Declaration_Perte = DATE111)
        qset = Eleves.objects.annotate(
            prets_en_cours = Count(
                'prets',
                filter = filter,
            )
        ).filter(prets_en_cours = 0)
        if classe:
            qset = qset.filter(Lib_Structure__icontains = classe)
        return qset.order_by('Lib_Structure','NomPrenomEleve')

    @staticmethod
    def membres(classe = ""):
        """
        Détermine les élèves membres de la coop (il suffit qu'il y aient
        un prêt)
        @param classe un des classes possibles (facultatif)
        @return une instance de QuerySet
        """
        qset = Eleves.objects.annotate(
            prets_count = Count('prets')
        ).filter(prets_count__gt = 0)
        if classe:
            qset = qset.filter(Lib_Structure__iexact = classe)
        else:
            qset = qset.filter(~Q(Lib_Structure = "PAS_DE_CLASSE"))
        return qset.order_by('Lib_Structure','NomPrenomEleve')

    
            
    @staticmethod
    def avec_prets_en_cours(classe = ""):
        """
        Détermine les élèves concernés par un prêt sans date de retour
        ou sans date de perte.
        @param classe un des classes possibles (facultatif)
        @return une instance de QuerySet
        """
        filter = Q(prets__date_retour = DATE111) & \
            Q(prets__Date_Declaration_Perte = DATE111)
        qset = Eleves.objects.annotate(
            prets_en_cours = Count(
                'prets',
                filter = filter,
            )
        ).filter(prets_en_cours__gt = 0)
        if classe:
            qset = qset.filter(Lib_Structure__iexact = classe)
        return qset.order_by('Lib_Structure','NomPrenomEleve')

    @property
    def categories(self):
        """
        Renvoie une liste de catégories auxquelles un élève appartient
        """
        result = []
        result.append(Classes.to_categorie(self.Lib_Structure))
        # on ajoute des synonymes aux catégories, nécessaire à cause de
        # l'ambiguïté 2NDE / 2D
        if "2NDE" in result:
            result.append("2D")
        return result
    
    @property
    def num_prets(self):
        """
        Renvoie le nombre de livres actuellement prêtés à l'élève
        """
        return len(Prets.objects.filter(
            eleve = self, date_retour = DATE111,
            Date_Declaration_Perte = DATE111))

    def livres_du_tc(self, tabous = {}):
        """
        Renvoie la liste des livres du tronc commun que l'élève doit
        récupérer. Les références des livres viennent du modèle
        Matériel ; de la classe d'affectation de l'élève, on déduit
        des catégories auxquelles il appartient, soit en passant par
        le modèle Niveau, soit directement à partir du nom de sa classe ;
        on filtre le modèle Materiel en ne gardant que les livres sans
        "option dans siècle", c'est à dire dont le champ lib_option est
        vide.
        @param tabous un dictionnaire nom de la classe => ids de livres
        qui permet par exemple d'interdire le livre de philo blanc pour
        certaines terminales et le livre de philo marron pour d'autres
        """
        # on fait une liste de tous les livres dont le booléen tronc est vrai
        # et qui n'ont rien dans lib_option c'est à dire comme option siècle
        # et qui ne soient pas déclassés
        qs = Materiel.objects.filter(
            lib_option="", tronc=True, declasse=DATE111).order_by(
                "-Annee_Depot_Legal", "titre")
        manuels = list(qs)
        # on inclut déjà les livres "pour tous" qui n'ont pas de catégories
        result = [l for l in manuels if l.categories == ""]
        # on ajoute les livres dans les catégories propres ont une intersection
        # non vide avec les catégories de l'élève
        for l in manuels:
            if set([c.strip() for c in l.categories.split(',')]) & \
               set(self.categories):
                # on contrôle que le livre ne soit pas tabou
                if str(l.id) in tabous.get(self.Lib_Structure, ""):
                    continue
                result.append(l)
        return result
        
    def livres_opt(self, tabous = {}):
        """
        Renvoie la liste des livres des options, langues et
        spécialités que l'élève doit récupérer. Les références des
        livres viennent du modèle Matériel ; on filtre le modèle
        Materiel en gardant les livres dont le champ lib_option est
        dans les matières enseignées de l'élève. On supprime les livres dont
        le champ tronc est vrai, comme ils font partie du tronc commun.
        @param tabous un dictionnaire nom de la classe => ids de livres
        qui permet par exemple d'interdire le livre de philo blanc pour
        certaines terminales et le livre de philo marron pour d'autres
        """
        # on fait une liste de tous les livres dont le booléen tronc est faux
        # et dont le champ lib_option est dans l'itérable
        # self.mat_enseignees
        qs = Materiel.objects.filter(
            lib_option__in=self.mat_enseignees, tronc=False). \
            order_by("-Annee_Depot_Legal", "titre")
        manuels = list(qs)
        # on inclut déjà les livres "pour tous" qui n'ont pas de catégories
        result = [l for l in manuels if l.categories == ""]
        # on ajoute les livres dans les catégories propres ont une intersection
        # non vide avec les catégories de l'élève
        for l in manuels:
            if set([c.strip() for c in l.categories.split(',')]) & \
               set(self.categories):
                # on contrôle que le livre ne soit pas tabou
                if str(l.id) in tabous.get(self.Lib_Structure, ""):
                    continue
                result.append(l)
        return result
        
les_modeles['Eleves'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T01_BASE ELEVE.csv',
    'fieldtypes': {
        'Id_Elève': {'int': 'id'},
        'CléClasse': {'foreign': ('classe', 'Classes')},
        'Sexe': {'text': 'sexe'},
        'Elève No Etab': {'text': 'Eleve_No_Etab'},
        'Num Elève Etab': {'text': 'Num_Eleve_Etab'},
        'Nom': {'text': None},
        'Nom de famille': {'text': 'Nom_de_famille'},
        "Nom d'usage": {'text': 'Nom_d_usage'},
        'Prénom': {'text': 'Prenom'},
        'Prénom 2': {'text': 'Prenom_2'},
        'Prénom 3': {'text': 'Prenom_3'},
        'NomPrénomElève': {'text': 'NomPrenomEleve'},
        'Date Naissance': {'date': 'Date_Naissance'},
        'Doublement': {'text': None},
        'Id National': {'text': 'Id_National'},
        'Date Entrée': {'date': 'Date_Entree'},
        'Date Sortie': {'date': 'Date_Sortie'},
        'Code Régime': {'text': 'Code_Regime'},
        'Lib Régime': {'text': 'Lib_Regime'},
        'Code MEF': {'text': 'Code_MEF'},
        'Lib MEF': {'text': 'Lib_MEF'},
        'Code Structure': {'text': 'Code_Structure'},
        'Type Structure': {'text': 'Type_Structure'},
        'Lib Structure': {'text': 'Lib_Structure'},
        'Clé Gestion Mat Enseignée 1': {'text': 'Cle_Gestion_Mat_Enseignee_1'},
        'Lib Mat Enseignée 1': {'text': 'Lib_Mat_Enseignee_1'},
        'Code Modalité Elect 1': {'text': 'ignore'},
        'Lib Modalité Elect 1': {'text': 'ignore'},
        'Clé Gestion Mat Enseignée 2': {'text': 'Cle_Gestion_Mat_Enseignee_2'},
        'Lib Mat Enseignée 2': {'text': 'Lib_Mat_Enseignee_2'},
        'Code Modalité Elect 2': {'text': 'ignore'},
        'Lib Modalité Elect 2': {'text': 'ignore'},
        'Clé Gestion Mat Enseignée 3': {'text': 'Cle_Gestion_Mat_Enseignee_3'},
        'Lib Mat Enseignée 3': {'text': 'Lib_Mat_Enseignee_3'},
        'Code Modalité Elect 3': {'text': 'ignore'},
        'Lib Modalité Elect 3': {'text': 'ignore'},
        'Clé Gestion Mat Enseignée 4': {'text': 'Cle_Gestion_Mat_Enseignee_4'},
        'Lib Mat Enseignée 4': {'text': 'Lib_Mat_Enseignee_4'},
        'Code Modalité Elect 4': {'text': 'ignore'},
        'Lib Modalité Elect 4': {'text': 'ignore'},
        'Clé Gestion Mat Enseignée 5': {'text': 'Cle_Gestion_Mat_Enseignee_5'},
        'Lib Mat Enseignée 5': {'text': 'Lib_Mat_Enseignee_5'},
        'Lib Mat Enseignée 6': {'text': 'Lib_Mat_Enseignee_6'},
        'Lib Mat Enseignée 7': {'text': 'Lib_Mat_Enseignee_7'},
        'Lib Mat Enseignée 8': {'text': 'Lib_Mat_Enseignee_8'},
        'Lib Mat Enseignée 9': {'text': 'Lib_Mat_Enseignee_9'},
        'Lib Mat Enseignée 10': {'text': 'Lib_Mat_Enseignee_10'},
        'Tél Personnel': {'text': 'Tel_Personnel'},
        'Tél Professionnel': {'text': 'Tel_Professionnel'},
        'Tél Portable': {'text': 'Tel_Portable'},
        'Email': {'text': None},
        'Pays': {'text': 'ignore'},
        'Pays Nat': {'text': 'ignore'},
        'Ville Naiss Etrangère': {'text': 'ignore'},
        'Commune Naiss': {'text': 'ignore'},
        'Pays Naiss': {'text': 'ignore'},
        'Ligne Adresse 1': {
            'function':
            'concatTo("Adresse","Ligne_Adresse_*","Lib_Postal",'+ \
            '"Code_Postal","Departement","Commune_Etrangere","Pays")'
        },
        'Ligne Adresse 2': {'text': 'ignore'},
        'Ligne Adresse 3': {'text': 'ignore'},
        'Ligne Adresse 4': {'text': 'ignore'},
        'Lib Postal': {'text': 'ignore'},
        'Code Postal': {'text': 'ignore'},
        'Département': {'text': 'ignore'},
        'Commune Etrangère': {'text': 'ignore'},
        'Civilité Repr Lég': {'text': 'Civilite_Repr_Leg'},
        'Nom Repr Lég': {'text': 'Nom_Repr_Leg'},
        'Nom de famille Repr Lég': {'text': 'Nom_de_famille_Repr_Leg'},
        "Nom d'usage Repr Lég": {'text': 'Nom_d_usage_Repr_Leg'},
        'Prénom Repr Lég': {'text': 'Prenom_Repr_Leg'},
        'ConcaReprLég': {'text': 'ConcaReprLeg'},
        'Tél Personnel Repr Lég': {'text': 'Tel_Personnel_Repr_Leg'},
        'Lien Parenté Repr Lég': {'text': 'Lien_Parente_Repr_Leg'},
        'Tél Portable Repr Lég': {'text': 'Tel_Portable_Repr_Leg'},
        'Tél Professionnel Repr Lég': {'text': 'Tel_Professionnel_Repr_Leg'},
        'Email Repr Lég': {'text': 'Email_Repr_Leg'},
        'Communication Adresse postale et courriel Repr Lég': {
            'text': 'Communication_Adresse_postale_et_courriel_Repr_Leg'},
        'Ligne Adresse 1 Repr Lég': {
            'function':
            'concatTo("Adresse_Repr_Leg","Ligne_Adresse_*_Repr_Leg",' + \
            '"Lib_Postal_Repr_Leg","Code_Postal_Repr_Leg",' + \
            '"Code_Departement_Repr_Leg","Commune_Etrangere_Repr_Leg",' + \
            '"Pays_Repr_Leg")'
        },
        'Ligne Adresse 2 Repr Lég': {'text': 'ignore'},
        'Ligne Adresse 3 Repr Lég': {'text': 'ignore'},
        'Ligne Adresse 4 Repr Lég': {'text': 'ignore'},
        'Lib Postal Repr Lég': {'text': 'ignore'},
        'Code Postal Repr Lég': {'text': 'ignore'},
        'Code Département Repr Lég': {'text': 'ignore'},
        'Commune Etrangère Repr Lég': {'text': 'ignore'},
        'Pays Repr Lég': {'text': 'ignore'},
        'Civilité Autre Repr Lég': {'text': 'Civilite_Autre_Repr_Leg'},
        'Nom Autre Repr Lég': {'text': 'Nom_Autre_Repr_Leg'},
        'Nom de famille Autre Repr Lég': {
            'text': 'Nom_de_famille_Autre_Repr_Leg'},
        "Nom d'usage Autre Repr Lég": {'text': 'Nom_d_usage_Autre_Repr_Leg'},
        'Prénom Autre Repr Lég': {'text': 'Prenom_Autre_Repr_Leg'},
        'ConcaAutreReprLég': {'text': 'ConcaAutreReprLeg'},
        'Tél Personnel Autre Repr Lég': {
            'text': 'Tel_Personnel_Autre_Repr_Leg'},
        'Lien Parenté Autre Repr Lég': {'text': 'Lien_Parente_Autre_Repr_Leg'},
        'Tél Portable Autre Repr Lég': {'text': 'Tel_Portable_Autre_Repr_Leg'},
        'Tél Professionnel Autre Repr Lég': {
            'text': 'Tel_Professionnel_Autre_Repr_Leg'},
        'Email Autre Repr Lég': {'text': 'Email_Autre_Repr_Leg'},
        'Communication Adresse postale et courriel Autre Repr Lég': {
            'text': 'Communication_Adresse_postale_et_courriel_Autre_Repr_Leg'},
        'Ligne Adresse 1 Autre Repr Lég': {
            'function':
            'concatTo("Adresse_Autre_Repr_Leg",' + \
            '"Ligne_Adresse_*_Autre_Repr_Leg","Lib_Postal_Autre_Repr_Leg",' + \
            '"Code_Postal_Autre_Repr_Leg", "Code_Departement_Autre_Repr_Leg",' + \
            ' "Commune_Etrangere_Autre_Repr_Leg", "Pays_Autre_Repr_Leg")'},
        'Ligne Adresse 2 Autre Repr Lég': {'text': 'ignore'},
        'Ligne Adresse 3 Autre Repr Lég': {'text': 'ignore'},
        'Ligne Adresse 4 Autre Repr Lég': {'text': 'ignore'},
        'Lib Postal Autre Repr Lég': {'text': 'ignore'},
        'Code Postal Autre Repr Lég': {'text': 'ignore'},
        'Code Département Autre Repr Lég': {'text': 'ignore'},
        'Commune Etrangère Autre Repr Lég': {'text': 'ignore'},
        'Pays Autre Repr Lég': {'text': 'ignore'}
    }
}

class Eleves_de_siecle(Eleves_abstract):
    """
    Une classe pour faire une table temporaire des élèves issus d'un
    import de SIÈCLE. Il n'y a pas de redéfinition de fonctions, comme
    la fonction save...
    """

    def __str__(self):
        return '''{Nom_de_famille} {Prenom} ({Lib_Structure})'''.format(**self.__dict__)

    class Meta:
        verbose_name_plural = "Élèves_de_siecle"

    def save(self, *args, **kwargs):
        # mise à jour du champ sans_accent
        self.calcule_sans_accent()
        super(Eleves_de_siecle, self).save(*args, **kwargs)

    @staticmethod
    def from_csv(dictReader):
        """
        Crée un générateur d'instance d'Eleves à partir d'un
        DictReader extrait d'un export SIÈCLE.
        @param dictReader un DictReader
        @result un générateur d'Eleves
        """
        def addTo(accu, ligne):
            return accu + ligne + "\n"

        dict_siecle_vers_eleves = {
            'Num. Elève Etab': 'Num_Eleve_Etab',
            'Nom de famille': 'Nom_de_famille',
            'Prénom': 'Prenom',
            'Prénom 2': 'Prenom_2',
            'Prénom 3': 'Prenom_3',
            'Lib. Mat. Enseignée 1': 'Lib_Mat_Enseignee_1',
            'Lib. Mat. Enseignée 2': 'Lib_Mat_Enseignee_2',
            'Lib. Mat. Enseignée 3': 'Lib_Mat_Enseignee_3',
            'Lib. Mat. Enseignée 4': 'Lib_Mat_Enseignee_4',
            'Lib. Mat. Enseignée 5': 'Lib_Mat_Enseignee_5',
            'Lib. Mat. Enseignée 6': 'Lib_Mat_Enseignee_6',
            'Lib. Mat. Enseignée 7': 'Lib_Mat_Enseignee_7',
            'Lib. Mat. Enseignée 8': 'Lib_Mat_Enseignee_8',
            'Lib. Mat. Enseignée 9': 'Lib_Mat_Enseignee_9',
            'Lib. Mat. Enseignée 10': 'Lib_Mat_Enseignee_10',
            'Code Structure': 'Lib_Structure',
            'Tél. Portable': 'Tel_Portable',
            'Tél. Portable Repr. Lég. ': 'Tel_Portable_Repr_Leg',
            'Tél. Portable Autre Repr. Lég. ': 'Tel_Portable_Autre_Repr_Leg',
            'Ligne Adresse 1 Repr. Lég. ': addTo,
            'Ligne Adresse 2 Repr. Lég. ': addTo,
            'Ligne Adresse 3 Repr. Lég. ': addTo,
            'Ligne Adresse 4 Repr. Lég. ': addTo,
            'Lib. Postal Repr. Lég. ': addTo,
            'Code Postal Repr. Lég. ': addTo,
            'Pays Repr. Lég. ': addTo,
        }
        # liste des secondes, premières et terminales
        classes_importantes = [c.libelle for c in Classes.classes_importantes()]
        for dict in dictReader:
            if dict['Code Structure'] not in classes_importantes:
                # on ne considère que les élèves des classes de seconde,
                # première et terminale.
                continue
            args = {}             # arguments pour l'instace d'Eleves
            Adresse_Repr_Leg = "" # on vide l'accumulateur
            for k in dict:
                if k not in dict_siecle_vers_eleves:
                    # certaines entrées comme 'Lib. Mat. Enseignée 6'
                    # et les suivantes, sont ignorées
                    continue
                if dict_siecle_vers_eleves[k] == addTo:
                    # les champs de lignes d'adresse sont accumulés
                    Adresse_Repr_Leg = addTo (Adresse_Repr_Leg,dict[k])
                else:
                    # les autres champs sont récupérés sans modification
                    args[dict_siecle_vers_eleves[k]] = dict[k]
            # à la fin, on récupère l'accumulateur pour l'adresse multi-ligne
            args['Adresse_Repr_Leg'] = re.sub(
                r"\n+", "\n", Adresse_Repr_Leg).strip()
            # valeur par défaut des la classe = 'PAS DE CLASSE'
            if 'Lib_Structure' not in args or not args['Lib_Structure']:
                args['Lib_Structure'] = 'PAS DE CLASSE'
            result = Eleves_de_siecle(**args)
            yield result
        return

class Materiel(models.Model):
    """
    Ici, la documentation de la classe Materiel.
    
    Chaque instance décrit des manuels ou matériels scolaires dont
    la coopérative achète une série.
    Plusieurs champs permettent la description du matériel.
    Quand le champ de date éventuelle de déclassement est renseigné, ça
    signifie que la coopérative se sépare de cette référence
    (qui va en déchetterie) ; à mesure que des séries de matériel sont
    déclassés, il faut annuler les prêts correspondants.
    """
    # utilisé dans Inventaire.materiel et Details.materiel
    id = IntegerField(primary_key = True)
    type_materiel = models.ForeignKey(
        'TypeMateriel', verbose_name = _("Type de Matériel"),
        on_delete=models.CASCADE)
    titre = CharField(max_length=50, verbose_name = _("Titre (ou nom)") )
    editeur = CharField(max_length=25, verbose_name = _("Editeur ou Marque") )
    Prix = CharField(
        max_length=15, verbose_name = _("Prix du Materiel") ,
        blank = True, null = True)
    Annee_Depot_Legal = CharField(
        max_length=5, verbose_name = _("Année du Dépôt Légal"),
        null = True, blank = True )
    ISBN = CharField(max_length=25, verbose_name = _("ISBN") )
    Collection = CharField(
        max_length=25, verbose_name = _("Collection"), null = True, blank = True )
    Auteurs = TextField(verbose_name = _("Auteurs"), null = True, blank = True)
    discipline = models.ForeignKey(
        'Disciplines', verbose_name = _("Discipline"), on_delete=models.CASCADE)
    niveau = models.ForeignKey(
        'Niveaux', verbose_name = _("Niveau"), on_delete=models.CASCADE)
    Serie_loc = CharField(
        max_length=10, verbose_name = _("Série (locale)"),
        blank = True, null = True )
    Option = CharField(
        max_length=10, verbose_name = _("Option") , blank = True, null = True )
    filiere = models.ForeignKey(
        'Filieres', verbose_name = _("Filière"), on_delete=models.CASCADE)
    serie = models.ForeignKey(
        'Series', verbose_name = _("Série"), on_delete=models.CASCADE)
    Pretes =  models.IntegerField(
        verbose_name = _("nombre de manuels prêtés"), blank=True, null=True)
    Achetes =  models.IntegerField(
        verbose_name = _("nombre de manuels total achetés"), blank=True, null=True)
    Perdus = models.IntegerField(
        verbose_name = _("nombre de manuels perdus"), blank=True, null=True)
    epaisseur_mm = models.IntegerField(
        verbose_name = _("épaisseur en mm"), default = 15)
    largeur_cm = models.IntegerField(
        verbose_name = _("largeur en cm"), default = 20)
    hauteur_cm = models.IntegerField(
        verbose_name = _("hauteur en cm"), default = 30)
    categories = CharField(
        max_length=25, verbose_name = _("Pour les categories"), default="",
        blank=True )
    tronc = BooleanField(verbose_name = _("Tronc commun"), default = False)
    abrege = CharField(
        max_length=20, verbose_name = _("Abrégé"), default="", blank=True )
    lib_option = CharField(
        max_length=20, verbose_name = _("Option dans SIECLE"), default="",
        blank=True )
    declasse = DateField(verbose_name = _("Déclassé, le"), default=DATE111 )

    def __str__(self):
        return f'{self.titre} [{self.abrege} {self.editeur}, ' \
            f'{self.Annee_Depot_Legal}], pour les ' + \
            f'{self.categories}{" (TC)" if self.tronc else ""}'

    @property
    def est_declasse(self):
        return self.declasse > DATE111
    
    @property
    def m3(self):
        """
        Renvoie le volume en mètre cube
        """
        return self.largeur_cm * self.hauteur_cm * self.epaisseur_mm * 1e-7
    
    @property
    def abrege_html(self):
        """
        Remplace les espaces par des <br>
        """
        return self.abrege.replace(" ", "<br>").upper()

    @property
    def is_poche(self):
        """
        vrai si c'est un livre à lire
        """
        return self.abrege.lower().strip() == 'poche'
    
    @property
    def float_prix(self):
        return float(self.Prix.replace(",", "."))

    @staticmethod
    def refait_inventaire(verbose = False, id_materiel = None):
        """
        Remise à jour des champs Pretes et Achetes
        @param verbose si vrai, des messages sont émis ; faux par défaut.
        @param id_materiel permet de faire l'inventaire pour un seul titre,
          s'il est défini ; None par défaut
        """
        mats = Materiel.objects.all()
        if id_materiel:
            mats = mats.filter(id = id_materiel)
        for m in mats:
            pretes = 0
            achetes = 0
            perdus = 0
            if verbose:
                print(
                    "Inventaire de {titre}".format(
                        **m.__dict__),
                    end=" ... ")
                sys.stdout.flush()
            stock = m.inventaire_set.all()
            perte = Inventaire.objects.filter(materiel__id = m.id, Perdu = True)
            achetes = len(stock)
            perdus = len(perte)
            if verbose:
                print("achetés : {achetes}".format(achetes=achetes), end = "")
                sys.stdout.flush()
            pretes = len([manuel for manuel in stock \
                          if manuel.actuellement_a()]) - perdus
            if verbose:
                print(", prêtés : {pretes} ; perdus : {perdus}".\
                      format(pretes=pretes, perdus=perdus))
            m.Pretes = pretes
            m.Achetes = achetes
            m.Perdus = perdus
            m.save()
        ## fin de l'inventaire
        if verbose:
            print("===== inventaire terminé =====")
        return

    @property
    def nombre_etiquetes(self):
        """
        Renvoie le nombre de manuels qui onté été commandés et étiquetés,
        qui sont donc dans l'inventaire.
        """
        liv = Inventaire.objects.filter(materiel = self)
        return len(liv)

les_modeles['Materiel'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T1060_Catalogue_Materiel.csv',
    'fieldtypes': {
        'Id_Catalogue': {'int': 'id'},
        'Prix_Materiel': {'text': 'Prix'},
        'ISBN': {'text': None},
        'Collection': {'text': None},
        'Auteurs': {'text': None},
        'Option': {'text': None},
        'Clé_Type_Matériel': {'foreign': ('type_materiel', 'TypeMateriel')},
        'Titre_Désignation_Materiel': {'text': 'titre'},
        'Editeur_Marque_Matériel': {'text': 'editeur'},
        'Année_Dépôt_Légal': {'text': 'Annee_Depot_Legal'},
        'Clé_Disc': {'foreign': ('discipline', 'Disciplines')},
        'Clé_Niveau': {'foreign': ('niveau', 'Niveaux')},
        'Série': {'text': 'Serie_loc'},
        'Clé_Filière': {'foreign': ('filiere', 'Filieres')},
        'Clé_Série': {'foreign': ('serie', 'Series')}
    }
}

class Inventaire(models.Model):
    """
    Ici, la documentation de la classe Inventaire ; chaque instance de
    Inventaire est un manuel (ou un matériel) physique, qui a été acheté
    un jour donné, qui est numéroté, peut être perdu, mis au pilon (détruit),
    et dont l'étiquette à code-barre peut être imprimée. Il a aussi un
    attribut (utilisé de façon facultative) indiquant son état, plus ou
    moins bon.
    Enfin, un attribut permet de signaler si le manuel a été payé (après une
    perte) par l'élève à qui on avait fait un prêt.
    """
    id = IntegerField(primary_key = True)
    
    materiel = models.ForeignKey(
        'Materiel', on_delete=models.CASCADE, null=True, blank=True)
    # !!!! dangereux : null=True, blank=True
    
    Date_Achat_Materiel = DateField(verbose_name = _("Date d'Achat du Materiel") )
    NumeroDeSerie = CharField(max_length=25, verbose_name = _("Numero de Série") )
    Perdu = BooleanField(verbose_name = _("Perdu"), default=False )
    pilon = DateField(verbose_name = _("Mis au pilon, le"), default=DATE111 )
    Etat_Materiel = IntegerField(
        verbose_name = _("État du matériel"), choices = ETAT_MATERIEL_CHOIX,
        default = etat_AUTRE)
    a_imprimer = BooleanField(verbose_name = _("à imprimer"), default=True )
    paye = models.ForeignKey(
        'Rachat', on_delete=models.SET_NULL, blank = True, null = True)

    class Meta:
        verbose_name_plural = "Inventaire"

    def __str__(self):
        return '''{titre} | acheté le : {Date_Achat_Materiel}'''.format(
            titre = str(self.materiel),
            Date_Achat_Materiel = self.Date_Achat_Materiel)

    @staticmethod
    def non_pretes_qs():
        """
        @return un queryset sur les livres non prêtés
        """
        return Inventaire.objects.filter(~Q(prets__date_retour = DATE111))
    
    @staticmethod
    def pretes_qs():
        """
        @return un queryset sur les livres prêtés
        """
        return Inventaire.objects.filter(Q(prets__date_retour = DATE111))
    

    @property
    def m3(self):
        """
        Renvoie le volume en mètre cube
        """
        return self.materiel.m3 if self.materiel else 0.0

    @property
    def est_au_pilon(self):
        return self.pilon > DATE111
    
    @property
    def etat(self):
        return DICT_ETAT_MATERIEL_CHOIX[self.Etat_Materiel]

    @property
    def tarif(self):
        """
        Détermine une pénalité à payer par l'élève, pour la date courante
        """
        anciennete = date.today() - self.Date_Achat_Materiel
        return formule_tarif(self.materiel.float_prix, anciennete)

    def actuellement_a(self):
        """
        Détermine à qui est prêté le manuel le cas échant
        @return un instance de Eleves si c'est un élève, ou
          la chaîne perdu si la perte a été enregistrée, ou encore None
          qui signifie que le manuel est censé être en stock
        """
        # Un livre prêté est relié à une attribution de matériel
        # sans date de retour enregistrée
        prets_en_cours = self.prets_set.filter(
            date_retour = DATE111, Date_Declaration_Perte = DATE111)
        result = None
        if prets_en_cours:
            result = prets_en_cours[0].eleve
        else:
            if self.Perdu:
                result = "perdu"
        return result

les_modeles['Inventaire'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T1061_Inventaire_Materiel.csv',
    'fieldtypes': {
        'Id_Inventaire_Materiel': {'int': 'id'},
        'Date_Achat_Materiel': {'date': None},
        'NumeroDeSerie': {'text': None},
        'Perdu': {'bool': None},
        'à_imprimer': {'bool': 'a_imprimer'},
        'Cle_Catalogue_Materiel': {'foreign': ('materiel', 'Materiel')},
        'Cle_Etat_Materiel' : {'choice': 'Etat_Materiel'},
    }
}

mode_CHEQUE = 1
mode_ESPECE = 2
mode_CARTE = 3
mode_HDF = 4

MODES_PAIEMENT_CHOIX = [
    (mode_CHEQUE,        gettext_lazy("par chèque")),
    (mode_ESPECE,        gettext_lazy("en espèces")),
    (mode_CARTE,         gettext_lazy("par carte")),
    (mode_HDF,           gettext_lazy("par carte du Conseil Régional")),
]

DICT_MODES_PAIEMENT_CHOIX = dict(MODES_PAIEMENT_CHOIX)

class Rachat(models.Model):
    """
    Les instance de Rachat correspondent à un paiement effectué par un
    élève, suite à la perte d'un manuel scolaire
    """
    date = DateField(verbose_name=_("Payé, le"), default=DATE111 )
    prix = FloatField(verbose_name=_("Prix"), default=0.0)
    mode = IntegerField(
        verbose_name=_("Mode de paiement"),
        choices=MODES_PAIEMENT_CHOIX,
        default = mode_CHEQUE)

    def __str__(self):
        inv = Inventaire.objects.filter(paye=self)
        eleve = Prets.objects.filter(
            inventaire=inv[0]).order_by('-date_pret')[0].eleve if inv \
            else None
        return _("racheté par {nomprenom}, le {date} pour {prix} €, paiement {mode}").format(
            nomprenom = eleve.NomPrenomEleve if eleve else "???",
            date = self.date, prix=self.prix,
            mode = DICT_MODES_PAIEMENT_CHOIX[self.mode])
    

class Details(models.Model):
    """
    Ici, la documentation de la classe Details
    """
    id = IntegerField(primary_key = True)
    materiel = models.ForeignKey('Materiel', on_delete=models.CASCADE)
    commande = models.ForeignKey('Commandes', on_delete=models.CASCADE)
    Quantite = CharField(max_length=25, verbose_name = """Quantité""" )
    Remise = CharField(max_length=25, verbose_name = """Remise""" )

    class Meta:
        verbose_name_plural = "Details"

    def __str__(self):
        return '''{Quantite}'''.format(**self.__dict__)

les_modeles['Details'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T1201_Détail_Commande.csv',
    'fieldtypes': {
        'Remise': {'text': None},
        'Id_Détail_Cmd': {'int': 'id'},
        'Clé_Catalogue': {'foreign': ('materiel', 'Materiel')},
        'Clé_Cmd': {'foreign': ('commande', 'Commandes')},
        'Quantité': {'text': None}
    }
}

class Prets(models.Model):
    """
    Ici, la documentation de la classe Prets
    """
    id = IntegerField(verbose_name = _("index") , primary_key = True)
    accompagnateur = models.ForeignKey(
        'Personnels', on_delete=models.CASCADE, blank=True, null = True)
    eleve = models.ForeignKey(
        'Eleves', on_delete=models.CASCADE, blank = True, null = True) # !!! danger
    inventaire = models.ForeignKey('Inventaire', on_delete=models.CASCADE)
    date_pret = DateField(verbose_name = _("Date de prêt") )
    date_retour = DateField(
        max_length=25, verbose_name = _("Date de retour"), blank = True,
        default = DATE111 )
    Date_Declaration_Perte = DateField(
        max_length=25, verbose_name = _("Date de déclaration de la perte"),
        blank = True, default = DATE111 )
    Etat_Initial = IntegerField(
        verbose_name = _("État Initial"), choices = ETAT_MATERIEL_CHOIX,
        null = True, default = etat_AUTRE)
    Etat_Final = IntegerField(
        verbose_name = _("État Final"), choices = ETAT_MATERIEL_CHOIX,
        blank = True, default = etat_AUTRE)
    Remise = FloatField(verbose_name = _("Remise"), default = 0 )

    class Meta:
        verbose_name_plural = "Prêts"

    def __str__(self):
        return '{eleve} | prêté le {date_pret} |rendu le {date_retour}, {manuel}'.\
            format(
            eleve = str(self.eleve),
            date_pret=self.date_pret,
            date_retour=self.date_retour,
            manuel = str(self.inventaire),
        )

    @property
    def tarif(self):
        """
        Détermine une pénalité à payer par l'élève, en cas de perte
        ou de dégradation.
        """
        valeur = float(self.inventaire.materiel.Prix.replace(",","."))
        if self.date_pret:
            anciennete = self.date_pret - self.inventaire.Date_Achat_Materiel
        else:
            anciennete = date.today() - self.inventaire.Date_Achat_Materiel
        return formule_tarif(valeur, anciennete)

    @property
    def reste_a_payer(self):
        return self.tarif if not self.inventaire.paye else 0.0

        
    @property
    def isRendu(self):
        """
        Decide si un livre est rendu
        """
        return self.date_retour is not None and \
            self.date_retour > DATE111
    
    @property
    def isDegrade(self):
        """
        Si l'état final est pire que "Bon" et qu'il est pire que l'état
        initial, alors il a eu une dégradation
        """
        return self.Etat_Final is not None  and \
            self.Etat_Final  > etat_BON and \
            self.Etat_Final > self.Etat_Initial
    
    @property
    def etat_initial(self):
        return DICT_ETAT_MATERIEL_CHOIX[self.Etat_Initial]

    @property
    def etat_final(self):
        return DICT_ETAT_MATERIEL_CHOIX[self.Etat_Final]

    ## un dictionnaire timbre à date => itérateur de manuel rendu
    iterateurs_livres = {}

    @property
    def to_dict(self):
        """
        Renvoie un dictionnaire utile pour caractériser un prêt,
        c'est utile pour les échanges de données JSON
        """
        return {
            "eleve": "{nomprenom} ({classe})".format(
                nomprenom = self.eleve.NomPrenomEleve,
                classe=self.eleve.Lib_Structure) \
            if self.eleve else "Élève inconnu",
            "titre": str(self.inventaire.materiel.\
                         titre),
            "prete": str(self.date_pret),
            "rendu": str(self.date_retour),
        }

    @property
    def est_paye(self):
        """
        Dit si un livre a été payé
        """
        return self.inventaire.paye and self.inventaire.paye.date > DATE111   
    @staticmethod
    def faits_a(eleve):
        """
        Détermine les prêts en cours faits à un élève
        @return une instance de QuerySet
        """
        # un livre perdu reste un livre dû par un élève.
        return Prets.objects.filter(eleve=eleve, date_retour = DATE111)

    @staticmethod
    def iter_rendu(date1, date2):
        """
        Renvoie un itérateur qui fournira à chaque appel le prochain
        manuel prêté et rendu, ainsi qu'un timbre à date qui correspond
        à sa création, et permet de retrouver cet itérateur plus tard.

        Par effet de bord, supprime du dictionnaire iterateurs_livres
        les itérateurs plus anciens qu'une demi-heure.

        @param date1 le prêt doit être postérieur à date1
        @param date2 le retour doit être antérieur à date2
        """
        # on commence par sélectionner les livres dont la date
        # de rendu est non vide ; le seul système qui semble fonctionner
        # c'est de compare avec le "0001-01-01"
        iterateur = Prets.objects.filter(
                date_retour__gt = DATE111)
        # puis on affine la sélection éventuellement
        if date1:
            iterateur = iterateur.filter(
                date_pret__gt = date1)
        if date2 :
            iterateur = iterateur.filter(
                date_retour__lt = date2)

        iterateur = enumerate(iterateur) # ajout d'un numéro !
        tstamp = datetime.now().isoformat()
        Prets.iterateurs_livres[tstamp] = iterateur
        # fait le ménage des itérateurs trop anciens
        demi_heure = timedelta(minutes=30)
        dates_trop_anciennes = [
            t for t in Prets.iterateurs_livres \
            if datetime.now() - datetime.fromisoformat(t) > demi_heure
        ]
        for t in dates_trop_anciennes:
            del (Prets.iterateurs_livres[t])
        # renvoie l'itérateur et sa clé d'accès
        return iterateur, tstamp
            

les_modeles['Prets'] = {
    'csvfile':
    '/home/georgesk/developpement/manuels/manuels-0.1/exemple/T1062_Attribution_Materiel.csv',
    'fieldtypes': {
        'Id_Attribution_Materiel_Prof': {'int': 'id'},
        'Cle_Personnel': {
            'foreign': ('accompagnateur', 'Personnels')
        },
        'Cle_Inventaire_Materiel': {
            'foreign': ('inventaire', 'inventaire')
        },
        'Date_Heure_Pret_Materiel': {'date': 'date_pret'},
        'Date_Heure_Retour_Materiel': {'date': 'date_retour'},
        'Remise': {'text': None},
        'Clé_Etat_Initial': {'choice': 'Etat_Initial'},
        'Clé_Etat_Final': {'choice': 'Etat_Final'},
        'Dégradation': {'float': 'Remise'},
        'Clé_Elève': {'foreign': ('eleve', 'Eleves')}
    }
}

def random_string():
    return str(random.randint(100000, 999999))

class Caution(models.Model):
    """
    Cette classe s'occupe du cahier de cautions. À chaque création d'élève
    et donc de caution, un enregistrement s'ajoute à cette table, avec sa
    date de création automatique
    """
    numero = models.IntegerField(verbose_name = _("numéro de caution"), unique=True)
    nom = models.CharField(verbose_name = _("Nom de l'élève"), max_length=50)
    prenom = CharField(max_length=25, verbose_name = _("Prénom"))
    date = DateField(verbose_name = _("Date d'inscription à la coop"))
    classe = CharField(max_length=25, verbose_name = _("Classe initiale"))
    rendue_le = DateField(verbose_name = _("Rendue le"), blank=True, default=DATE111)
    somme_payee = FloatField(verbose_name = _("Somme payée"), blank=True, default=70)
    somme_rendue = FloatField(verbose_name = _("Somme rendue"), blank=True, default=0)
    commentaire = CharField(
        max_length=140, verbose_name = _("Commentaire"), blank=True, default = "")
    sans_accent = CharField(
        max_length=50, verbose_name = _("Nom sans accents"),
        blank=True, null=True )

    def __str__(self):
        if self.somme_payee == 0:
            # Translators: N is a surname, p is a first name, d is a date
            result = _("SANS CAUTION, {N:35s}  {p:20s}, {d}").format(
                N=self.nom, p=self.prenom, d=self.date.strftime('%d/%m/%Y')
            )
        else:
            fin = " → " + self.rendue_le.strftime('%d/%m/%Y') if \
                self.rendue_le > DATE111 else \
                _(" (non rendue)")
            # Translators: N is a surname, p is a first name, c is a class name, d and f are dates
            result = _("Caution n° {n:6d}, {N:35s} {p:2s} ({c:7s}), {d}{f}")\
                .format(
                    n=self.numero, N=self.nom, p=self.prenom, c=self.classe,
                    d=self.date.strftime('%d/%m/%Y') if self.date else 'None',
                    f=fin
                )
        return result

    @property
    def nomprenom(self):
        return f"{self.nom} {self.prenom}"

    def calcule_sans_accent(self):
        """
        Crée une version sans accents, ni tirets ni apostrophes, en minuscules
        du nom/prénom, et l'affecte à self.sans_accent
        """
        self.sans_accent = sans_accent(
            self.nom + " " + self.prenom).lower()
        return

    @staticmethod
    def next_no_caution():
        """
        Renvoie à coup sûr un numéro de caution unique, supérieur aux numéros
        alloués jusque-là
        """
        # on cherche la valeur max de no_caution dans la base
        # et on affecte la valeur max + 1 à self.no_caution
        last_no_caution = Eleves.objects.all().aggregate(
            largest=models.Max('no_caution'))['largest']
        last_no_caution = max(last_no_caution or 0, MIN_NO_CAUTION)
        return last_no_caution + 1
        
    @property
    def est_valide(self):
        """
        renvoie vrai si la caution n'est pas encore rendue
        """
        return self.rendue_le == DATE111
    
    @staticmethod
    def fromEleve(e):
        """
        Récupère une caution à partir d'une instance d'Eleves
        Crée une instance si on trouve rien dans la base
        @param e une instance d'Eleves
        @return une instance de Caution
        """
        cautions = Caution.objects.filter(numero = e.no_caution)
        if cautions:
            caution = cautions[0]
        else:
            date_ = date.today()
            if e.date_inscription_coop:
                date_ = e.date_inscription_coop
            caution = Caution(
                numero = e.no_caution, nom = e.Nom_de_famille,
                sans_accent = e.sans_accent,
                prenom = e.Prenom, date = date_,
                classe = e.Lib_Structure,
        )
        return caution
    
    @property
    def commentaire_html(self):
        """
        Remplece les retours chariot par des <br> dans le texte du commentaire
        """
        return safestring.SafeString(self.commentaire.replace("\n", "<br>"))

class RevueStock(models.Model):
    """
    Cette classe permet de garder trace des actions de revue du stock ;
    pour chaque entrée de l'inventaire, un enregistrement au plus est créé
    ou remis à jour, au moment où on saisit son code
    """
    code = models.OneToOneField(
        'Inventaire', on_delete=models.CASCADE, verbose_name = _("N° de livre"),
        primary_key=True
    )
    date = DateTimeField(
        verbose_name = _("Date de la dernière vérification"), default = DATE111)
    dernier_pret = models.ForeignKey(
        "Prets", on_delete=models.CASCADE, verbose_name = _("Dernier prêt"),
        null=True, blank=True)
    prete = BooleanField(verbose_name = _("Est prêté"), default = False)

    class Meta:
        verbose_name_plural = "Revue du stock"

    def __str__(self):
        titre = self.code.materiel.titre
        prete = "non prêté" if not self.prete else \
            "prêté à " + self.dernier_pret.eleve.NomPrenomEleve + \
            " le " + self.dernier_pret.date_pret.strftime('%d/%m/%Y')
        return f"{self.code.id} : {titre} ; {prete}. " + \
            f"Flashé le {self.date.strftime('%d/%m/%Y')}"

class Jeton(models.Model):
    """
    Cette classe permet de gérer des jetons périssables. Après leur création,
    ils deviennent « invalides » aubout d'un délai réglable.
    """
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    duree = IntegerField(verbose_name = _("Durée en secondes"))
    perime = DateTimeField(verbose_name = _("Périmé après"), default = TIMEZONE111)

    def __str__(self):
        return f"""Jeton {self.id}, valable jusqu'à {self.perime} ; """ + \
            f"""Validité : {self.estValide()} ; reste : {self.aVivre()//60} minute(s)"""

    def estValide(self):
        """
        renvoie vrai si le jeton n'est pas encore périmé au moment de l'appel
        """
        return self.perime >= timezone.now()

    def aVivre(self):
        """
        Renvoie le temps qui reste à vivre en secondes
        """
        return round((self.perime - timezone.now()).total_seconds())
        
    @classmethod
    def create(cls, duree):
        """
        Création d'un jeton à la date présente, pour une durée donnée
        @param cls la classe Jeton
        @param duree la durée de vie du jeton
        """
        perime = timezone.now() + timedelta(seconds = duree)
        return cls(duree = duree, perime = perime)

    @staticmethod
    def housekeep():
        """
        Fait le ménage. Supprime les jetons déjà périmés, au moment
        de l'appel.
        """
        perimes = Jeton.objects.filter(perime__lt = timezone.now())
        perimes.delete()
        return

class LivreSupplement(models.Model):
    """
    Cette classe permet d'enregistrer temporairement la demande d'un prof
    pour des livres supplémentaires à prêter.
    """
    data = TextField(verbose_name="Demande de prêt supplémentaire")
    date = DateTimeField(auto_now_add=True, blank=True)
    commentaire = TextField(verbose_name="Motivation", null=True, blank=True)

    class Meta:
        verbose_name_plural = "Demandes de livres supplémentaires"
    
    def __str__(self):
        d = json.loads(self.data)
        return f"{d['prof']} ({self.date.strftime('%d/%m/%Y')}) : " + \
            f"{len(d['livres'])} livres, pour {len(d['eleves'])} élèves)"

class Livre_a_lire(models.Model):
    """
    assigne un livre à lire à un élève. Il faut périmer les enregistrements
    datant de plus de neuf mois ou de l'année scolaire précédente
    """
    date = DateTimeField(auto_now_add=True, blank=True)
    eleve =  models.ForeignKey(
        "Eleves", on_delete=models.CASCADE, verbose_name = _("À prêter à"))
    livre = models.ForeignKey(
        "Materiel", on_delete=models.CASCADE, verbose_name = _("Livre"))
    class Meta:
        verbose_name_plural = "Demandes de livres validées"
    
    def __str__(self):
        return f"{self.livre.titre} → {self.eleve.NomPrenomEleve} " + \
            f"({self.eleve.Lib_Structure})"

    @property
    def perime(self):
        """
        Renvoie le status périmé d'une demande ; c'est le cas si elle est
        antérieure au premier mois d'août de l'année scolaire en cours
        selon les paramètres
        """
        annee_scolaire = Parametres.objects.order_by(
            "-AnneeScolaire")[0].AnneeScolaire
        m = re.match(r"(\d+)\D.*", annee_scolaire)
        aout = timezone.make_aware(datetime(
            year = int(m.group(1)), month = 8, day= 1))
        return self.date < aout

    @staticmethod
    def del_perimes(annee_scolaire = None):
        """
        Supprime toutes les instances périmées à la date présente, selon
        l'enregistrement de l'année scolaire dans la table Parametres, ou
        selon le paramètre annee_scolaire
        @param annee_scolaire (None par défaut) une année scolaire
               par exemple "2023/24"
        """
        if annee_scolaire is None:
            # on va rechercher dans la table des paramètres
            annee_scolaire = Parametres.objects.order_by(
                "-AnneeScolaire")[0].AnneeScolaire
        m = re.match(r"(\d+)\D.*", annee_scolaire)
        aout = timezone.make_aware(datetime(
            year = int(m.group(1)), month = 8, day= 1))
        Livre_a_lire.objects.filter(date__lt = aout).delete()
        return
    
class Selector2options:
    """
    transforme un sélecteur pour base de donnée en un ensemble d'options
    dans un élément <select> pour page web
    Paramètres du constructeur :
    @param demandeur un appel de fonction ou une expression qui renvoie
    une reqête Django ;
    par exemple Classes.classes_importantes() ou Niveaux.objects.all()
    @param quel_val un attribut permettant d'extraire une valeur
    attaché à un nœud
    @param quel_text un attribut permettant d'extraire un texte
    attaché à un nœud
    @param ordre chaîne facultative, définit un ordre ; "" par défaut
    """
    def __init__(self, demandeur, quel_val, quel_text, ordre=""):
        self.demandeur = demandeur
        self.quel_val = quel_val
        self.quel_text = quel_text
        self.ordre = ordre
        return

    def vertices(self):
        """
        Renvoye une liste de propriétés de nœuds pour digraphe,
        éventuellement ordonnée
        """
        query = eval(self.demandeur)
        if self.ordre:
            query = query.order_by(self.ordre)
        return [
            {
                "val": getattr(n, self.quel_val),
                "text" : getattr(n, self.quel_text),
            }
            for n in query]

class Digraphe_select(models.Model):
    """
    Sert à spécifier quels éléments <SELECT> on peut utiliser pour
    gérer un ensemble thématique de digraphes, identifié par le champ name
    """
    name = models.CharField(
        max_length = 25, verbose_name = _("Nom"), unique=True)
    left_selector = models.CharField(
        max_length = 50, verbose_name = _("Sélecteur de cause"))
    left_val = models.CharField(
        max_length = 25, verbose_name = _("Cause : champ de valeur"))
    left_text = models.CharField(
        max_length = 25, verbose_name = _("Cause : champ de texte"))
    left_ordering = models.CharField(
        max_length = 25, verbose_name = _("Cause : champ de mise en ordre"))
    right_selector = models.CharField(
        max_length = 50, verbose_name = _("Sélecteur de effet"))
    right_val = models.CharField(
        max_length = 25, verbose_name = _("Effet : champ de valeur"))
    right_text = models.CharField(
        max_length = 25, verbose_name = _("Effet : champ de texte"))
    right_ordering = models.CharField(
        max_length = 25, verbose_name = _("Effet : champ de mise en ordre"))
    
    def __str__(self):
        return _("""Sélecteur d'options "{n}" : {l} -> {r}""").format(
            n=self.name, l=self.left_selector, r=self.right_selector)

    def lr_query(self):
        """
        produit doublet de Selector2options pour récupérer des
        données dans la base
        """
        left = Selector2options(
            self.left_selector, self.left_val, self.left_text,
            self.left_ordering).vertices()
        right = Selector2options(
            self.right_selector, self.right_val, self.right_text,
            self.right_ordering).vertices()
        return {"left": left, "right": right}

class Digraphe(models.Model):
    """
    Pour enregistrer un digraphe : chaque instance est liée à une instance de
    Digraphe_select, possède une date de création/modification et
    la source d'un digraphe en langage dot
    """

    select = models.ForeignKey(
        "Digraphe_select", on_delete=models.CASCADE,
        verbose_name = _("Sélection d'option"))
    date_creation = DateTimeField(
        auto_now_add=True, blank=True, verbose_name="créé le")
    dot_code = models.TextField(
        verbose_name = _("graphe en langage DOT"), default = "")

    def __str__(self):
        return _('Digraphe "{n}"').format(
            n=self.select.name)

    def to_svg(self):
        """
        Produit un code en langage SVG, sans headers et sans retours
        à la ligne, pour représenter graphiquement le digraphe
        """
        cp = run(
            ['dot','-T', 'svg'],
            input = self.dot_code,
            encoding='UTF-8',
            capture_output=True,
        )
        svg_code = cp.stdout
        d = minidom.parseString(svg_code)
        return d.documentElement.toxml().replace("\n", " ")

    def tabous(self):
        """
        Produit un dictionnaire cause => ensemble d'effets qui résulte
        de l'attribut dot_code
        """
        lignes = self.dot_code.split("\n")
        result = {}
        for l in lignes:
            m = re.match(r'^"l(.*)" -> "r(.*)"$', l)
            if m:
                cause = m.group(1)
                effet = m.group(2)
                if cause not in result :
                    result[cause] = []
                result[cause].append(effet)
        return result
    
