Metadata-Version: 2.4
Name: pyetnic
Version: 0.1.0b1
Summary: Access to ETNIC web services through Python
Author-email: Fabien Toune <fabien.toune@eica.be>
License-Expression: MIT
Project-URL: Homepage, https://github.com/Lapin-Blanc/pyetnic
Project-URL: Repository, https://github.com/Lapin-Blanc/pyetnic
Project-URL: Issues, https://github.com/Lapin-Blanc/pyetnic/issues
Project-URL: Changelog, https://github.com/Lapin-Blanc/pyetnic/blob/main/CHANGELOG.md
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: zeep
Requires-Dist: python-dotenv
Requires-Dist: requests
Provides-Extra: seps
Requires-Dist: xmlsec; extra == "seps"
Requires-Dist: cryptography; extra == "seps"
Provides-Extra: excel
Requires-Dist: openpyxl; extra == "excel"
Dynamic: license-file

# pyetnic

[![Tests](https://github.com/Lapin-Blanc/pyetnic/actions/workflows/tests.yml/badge.svg)](https://github.com/Lapin-Blanc/pyetnic/actions/workflows/tests.yml)

Bibliothèque Python d'accès aux services web SOAP d'[ETNIC](https://www.etnic.be/) pour l'enseignement de promotion sociale (Wallonie-Bruxelles).

## Fonctionnalités

| Namespace | Service | Opérations |
|---|---|---|
| `pyetnic.eprom` | **Formations** | Lister les formations organisables (catalogue), lister les formations organisées |
| `pyetnic.eprom` | **Organisation** | Créer, lire, modifier, supprimer une organisation de formation |
| `pyetnic.eprom` | **Document 1** | Lire, modifier, approuver le document de population (inscriptions) |
| `pyetnic.eprom` | **Document 2** | Lire, modifier le document des périodes d'activités d'enseignement |
| `pyetnic.eprom` | **Document 3** | Lire, modifier le document des attributions d'enseignants |
| `pyetnic.seps` | **Recherche étudiants** | Lire un étudiant par numéro CF, rechercher par NISS ou nom *(prod uniquement)* |
| `pyetnic.seps` | **Enregistrement étudiants** | Enregistrer un nouvel étudiant, modifier un étudiant existant *(prod uniquement)* |
| `pyetnic.seps` | **Inscriptions** | Rechercher, enregistrer, modifier des inscriptions d'étudiants *(prod uniquement)* |

## Installation

```bash
pip install pyetnic
```

Pour utiliser les services SEPS (signature X509) :

```bash
pip install pyetnic[seps]
```

## Configuration

Générer un fichier `.env` de départ :

```bash
pyetnic init-config
```

Remplir les valeurs dans `.env` :

```ini
# Environnement : dev (services-web.tq.etnic.be) ou prod (services-web.etnic.be)
ENV=dev

# Identifiants pour le développement
DEV_USERNAME=
DEV_PASSWORD=

# Identifiants pour la production
PROD_USERNAME=
PROD_PASSWORD=

# Paramètres par défaut
DEFAULT_ETABID=       # identifiant établissement (int)
DEFAULT_IMPLID=       # identifiant implantation (int)
DEFAULT_SCHOOLYEAR=2024-2025

# Certificat X509 pour les services SEPS (chemin relatif au répertoire courant)
# Fourni par ETNIC (IAM-PROD). Uniquement nécessaire pour les services SEPS.
SEPS_PFX_PATH=
SEPS_PFX_PASSWORD=
```

Les identifiants EPROM sont fournis par ETNIC pour accéder aux services web de votre établissement.
Le certificat SEPS (fichier `.pfx`) est fourni séparément par ETNIC via IAM-PROD.

### Configuration programmatique

Pour les applications qui gèrent leurs propres credentials (ex. Django), il est possible de configurer `pyetnic` sans fichier `.env` :

```python
from pyetnic.config import Config

Config.ENV = "prod"
Config.USERNAME = "my_user"
Config.PASSWORD = "my_pass"

# Optionnel : charger explicitement un fichier .env
Config.load_from_dotenv()
```

La configuration est **lazy** : rien n'est résolu à l'import. Les overrides programmatiques ont priorité sur les variables d'environnement.

---

## Namespaces

`pyetnic` expose deux namespaces publics :

- **`pyetnic.eprom`** — services EPROM (Enseignement de Promotion Sociale) : formations, organisations, documents 1/2/3.
  Authentification : WSSE UsernameToken (credentials dev ou prod).

- **`pyetnic.seps`** — services SEPS (registre des étudiants CFWB).
  Authentification : certificat X509 (PFX). Production uniquement.
  Nécessite `pip install pyetnic[seps]`.

Toutes les fonctions, modèles et exceptions publiques s'importent directement depuis ces namespaces :

```python
from pyetnic.eprom import lire_organisation, OrganisationId, EtnicBusinessError
from pyetnic.seps import lire_etudiant, NissMutationError
```

L'ancien namespace plat (`pyetnic.lire_organisation`) n'est plus documenté et ne devrait pas être utilisé dans du nouveau code.

---

## Utilisation

### Formations

```python
import pyetnic

# Formations organisables (catalogue de l'année)
result = pyetnic.eprom.lister_formations_organisables(annee_scolaire="2024-2025")
for formation in result:
    print(formation.numAdmFormation, formation.codeFormation, formation.libelleFormation)

# Formations déjà organisées (avec leurs organisations et statuts de documents)
result = pyetnic.eprom.lister_formations(annee_scolaire="2024-2025")
for formation in result:
    for org in formation.organisations:
        print(
            f"F{org.id.numAdmFormation}/org{org.id.numOrganisation}",
            org.dateDebutOrganisation, "→", org.dateFinOrganisation,
            org.statutDocumentOrganisation.statut if org.statutDocumentOrganisation else "—",
        )
```

### Organisation

```python
from datetime import date
from pyetnic.eprom import OrganisationId, lire_organisation, creer_organisation
from pyetnic.eprom import modifier_organisation, supprimer_organisation

org_id = OrganisationId(
    anneeScolaire="2024-2025",
    etabId=3052,
    numAdmFormation=455,
    numOrganisation=1,
)

# Lire
org = lire_organisation(org_id)

# Créer (numOrganisation attribué par le serveur)
org = creer_organisation(
    annee_scolaire="2025-2026",
    etab_id=3052,
    impl_id=6050,
    num_adm_formation=455,
    date_debut=date(2025, 9, 15),
    date_fin=date(2026, 6, 26),
)

# Modifier
org.dateFinOrganisation = date(2026, 6, 20)
org = modifier_organisation(org)

# Supprimer
ok = supprimer_organisation(org_id)
```

### Document 1 — Population

```python
from pyetnic.eprom import lire_document_1, modifier_document_1, approuver_document_1
from pyetnic.eprom import Doc1PopulationListSave, Doc1PopulationLineSave

doc1 = lire_document_1(org_id)
# doc1 est None si le document n'est pas accessible (statut org insuffisant)

# Modifier
liste_save = Doc1PopulationListSave(population=[
    Doc1PopulationLineSave(coAnnEtude=1, nbEleveA=12, nbEleveTotHom=5, nbEleveTotFem=7),
])
doc1 = modifier_document_1(org_id, population_liste=liste_save)

# Approuver
doc1 = approuver_document_1(org_id)
```

### Document 2 — Périodes d'activités

```python
from pyetnic.eprom import lire_document_2, modifier_document_2
from pyetnic.eprom import Doc2ActiviteEnseignementListSave, Doc2ActiviteEnseignementLineSave

doc2 = lire_document_2(org_id)

liste_save = Doc2ActiviteEnseignementListSave(activiteEnseignement=[
    Doc2ActiviteEnseignementLineSave(coNumBranche=1, nbEleveC1=15, nbPeriodePrevueAn1=32.0),
])
doc2 = modifier_document_2(org_id, activite_enseignement_liste=liste_save)
```

### Document 3 — Attributions d'enseignants

```python
from pyetnic.eprom import (
    lire_document_3, modifier_document_3,
    Doc3ActiviteListeSave, Doc3ActiviteDetailSave,
    Doc3EnseignantListSave, Doc3EnseignantDetailSave,
)

doc3 = lire_document_3(org_id)
# doc3 est None si Doc 1 et Doc 2 ne sont pas encore approuvés

liste_save = Doc3ActiviteListeSave(activite=[
    Doc3ActiviteDetailSave(
        coNumBranche=1,
        noAnneeEtude="1",
        enseignantListe=Doc3EnseignantListSave(enseignant=[
            Doc3EnseignantDetailSave(
                coNumAttribution=1,
                noMatEns="28901061314",
                teStatut="T",
                nbPeriodesAttribuees=52.0,
            )
        ]),
    ),
])
doc3 = modifier_document_3(org_id, liste_save)
```

### SEPS — Recherche et enregistrement d'étudiants

> **Prérequis :** `pip install pyetnic[seps]` + certificat `.pfx` fourni par ETNIC (IAM-PROD).
> Ces services fonctionnent **uniquement en production** (`ws.etnic.be`).

```python
from pyetnic.seps import (
    rechercher_etudiants, lire_etudiant,
    enregistrer_etudiant, modifier_etudiant,
    EtudiantDetailsSave, SepsNaissanceSave, SepsAdresseSave,
    NissMutationError,
)

# Recherche par nom
etudiants = rechercher_etudiants(nom="DUPONT", prenom="Jean")
for e in etudiants:
    details = e.rnDetails or e.cfwbDetails
    if details:
        print(e.cfNum, details.nom, details.prenom, details.naissance.date if details.naissance else "")

# Recherche par NISS (avec gestion de mutation)
try:
    etudiants = rechercher_etudiants(niss="850101-123-45")
except NissMutationError as e:
    etudiants = rechercher_etudiants(niss=e.nouveau_niss)

# Lecture par numéro CF (format : [0-9]{1,10}-[0-9]{2})
etudiant = lire_etudiant("12345678-01")
if etudiant:
    rn = etudiant.rnDetails
    print(rn.nom, rn.prenom, rn.niss)

# Enregistrement d'un étudiant (mode DETAILS = recherche par identité)
details = EtudiantDetailsSave(
    nom="DUPONT",
    prenom="Jean",
    sexe="M",
    naissance=SepsNaissanceSave(date="1985-01-15", codePays="BE"),
)
etudiant = enregistrer_etudiant("DETAILS", etudiant_details=details)

# Modification d'un étudiant existant
etudiant = modifier_etudiant("12345678-01", etudiant_details=EtudiantDetailsSave(
    adresse=SepsAdresseSave(rue="Rue de la Paix", codePostal="1000", codePays="BE"),
))
```

### SEPS — Inscriptions

```python
from pyetnic.seps import (
    rechercher_inscriptions, enregistrer_inscription, modifier_inscription,
    InscriptionInputDataSave, InscriptionInputSave, SepsUESave,
)

# Recherche des inscriptions d'un étudiant
inscriptions = rechercher_inscriptions(cf_num="12345678-01", annee_scolaire=2024)
for i in inscriptions:
    print(i.cfNum, i.anneeScolaire, i.statut, i.ue.noAdministratif if i.ue else "")

# Enregistrement d'une nouvelle inscription
data = InscriptionInputDataSave(
    cfNum="12345678-01",
    idEtab=3052,
    idImplantation=6050,
    codePostalLieuCours="4000",
    inscription=InscriptionInputSave(
        dateInscription="2024-09-15",
        statut="I",
        ue=SepsUESave(noAdministratif=455, noOrganisation=1),
    ),
)
inscription = enregistrer_inscription(inscription_input_data=data)

# Modification d'une inscription existante
inscription = modifier_inscription(inscription_input_data=data)
```

---

## Workflow métier

Les documents suivent un workflow séquentiel imposé par ETNIC :

```
Créer organisation  →  statut "Encodé école"
        ↓
Inspection approuve →  statut "Approuvé"
        ↓
Doc 1 accessible (lecture/modification/approbation)
        ↓
Doc 2 accessible (lecture/modification)
        ↓
Doc 1 ET Doc 2 approuvés  →  Doc 3 accessible
```

**Règles de blocage :**
- Doc 1 : inaccessible si l'organisation est "Encodé école"
- Doc 3 : nécessite que Doc 1 **et** Doc 2 soient approuvés (erreur ETNIC 20102)
- L'approbation de l'organisation est effectuée par l'inspection, pas par l'école

---

## Modèles de données

### Identifiant d'organisation

```python
from pyetnic.eprom import OrganisationId

org_id = OrganisationId(
    anneeScolaire="2024-2025",  # ex. "2024-2025"
    etabId=3052,                # identifiant établissement
    numAdmFormation=455,        # numéro administratif de la formation
    numOrganisation=1,          # numéro d'organisation (attribué par le serveur à la création)
    # implId : présent dans les réponses serveur, NE PAS inclure dans les requêtes
)
```

> **Important** : `implId` est retourné par le serveur mais **ne doit pas** être envoyé dans les requêtes Lire/Modifier/Supprimer. Seul `creer_organisation` accepte `impl_id`.

### Vue d'une organisation

`OrganisationApercu` (retourné par `lister_formations`) contient les statuts des 4 documents :

| Champ | Description |
|---|---|
| `statutDocumentOrganisation` | Statut de l'organisation elle-même |
| `statutDocumentPopulationPeriodes` | Statut du Document 1 |
| `statutDocumentDroitsInscription` | Statut du Document droits d'inscription |
| `statutDocumentAttributions` | Statut du Document 3 |

`Organisation` (retourné par `lire_organisation`) hérite de `OrganisationApercu` et ajoute les champs métier complets. Note : les champs `statutDocument*` sont toujours `None` dans `Organisation` — ils ne sont disponibles que via `lister_formations`.

---

## Gestion des erreurs

Par défaut, les fonctions EPROM retournent `None` (ou `FormationsListeResult(success=False)` pour `lister_formations*`) en cas d'échec serveur, pour préserver la compatibilité ascendante.

Pour du nouveau code, préférer le **mode strict** qui lève des exceptions typées :

```python
from pyetnic import strict_errors, EtnicBusinessError, EtnicDocumentNotAccessibleError
from pyetnic.eprom import lire_document_3

with strict_errors():
    try:
        doc3 = lire_document_3(org_id)
    except EtnicDocumentNotAccessibleError as e:
        print(f"Doc 3 pas encore accessible : {e}")
    except EtnicBusinessError as e:
        print(f"Erreur ETNIC {e.code} : {e.description}")
```

Le mode strict peut aussi être activé globalement :

```python
from pyetnic import Config
Config.RAISE_ON_ERROR = True
```

Le flag est stocké dans un `ContextVar` : il est donc sûr en contexte multi-threads et asyncio (chaque thread/tâche voit sa propre valeur).

Le comportement par défaut (retour de `None`) deviendra « lever » en version 0.2.0.
Les services SEPS lèvent déjà des exceptions typées (`SepsEtnicError` et sous-classes) et ne sont pas affectés par ce flag.

**Hiérarchie des exceptions EPROM :**

```
EtnicError
├── EtnicTransportError          # erreurs réseau/SOAP (alias historique : SoapError)
└── EtnicBusinessError           # refus serveur (success=False)
    ├── EtnicDocumentNotAccessibleError  # code 20102
    ├── EtnicNotFoundError               # code 00009
    └── EtnicValidationError             # entrée invalide
```

**Codes d'erreur ETNIC courants :**

| Code | Description |
|---|---|
| 00009 | Aucun enregistrement trouvé |
| 20102 | Doc 1 et Doc 2 doivent être approuvés pour accéder au Doc 3 |
| SECU-0104 | Certificat X509 non enregistré dans l'annuaire (SEPS en dev) |

---

## Structure du projet

```
pyetnic/
├── __init__.py                  # Point d'entrée : from . import eprom, seps
├── eprom/
│   └── __init__.py              # Namespace public EPROM (fonctions + modèles)
├── seps/
│   └── __init__.py              # Namespace public SEPS (fonctions + modèles)
├── cli.py                       # CLI : commande init-config
├── config.py                    # Configuration (.env, endpoints SOAP)
├── soap_client.py               # SoapClientManager (zeep + WSSE/X509)
├── services/                    # Implémentation interne
│   ├── models.py                # Tous les dataclasses
│   ├── formations_liste.py
│   ├── organisation.py
│   ├── document1.py
│   ├── document2.py
│   ├── document3.py
│   ├── seps.py
│   ├── enregistrer_etudiant.py
│   └── inscriptions.py
└── resources/
    ├── EPROM_Formations_Liste_2.0/
    ├── EPROM_Formation_Organisation_7.0/
    ├── EPROM_Formation_Population_1.0/
    ├── EPROM_Formation_Periodes_1.0/
    ├── EPROM_Document_3_1.0/
    ├── SEPS_Recherche_Etudiants_2.1/
    ├── SEPS_Enregistrer_Etudiant_2.1/
    ├── SEPS_Enregistrer_Inscription_2.1/
    └── SEPS_Recherche_Inscriptions_2.1/
```

---

## Tests

```bash
# Tous les tests (mock + intégration)
pytest tests/

# Mock uniquement (sans credentials)
pytest tests/ -k "mock"
```

Les tests d'intégration nécessitent un `.env` valide. Ils skipent automatiquement si les credentials sont absents ou si le document n'est pas accessible dans l'environnement courant.

**Environnements ETNIC :**
- `dev` → `services-web.tq.etnic.be` / `ws-tq.etnic.be` (test, SSL non vérifié)
- `prod` → `services-web.etnic.be` / `ws.etnic.be` (production, SSL vérifié)

---

## Dépendances

| Package | Usage |
|---|---|
| [zeep](https://docs.python-zeep.org/) | Client SOAP |
| [python-dotenv](https://pypi.org/project/python-dotenv/) | Chargement `.env` |
| [requests](https://pypi.org/project/requests/) | Transport HTTP |
| [cryptography](https://pypi.org/project/cryptography/) | Extraction PFX en mémoire (SEPS) |
| [openpyxl](https://pypi.org/project/openpyxl/) | Export Excel |
| [xmlsec](https://pypi.org/project/xmlsec/) *(extra seps)* | Signature XML X509 |

## Licence

MIT — voir [LICENSE](LICENSE)
