collecte_remises.py

Python · 9 Ko · 19 septembre 2026

Télécharger Ouvrir le fichier

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MIG8110 — collecte des remises déposées sur GitLab
───────────────────────────────────────────────────────────────────────────
Clone ou met à jour tous les dépôts du groupe du cours, puis dresse le
tableau des remises avec la date enregistrée par le serveur GitLab.

  python3 collecte_remises.py                        toutes les remises
  python3 collecte_remises.py --remise 5             une seule
  python3 collecte_remises.py --remise 5 --echeance 2026-02-11
  python3 collecte_remises.py --csv remises.csv      export pour le compilateur
  python3 collecte_remises.py --sans-clone           tableau seulement

Prérequis : python3 et git. Rien d'autre.
Jeton d'accès GitLab avec la portée read_api, dans la variable
d'environnement GITLAB_TOKEN.
"""
import argparse, csv, json, os, subprocess, sys, urllib.error, urllib.parse, urllib.request
from datetime import datetime, timezone

PRODUCTIONS = [
    ("01", "Fiche d'intention",                      2,  "C1"),
    ("02", "Carte de positionnement",                3,  "C1"),
    ("03", "Fiche de cycle de vie des données",      4,  "C1"),
    ("04", "Dossier de processus",                   5,  "C1"),
    ("05", "Charte de projet",                       6,  "C1"),
    ("06", "Résumé des sujets présentés",            9,  "C3"),
    ("07", "Dossier de veille et bibliographie",     9,  "C1"),
    ("08", "Registre des risques",                  11,  "C1"),
    ("09", "Plan de travail complet",               12,  "C1"),
    ("10", "Dossier de comparaison technologique",  12,  "C4"),
    ("11", "Document de proposition de projet",     13,  "C5"),
]

C = dict(bleu="\033[36m", vert="\033[32m", jaune="\033[33m", rouge="\033[31m",
         gris="\033[90m", gras="\033[1m", fin="\033[0m")
def sansCouleur():
    for k in C: C[k] = ""
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
    sansCouleur()

def erreur(m):
    print(f"{C['rouge']}{m}{C['fin']}", file=sys.stderr)

class GitLab:
    def __init__(self, base, jeton):
        self.base = base.rstrip("/") + "/api/v4"
        self.jeton = jeton
    def get(self, chemin):
        req = urllib.request.Request(f"{self.base}/{chemin}",
                                     headers={"PRIVATE-TOKEN": self.jeton})
        try:
            with urllib.request.urlopen(req, timeout=30) as r:
                return json.loads(r.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            if e.code == 404: return None
            raise RuntimeError(f"GitLab a répondu {e.code} pour /{chemin}") from None
        except urllib.error.URLError as e:
            raise RuntimeError(f"Serveur injoignable : {e.reason}") from None

    def projets(self, groupe):
        q = urllib.parse.quote(groupe, safe="")
        out, page = [], 1
        while True:
            lot = self.get(f"groups/{q}/projects?per_page=100&page={page}"
                           f"&include_subgroups=true&archived=false")
            if not lot: break
            out.extend(lot)
            if len(lot) < 100: break
            page += 1
        return out

    def etiquette(self, pid, nom):
        q = urllib.parse.quote(nom, safe="")
        return self.get(f"projects/{pid}/repository/tags/{q}")

def dateServeur(tag):
    """Date retenue : celle enregistrée par GitLab, et non la date de
    validation, qui est déclarative et modifiable par l'étudiant."""
    if not tag: return None, None
    c = tag.get("commit") or {}
    # created_at du commit tel que le serveur l'a reçu
    brute = c.get("created_at") or c.get("committed_date") or tag.get("created_at")
    if not brute: return None, None
    try:
        d = datetime.fromisoformat(brute.replace("Z", "+00:00"))
        if d.tzinfo is None: d = d.replace(tzinfo=timezone.utc)
        return d, brute
    except ValueError:
        return None, brute

def majDepot(url, dossier, cloner=True):
    os.makedirs(os.path.dirname(dossier), exist_ok=True)
    if os.path.isdir(os.path.join(dossier, ".git")):
        r = subprocess.run(["git", "-C", dossier, "fetch", "--all", "--tags", "--quiet"],
                           capture_output=True, text=True)
        return ("maj", "") if r.returncode == 0 else ("echec", r.stderr.strip()[:90])
    if not cloner: return ("absent", "")
    r = subprocess.run(["git", "clone", "--quiet", url, dossier],
                       capture_output=True, text=True)
    return ("clone", "") if r.returncode == 0 else ("echec", r.stderr.strip()[:90])

def main():
    p = argparse.ArgumentParser(description="Collecte des remises MIG8110 sur GitLab.")
    p.add_argument("--gitlab", default=os.environ.get("GITLAB", "https://gitlab.info.uqam.ca"))
    p.add_argument("--groupe", default=os.environ.get("GROUPE", "mig8110-h2026"))
    p.add_argument("--dest",   default=os.environ.get("DEST",
                   os.path.join(os.path.expanduser("~"), "MIG8110", "depots")))
    p.add_argument("--remise", help="numéro de la remise, par exemple 5 ou 05")
    p.add_argument("--echeance", help="date limite, AAAA-MM-JJ, pour signaler les retards")
    p.add_argument("--csv", help="fichier CSV où écrire le tableau")
    p.add_argument("--sans-clone", action="store_true", help="ne pas cloner ni mettre à jour")
    a = p.parse_args()

    jeton = os.environ.get("GITLAB_TOKEN", "")
    if not jeton:
        erreur("Définissez GITLAB_TOKEN : un jeton d'accès GitLab de portée read_api.")
        erreur("Dans GitLab : photo de profil → Préférences → Jetons d'accès.")
        return 1
    if subprocess.run(["git", "--version"], capture_output=True).returncode != 0:
        erreur("git est introuvable."); return 1

    gl = GitLab(a.gitlab, jeton)
    print(f"{C['gras']}{C['bleu']}MIG8110 — collecte des remises{C['fin']}")
    print(f"{C['gris']}Groupe {a.groupe} sur {a.gitlab}{C['fin']}\n")

    try:
        projets = gl.projets(a.groupe)
    except RuntimeError as e:
        erreur(str(e)); return 1
    if projets is None or not projets:
        erreur(f"Aucun projet dans le groupe « {a.groupe} », ou jeton refusé.")
        return 1
    projets.sort(key=lambda x: (x.get("name") or "").lower())
    print(f"{len(projets)} dépôt(s) trouvé(s).\n")

    if not a.sans_clone:
        print(f"{C['gras']}Copies locales{C['fin']}")
        for pr in projets:
            etat, msg = majDepot(pr["http_url_to_repo"],
                                 os.path.join(a.dest, pr["path"]))
            sym = {"clone": (C["bleu"], "+"), "maj": (C["vert"], "✓"),
                   "absent": (C["gris"], "·"), "echec": (C["rouge"], "✗")}[etat]
            print(f"  {sym[0]}{sym[1]}{C['fin']} {pr['path']}" + (f"  {C['rouge']}{msg}{C['fin']}" if msg else ""))
        print()

    ech = None
    if a.echeance:
        try:
            ech = datetime.fromisoformat(a.echeance + "T23:59:59").replace(tzinfo=timezone.utc)
        except ValueError:
            erreur("Échéance illisible : utilisez AAAA-MM-JJ."); return 1

    cible = a.remise.zfill(2) if a.remise else None
    lignes = []
    for num, titre, seance, comp in PRODUCTIONS:
        if cible and num != cible: continue
        print(f"{C['gras']}{C['bleu']}Remise {num} — {titre}{C['fin']} "
              f"{C['gris']}(séance {seance} · {comp}){C['fin']}")
        faits = 0
        for pr in projets:
            nom = pr.get("name") or pr["path"]
            try:
                tag = gl.etiquette(pr["id"], f"remise-{num}")
            except RuntimeError as e:
                print(f"  {C['rouge']}✗{C['fin']} {nom:<30} {e}"); continue
            d, brute = dateServeur(tag)
            if not tag:
                print(f"  {C['gris']}○{C['fin']} {nom:<30} {C['gris']}aucune remise{C['fin']}")
                lignes.append([num, titre, seance, comp, nom, pr["path"], "", "non remis"])
                continue
            faits += 1
            court = d.strftime("%Y-%m-%d %H:%M") if d else (brute or "date inconnue")
            if ech and d:
                if d <= ech:
                    print(f"  {C['vert']}●{C['fin']} {nom:<30} {court}  {C['vert']}dans les délais{C['fin']}")
                    etat = "dans les délais"
                else:
                    jours = (d - ech).days
                    print(f"  {C['rouge']}●{C['fin']} {nom:<30} {court}  "
                          f"{C['rouge']}en retard de {jours} j{C['fin']}")
                    etat = f"en retard de {jours} j"
            else:
                print(f"  {C['jaune']}●{C['fin']} {nom:<30} {court}")
                etat = "remis"
            lignes.append([num, titre, seance, comp, nom, pr["path"], court, etat])
        print(f"  {C['gris']}{faits}/{len(projets)} remise(s){C['fin']}\n")

    if a.csv:
        with open(a.csv, "w", newline="", encoding="utf-8-sig") as f:
            w = csv.writer(f, delimiter=";")
            w.writerow(["numero", "production", "seance", "composante",
                        "etudiant", "depot", "date_serveur", "etat"])
            w.writerows(lignes)
        print(f"{C['vert']}Tableau écrit dans {a.csv}{C['fin']}")

    if not a.sans_clone:
        print(f"{C['gris']}Copies locales dans {a.dest}{C['fin']}")
    print(f"{C['gris']}La date affichée est celle enregistrée par GitLab, "
          f"non celle des validations locales.{C['fin']}")
    return 0

if __name__ == "__main__":
    sys.exit(main())