From 28d46e413dc50d931c868e6de7698e45f20a99cc Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Sun, 31 Mar 2024 23:04:54 +0200
Subject: [PATCH] Filtrage par groupes dans els pages statistiques: fix #791

---
 app/scodoc/gen_tables.py      |   7 +-
 app/scodoc/sco_groups_view.py |   4 +
 app/scodoc/sco_lycee.py       |  45 +++--
 app/scodoc/sco_report.py      | 359 +++++++++++++++++++---------------
 app/static/css/gt_table.css   | 289 +++++++++++----------------
 app/static/css/scodoc.css     |   5 +-
 app/static/js/groups_view.js  |   2 +-
 7 files changed, 356 insertions(+), 355 deletions(-)

diff --git a/app/scodoc/gen_tables.py b/app/scodoc/gen_tables.py
index 5e102c834..422dc6760 100644
--- a/app/scodoc/gen_tables.py
+++ b/app/scodoc/gen_tables.py
@@ -676,6 +676,7 @@ class GenTable:
         fmt="html",
         page_title="",
         filename=None,
+        cssstyles=[],
         javascripts=[],
         with_html_headers=True,
         publish=True,
@@ -696,6 +697,7 @@ class GenTable:
                 H.append(
                     self.html_header
                     or html_sco_header.sco_header(
+                        cssstyles=cssstyles,
                         page_title=page_title,
                         javascripts=javascripts,
                         init_qtip=init_qtip,
@@ -721,7 +723,7 @@ class GenTable:
                 )
             else:
                 return pdf_doc
-        elif fmt == "xls" or fmt == "xlsx":  # dans les 2 cas retourne du xlsx
+        elif fmt in ("xls", "xlsx"):  # dans les 2 cas retourne du xlsx
             xls = self.excel()
             if publish:
                 return scu.send_file(
@@ -730,8 +732,7 @@ class GenTable:
                     suffix=scu.XLSX_SUFFIX,
                     mime=scu.XLSX_MIMETYPE,
                 )
-            else:
-                return xls
+            return xls
         elif fmt == "text":
             return self.text()
         elif fmt == "csv":
diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py
index ebfffab38..707a41af0 100644
--- a/app/scodoc/sco_groups_view.py
+++ b/app/scodoc/sco_groups_view.py
@@ -453,6 +453,10 @@ class DisplayedGroupsInfos:
         for i in to_remove:
             del T[i]
 
+    def get_etudids(self) -> set[int]:
+        "Les etudids des groupes choisis"
+        return {member["etudid"] for member in self.members}
+
     def get_form_elem(self):
         """html hidden input with groups"""
         H = []
diff --git a/app/scodoc/sco_lycee.py b/app/scodoc/sco_lycee.py
index da274dc9f..d9c27633b 100644
--- a/app/scodoc/sco_lycee.py
+++ b/app/scodoc/sco_lycee.py
@@ -34,22 +34,28 @@ from operator import itemgetter
 from flask import url_for, g, request
 
 import app
+from app.scodoc import (
+    html_sco_header,
+    sco_formsemestre,
+    sco_groups_view,
+    sco_preferences,
+    sco_report,
+    sco_etud,
+)
+from app.models import FormSemestre
+from app.scodoc.gen_tables import GenTable
 import app.scodoc.sco_utils as scu
-from app.scodoc import html_sco_header
-from app.scodoc import sco_formsemestre
-from app.scodoc import sco_preferences
-from app.scodoc import sco_report
-from app.scodoc import sco_etud
 import sco_version
-from app.scodoc.gen_tables import GenTable
 
 
 def formsemestre_table_etuds_lycees(
-    formsemestre_id, group_lycees=True, only_primo=False
+    formsemestre: FormSemestre, groups_infos, group_lycees=True, only_primo=False
 ):
     """Récupère liste d'etudiants avec etat et decision."""
-    sem = sco_formsemestre.get_formsemestre(formsemestre_id)
-    etuds = sco_report.tsp_etud_list(formsemestre_id, only_primo=only_primo)[0]
+    sem = sco_formsemestre.get_formsemestre(formsemestre.id)
+    etuds = sco_report.tsp_etud_list(
+        formsemestre.id, groups_infos=groups_infos, only_primo=only_primo
+    )[0]
     if only_primo:
         primostr = "primo-entrants du "
     else:
@@ -59,7 +65,7 @@ def formsemestre_table_etuds_lycees(
         etuds,
         group_lycees,
         title,
-        sco_preferences.SemPreferences(formsemestre_id),
+        sco_preferences.SemPreferences(formsemestre.id),
     )
 
 
@@ -180,13 +186,20 @@ def _table_etuds_lycees(etuds, group_lycees, title, preferences, no_links=False)
 
 def formsemestre_etuds_lycees(
     formsemestre_id,
+    group_ids: list[int] = None,  # si indiqué, ne prend que ces groupes
     fmt="html",
     only_primo=False,
     no_grouping=False,
 ):
     """Table des lycées d'origine"""
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    groups_infos = sco_groups_view.DisplayedGroupsInfos(
+        group_ids,
+        formsemestre_id=formsemestre.id,
+        select_all_when_unspecified=True,
+    )
     tab, etuds_by_lycee = formsemestre_table_etuds_lycees(
-        formsemestre_id, only_primo=only_primo, group_lycees=not no_grouping
+        formsemestre, groups_infos, only_primo=only_primo, group_lycees=not no_grouping
     )
     tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id)
     if only_primo:
@@ -196,13 +209,19 @@ def formsemestre_etuds_lycees(
     t = tab.make_page(fmt=fmt, with_html_headers=False)
     if fmt != "html":
         return t
-    F = [sco_report.tsp_form_primo_group(only_primo, no_grouping, formsemestre_id, fmt)]
+    F = [
+        sco_report.tsp_form_primo_group(
+            only_primo, no_grouping, formsemestre_id, fmt, groups_infos=groups_infos
+        )
+    ]
     H = [
         html_sco_header.sco_header(
             page_title=tab.page_title,
             init_google_maps=True,
             init_qtip=True,
-            javascripts=["js/etud_info.js", "js/map_lycees.js"],
+            cssstyles=sco_groups_view.CSSSTYLES,
+            javascripts=sco_groups_view.JAVASCRIPTS
+            + ["js/etud_info.js", "js/map_lycees.js"],
         ),
         """<h2 class="formsemestre">Lycées d'origine des étudiants</h2>""",
         "\n".join(F),
diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py
index 75caad140..cc66ec071 100644
--- a/app/scodoc/sco_report.py
+++ b/app/scodoc/sco_report.py
@@ -40,6 +40,7 @@ from operator import itemgetter
 from flask import url_for, g, request
 import pydot
 
+from app import log
 from app.but import jury_but
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
@@ -47,18 +48,21 @@ from app.models import FormSemestre, ScolarAutorisationInscription
 from app.models import FormationModalite
 from app.models.etudiants import Identite
 
-import app.scodoc.sco_utils as scu
-from app.scodoc import notesdb as ndb
-from app.scodoc import html_sco_header
-from app.scodoc import codes_cursus
-from app.scodoc import sco_etud
-from app.scodoc import sco_formsemestre
-from app.scodoc import sco_formsemestre_inscriptions
-from app.scodoc import sco_preferences
-import sco_version
+from app.scodoc import (
+    codes_cursus,
+    html_sco_header,
+    sco_etud,
+    sco_formsemestre,
+    sco_formsemestre_inscriptions,
+    sco_groups_view,
+    sco_preferences,
+)
 from app.scodoc.gen_tables import GenTable
-from app import log
 from app.scodoc.codes_cursus import code_semestre_validant
+from app.scodoc.sco_exceptions import ScoValueError
+from app.scodoc import notesdb as ndb
+import app.scodoc.sco_utils as scu
+import sco_version
 
 MAX_ETUD_IN_DESCR = 20
 
@@ -68,21 +72,25 @@ LEGENDES_CODES_BUT = {
 }
 
 
-def formsemestre_etuds_stats(sem: dict, only_primo=False):
+def formsemestre_etuds_stats(
+    formsemestre: FormSemestre,
+    only_primo=False,
+    groups_infos: sco_groups_view.DisplayedGroupsInfos | None = None,
+):
     """Récupère liste d'etudiants avec etat et decision."""
-    formsemestre: FormSemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
     nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
-
-    T = nt.get_table_moyennes_triees()
-
+    etudids = groups_infos.get_etudids() if groups_infos else set()
+    rows = nt.get_table_moyennes_triees()
     # Décisions de jury BUT pour les semestres pairs seulement
     jury_but_mode = (
         formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0
     )
     # Construit liste d'étudiants du semestre avec leur decision
     etuds = []
-    for t in T:
+    for t in rows:
         etudid = t[-1]
+        if etudids and etudid not in etudids:
+            continue
         etudiant = Identite.get_etud(etudid)
         etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
         etud["annee_admission"] = etud["annee"]  # plus explicite
@@ -96,7 +104,7 @@ def formsemestre_etuds_stats(sem: dict, only_primo=False):
             etud["codedecision"] = "(nd)"  # pas de decision jury
         # Ajout devenir (autorisations inscriptions), utile pour stats passage
         aut_list = ScolarAutorisationInscription.query.filter_by(
-            etudid=etudid, origin_formsemestre_id=sem["formsemestre_id"]
+            etudid=etudid, origin_formsemestre_id=formsemestre.id
         ).all()
         autorisations = [f"S{a.semestre_id}" for a in aut_list]
         autorisations.sort()
@@ -115,27 +123,27 @@ def formsemestre_etuds_stats(sem: dict, only_primo=False):
             bs.append(etud["specialite"])
         etud["bac-specialite"] = " ".join(bs)
         #
-        if (not only_primo) or is_primo_etud(etud, sem):
+        if (not only_primo) or is_primo_etud(etud, formsemestre):
             etuds.append(etud)
     return etuds
 
 
-def is_primo_etud(etud: dict, sem: dict):
+def is_primo_etud(etud: dict, formsemestre: FormSemestre):
     """Determine si un (filled) etud a été inscrit avant ce semestre.
     Regarde la liste des semestres dans lesquels l'étudiant est inscrit.
     Si semestre pair, considère comme primo-entrants ceux qui étaient
     primo dans le précédent (S_{2n-1}).
     """
-    debut_cur = sem["date_debut_iso"]
+    debut_cur_iso = formsemestre.date_debut.isoformat()
     # si semestre impair et sem. précédent contigu, recule date debut
     if (
         (len(etud["sems"]) > 1)
-        and (sem["semestre_id"] % 2 == 0)
-        and (etud["sems"][1]["semestre_id"] == (sem["semestre_id"] - 1))
+        and (formsemestre.semestre_id % 2 == 0)
+        and (etud["sems"][1]["semestre_id"] == (formsemestre.semestre_id - 1))
     ):
-        debut_cur = etud["sems"][1]["date_debut_iso"]
+        debut_cur_iso = etud["sems"][1]["date_debut_iso"]
     for s in etud["sems"]:  # le + recent d'abord
-        if s["date_debut_iso"] < debut_cur:
+        if s["date_debut_iso"] < debut_cur_iso:
             return False
     return True
 
@@ -274,22 +282,6 @@ def formsemestre_report(
     return tab
 
 
-# def formsemestre_report_bacs(formsemestre_id, fmt='html'):
-#     """
-#     Tableau sur résultats par type de bac
-#     """
-#     sem = sco_formsemestre.get_formsemestre( formsemestre_id)
-#     title = 'Statistiques bacs ' + sem['titreannee']
-#     etuds = formsemestre_etuds_stats(sem)
-#     tab = formsemestre_report(formsemestre_id, etuds,
-#                               category='bac', result='codedecision',
-#                               category_name='Bac',
-#                               title=title)
-#     return tab.make_page(
-#         title =  """<h2>Résultats de <a href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titreannee)s</a></h2>""" % sem,
-#         fmt=fmt, page_title = title)
-
-
 def formsemestre_report_counts(
     formsemestre_id: int,
     fmt="html",
@@ -297,6 +289,7 @@ def formsemestre_report_counts(
     result: str = None,
     allkeys: bool = False,
     only_primo: bool = False,
+    group_ids: list[int] = None,  # si indiqué, ne prend que ces groupes
 ):
     """
     Tableau comptage avec choix des categories
@@ -307,8 +300,12 @@ def formsemestre_report_counts(
         si vrai, toutes les valeurs présentes dans les données
         sinon liste prédéfinie (voir ci-dessous)
     """
-    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
-    sem = sco_formsemestre.get_formsemestre(formsemestre_id)
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    groups_infos = sco_groups_view.DisplayedGroupsInfos(
+        group_ids,
+        formsemestre_id=formsemestre.id,
+        select_all_when_unspecified=True,
+    )
     # Décisions de jury BUT pour les semestres pairs seulement
     jury_but_mode = (
         formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0
@@ -319,7 +316,9 @@ def formsemestre_report_counts(
 
     category_name = category.capitalize()
     title = "Comptages " + category_name
-    etuds = formsemestre_etuds_stats(sem, only_primo=only_primo)
+    etuds = formsemestre_etuds_stats(
+        formsemestre, groups_infos=groups_infos, only_primo=only_primo
+    )
     tab = formsemestre_report(
         formsemestre_id,
         etuds,
@@ -329,7 +328,7 @@ def formsemestre_report_counts(
         title=title,
         only_primo=only_primo,
     )
-    if not etuds:
+    if len(formsemestre.inscriptions) == 0:
         F = ["""<p><em>Aucun étudiant</em></p>"""]
     else:
         if allkeys:
@@ -357,9 +356,10 @@ def formsemestre_report_counts(
                 keys += ["nb_rcue_valides", "decision_annee"]
         keys.sort(key=scu.heterogeneous_sorting_key)
         F = [
-            """<form name="f" method="get" action="%s"><p>
-              Colonnes: <select name="result" onchange="document.f.submit()">"""
-            % request.base_url
+            f"""<form id="group_selector" name="f" method="get" action="{request.base_url}">
+              Colonnes:
+              <select name="result" onchange="document.f.submit()">
+            """
         ]
         for k in keys:
             if k == result:
@@ -381,30 +381,38 @@ def formsemestre_report_counts(
                 '<option value="%s" %s>%s</option>'
                 % (k, selected, LEGENDES_CODES_BUT.get(k, k))
             )
-        F.append("</select>")
-        if only_primo:
-            checked = 'checked="1"'
-        else:
-            checked = ""
         F.append(
-            '<br><input type="checkbox" name="only_primo" onchange="document.f.submit()" %s>Restreindre aux primo-entrants</input>'
-            % checked
-        )
-        F.append(
-            '<input type="hidden" name="formsemestre_id" value="%s"/>' % formsemestre_id
+            f"""
+            </select>
+            <div style="margin-top:12px;">
+            <input type="checkbox" name="only_primo" onchange="document.f.submit()"
+                {'checked' if only_primo else ''}>
+                Restreindre aux primo-entrants</input>
+            <span style="margin: 12px;">
+            Restreindre au(x) groupe(s)&nbsp;:
+            {sco_groups_view.menu_groups_choice(groups_infos, submit_on_change=True)
+                if groups_infos else ''}
+            </span>
+            <input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/>
+            </div>
+            </form>
+            """
         )
-        F.append("</p></form>")
 
-    t = tab.make_page(
-        title="""<h2 class="formsemestre">Comptes croisés</h2>""",
+    tableau = tab.make_page(
         fmt=fmt,
+        title="""<h2 class="formsemestre">Comptes croisés</h2>""",
         with_html_headers=False,
     )
     if fmt != "html":
-        return t
+        return tableau
     H = [
-        html_sco_header.sco_header(page_title=title),
-        t,
+        html_sco_header.sco_header(
+            cssstyles=sco_groups_view.CSSSTYLES,
+            javascripts=sco_groups_view.JAVASCRIPTS,
+            page_title=title,
+        ),
+        tableau,
         "\n".join(F),
         """<p class="help">Le tableau affiche le nombre d'étudiants de ce semestre dans chacun
           des cas choisis: à l'aide des deux menus, vous pouvez choisir les catégories utilisées
@@ -418,7 +426,8 @@ def formsemestre_report_counts(
 
 # --------------------------------------------------------------------------
 def table_suivi_cohorte(
-    formsemestre_id,
+    formsemestre: FormSemestre,
+    groups_infos,
     percent=False,
     bac="",  # selection sur type de bac
     bacspecialite="",
@@ -441,9 +450,8 @@ def table_suivi_cohorte(
     Determination des dates: on regroupe les semestres commençant à des dates proches
 
     """
-    sem = sco_formsemestre.get_formsemestre(
-        formsemestre_id
-    )  # sem est le semestre origine
+    sem = sco_formsemestre.get_formsemestre(formsemestre.id)
+    # sem est le semestre origine
     t0 = time.time()
 
     def logt(op):
@@ -452,12 +460,12 @@ def table_suivi_cohorte(
 
     logt("table_suivi_cohorte: start")
     # 1-- Liste des semestres posterieurs dans lesquels ont été les etudiants de sem
-    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
-    etudids = nt.get_etudids()
-
+    etudids_inscrits = {ins.etudid for ins in formsemestre.inscriptions}
+    etudids_groups = groups_infos.get_etudids()
+    etudids = etudids_inscrits.intersection(etudids_groups)
     logt("A: orig etuds set")
-    S = {formsemestre_id: sem}  # ensemble de formsemestre_id
+    S = {formsemestre.id: sem}  # ensemble de formsemestre_id
     orig_set = set()  # ensemble d'etudid du semestre d'origine
     bacs = set()
     bacspecialites = set()
@@ -479,7 +487,7 @@ def table_suivi_cohorte(
             )
             and (not civilite or (civilite == etud["civilite"]))
             and (not statut or (statut == etud["statut"]))
-            and (not only_primo or is_primo_etud(etud, sem))
+            and (not only_primo or is_primo_etud(etud, formsemestre))
         ):
             orig_set.add(etudid)
             # semestres suivants:
@@ -524,17 +532,15 @@ def table_suivi_cohorte(
         s["nb_dipl"] = nb_dipl
 
     # 3-- Regroupe les semestres par date de debut
-    P = []  #  liste de periodsem
-
     class PeriodSem:
-        pass
+        def __init__(self, datedebut: datetime.datetime, sems: list[dict]):
+            self.datedebut = datedebut
+            self.sems = sems
 
     # semestre de depart:
-    porigin = PeriodSem()
     d, m, y = [int(x) for x in sem["date_debut"].split("/")]
-    porigin.datedebut = datetime.datetime(y, m, d)
-    porigin.sems = [sem]
-
+    porigin = PeriodSem(datetime.datetime(y, m, d), [sem])
+    P = []  #  liste de periodsem
     #
     tolerance = datetime.timedelta(days=45)
     for s in sems:
@@ -545,9 +551,7 @@ def table_suivi_cohorte(
                 merged = True
                 break
         if not merged:
-            p = PeriodSem()
-            p.datedebut = s["date_debut_dt"]
-            p.sems = [s]
+            p = PeriodSem(s["date_debut_dt"], [s])
             P.append(p)
 
     # 4-- regroupe par indice de semestre S_i
@@ -602,7 +606,7 @@ def table_suivi_cohorte(
         L.append(d)
     # Compte nb de démissions et de ré-orientation par période
     logt("D: cout dems reos")
-    sem["dems"], sem["reos"] = _count_dem_reo(formsemestre_id, sem["members"])
+    sem["dems"], sem["reos"] = _count_dem_reo(formsemestre.id, sem["members"])
     for p in P:
         p.dems = set()
         p.reos = set()
@@ -703,7 +707,7 @@ def table_suivi_cohorte(
         caption="Suivi cohorte " + pp + sem["titreannee"] + dbac,
         page_title="Suivi cohorte " + sem["titreannee"],
         html_class="table_cohorte",
-        preferences=sco_preferences.SemPreferences(formsemestre_id),
+        preferences=sco_preferences.SemPreferences(formsemestre.id),
     )
     # Explication: liste des semestres associés à chaque date
     if not P:
@@ -713,13 +717,10 @@ def table_suivi_cohorte(
     else:
         expl = ["<h3>Semestres associés à chaque date:</h3><ul>"]
         for p in P:
-            expl.append("<li><b>%s</b>:" % p.datedebut.strftime("%d/%m/%y"))
+            expl.append(f"""<li><b>{p.datedebut.strftime("%d/%m/%y")}</b>:""")
             ls = []
             for s in p.sems:
-                ls.append(
-                    '<a href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titreannee)s</a>'
-                    % s
-                )
+                ls.append(formsemestre.html_link_status())
             expl.append(", ".join(ls) + "</li>")
         expl.append("</ul>")
     return (
@@ -737,6 +738,7 @@ def table_suivi_cohorte(
 def formsemestre_suivi_cohorte(
     formsemestre_id,
     fmt="html",
+    group_ids: list[int] = None,  # si indiqué, ne prend que ces groupes
     percent=1,
     bac="",
     bacspecialite="",
@@ -747,9 +749,19 @@ def formsemestre_suivi_cohorte(
     only_primo=False,
 ) -> str:
     """Affiche suivi cohortes par numero de semestre"""
-    annee_bac = str(annee_bac or "")
-    annee_admission = str(annee_admission or "")
-    percent = int(percent)
+    try:
+        annee_bac = str(annee_bac or "")
+        annee_admission = str(annee_admission or "")
+        percent = int(percent)
+    except ValueError as exc:
+        raise ScoValueError("formsemestre_suivi_cohorte: argument invalide") from exc
+
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    groups_infos = sco_groups_view.DisplayedGroupsInfos(
+        group_ids,
+        formsemestre_id=formsemestre.id,
+        select_all_when_unspecified=True,
+    )
     (
         tab,
         expl,
@@ -760,7 +772,8 @@ def formsemestre_suivi_cohorte(
         civilites,
         statuts,
     ) = table_suivi_cohorte(
-        formsemestre_id,
+        formsemestre,
+        groups_infos=groups_infos,
         percent=percent,
         bac=bac,
         bacspecialite=bacspecialite,
@@ -772,7 +785,7 @@ def formsemestre_suivi_cohorte(
     )
     tab.base_url = (
         "%s?formsemestre_id=%s&percent=%s&bac=%s&bacspecialite=%s&civilite=%s"
-        % (request.base_url, formsemestre_id, percent, bac, bacspecialite, civilite)
+        % (request.base_url, formsemestre.id, percent, bac, bacspecialite, civilite)
     )
     if only_primo:
         tab.base_url += "&only_primo=on"
@@ -783,25 +796,29 @@ def formsemestre_suivi_cohorte(
     base_url = request.base_url
     burl = "%s?formsemestre_id=%s&bac=%s&bacspecialite=%s&civilite=%s&statut=%s" % (
         base_url,
-        formsemestre_id,
+        formsemestre.id,
         bac,
         bacspecialite,
         civilite,
         statut,
     )
     if percent:
-        pplink = '<p><a href="%s&percent=0">Afficher les résultats bruts</a></p>' % burl
+        pplink = f"""<p><a class="stdlink"
+            href="{burl}&percent=0">Afficher les résultats bruts</a></p>"""
     else:
-        pplink = (
-            '<p><a href="%s&percent=1">Afficher les résultats en pourcentages</a></p>'
-            % burl
-        )
+        pplink = f"""<p><a class="stdlink"
+            href="{burl}&percent=1">Afficher les résultats en pourcentages</a></p>"""
 
     H = [
-        html_sco_header.sco_header(page_title=tab.page_title),
+        html_sco_header.sco_header(
+            cssstyles=sco_groups_view.CSSSTYLES,
+            javascripts=sco_groups_view.JAVASCRIPTS,
+            page_title=tab.page_title,
+        ),
         """<h2 class="formsemestre">Suivi cohorte: devenir des étudiants de ce semestre</h2>""",
         _gen_form_selectetuds(
-            formsemestre_id,
+            formsemestre.id,
+            groups_infos=groups_infos,
             only_primo=only_primo,
             bac=bac,
             bacspecialite=bacspecialite,
@@ -854,6 +871,7 @@ def _gen_form_selectetuds(
     annee_admissions=None,
     civilites=None,
     statuts=None,
+    groups_infos: sco_groups_view.DisplayedGroupsInfos = None,
 ):
     """HTML form pour choix criteres selection etudiants"""
     annee_bacs = annee_bacs or []
@@ -877,9 +895,10 @@ def _gen_form_selectetuds(
     else:
         selected = 'selected="selected"'
     F = [
-        f"""<form id="f" method="get" action="{request.base_url}">
-    <p>Bac: <select name="bac" onchange="javascript: submit(this);">
-    <option value="" {selected}>tous</option>
+        f"""<form id="group_selector" name="f" method="get" action="{request.base_url}">
+    <div>Bac:
+    <select name="bac" onchange="javascript: submit(this);">
+        <option value="" {selected}>tous</option>
     """
     ]
     for b in bacs:
@@ -894,7 +913,8 @@ def _gen_form_selectetuds(
     else:
         selected = 'selected="selected"'
     F.append(
-        f"""&nbsp; Bac/Specialité: <select name="bacspecialite" onchange="javascript: submit(this);">
+        f"""&nbsp; Bac/Specialité:
+    <select name="bacspecialite" onchange="javascript: submit(this);">
     <option value="" {selected}>tous</option>
     """
     )
@@ -938,17 +958,24 @@ def _gen_form_selectetuds(
         else:
             selected = ""
         F.append(f'<option value="{b}" {selected}>{b}</option>')
-    F.append("</select>")
 
     F.append(
-        f"""<br>
-        <input type="checkbox" name="only_primo"
-            onchange="javascript: submit(this);"
-            {'checked="1"' if only_primo else ""}/>Restreindre aux primo-entrants
-        <input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/>
-        <input type="hidden" name="percent" value="{percent}"/>
-
-        </p>
+        f"""
+        </select>
+        <div style="margin-top:12px;">
+            <input type="checkbox" name="only_primo"
+                onchange="javascript: submit(this);"
+                {'checked="1"' if only_primo else ""}/>Restreindre aux primo-entrants
+
+            <span style="margin: 12px;">
+            Restreindre au(x) groupe(s)&nbsp;:
+            {sco_groups_view.menu_groups_choice(groups_infos, submit_on_change=True)
+                if groups_infos else ''}
+            </span>
+            <input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/>
+            <input type="hidden" name="percent" value="{percent}"/>
+        </div>
+        </div>
         </form>
         """
     )
@@ -1002,17 +1029,6 @@ def _count_dem_reo(formsemestre_id, etudids):
     return dems, reos
 
 
-"""OLDGEA:
-27s pour  S1 F.I. classique Semestre 1 2006-2007
-B 2.3s
-C 5.6s
-D 5.9s
-Z 27s  => cache des semestres pour nt
-
-à chaud: 3s
-B: etuds sets: 2.4s => lent: N x getEtudInfo (non caché)
-"""
-
 EXP_LIC = re.compile(r"licence", re.I)
 EXP_LPRO = re.compile(r"professionnelle", re.I)
 
@@ -1125,6 +1141,7 @@ def get_code_cursus_etud(
 
 def tsp_etud_list(
     formsemestre_id,
+    groups_infos: sco_groups_view.DisplayedGroupsInfos | None = None,
     only_primo=False,
     bac="",  # selection sur type de bac
     bacspecialite="",
@@ -1136,11 +1153,13 @@ def tsp_etud_list(
     """Liste des etuds a considerer dans table suivi cursus
     ramene aussi ensembles des bacs, genres, statuts de (tous) les etudiants
     """
-    # log('tsp_etud_list(%s, bac="%s")' % (formsemestre_id,bac))
-    sem = sco_formsemestre.get_formsemestre(formsemestre_id)
     formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
-    nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
-    etudids = nt.get_etudids()
+    etudids_inscrits = {ins.etudid for ins in formsemestre.inscriptions}
+    if groups_infos:
+        etudids_groups = groups_infos.get_etudids()
+        etudids = etudids_inscrits.intersection(etudids_groups)
+    else:
+        etudids = etudids_inscrits
     etuds = []
     bacs = set()
     bacspecialites = set()
@@ -1162,7 +1181,7 @@ def tsp_etud_list(
             )
             and (not civilite or (civilite == etud["civilite"]))
             and (not statut or (statut == etud["statut"]))
-            and (not only_primo or is_primo_etud(etud, sem))
+            and (not only_primo or is_primo_etud(etud, formsemestre))
         ):
             etuds.append(etud)
 
@@ -1173,7 +1192,6 @@ def tsp_etud_list(
         civilites.add(etud["civilite"])
         if etud["statut"]:  # ne montre pas les statuts non renseignés
             statuts.add(etud["statut"])
-    # log('tsp_etud_list: %s etuds' % len(etuds))
     return etuds, bacs, bacspecialites, annee_bacs, annee_admissions, civilites, statuts
 
 
@@ -1290,31 +1308,30 @@ def table_suivi_cursus(formsemestre_id, only_primo=False, grouped_parcours=True)
     return tab
 
 
-def tsp_form_primo_group(only_primo, no_grouping, formsemestre_id, fmt):
-    """Element de formulaire pour choisir si restriction aux primos entrants et groupement par lycees"""
-    F = ["""<form name="f" method="get" action="%s">""" % request.base_url]
-    if only_primo:
-        checked = 'checked="1"'
-    else:
-        checked = ""
-    F.append(
-        '<input type="checkbox" name="only_primo" onchange="document.f.submit()" %s>Restreindre aux primo-entrants</input>'
-        % checked
-    )
-    if no_grouping:
-        checked = 'checked="1"'
-    else:
-        checked = ""
-    F.append(
-        '<input type="checkbox" name="no_grouping" onchange="document.f.submit()" %s>Lister chaque étudiant</input>'
-        % checked
-    )
-    F.append(
-        '<input type="hidden" name="formsemestre_id" value="%s"/>' % formsemestre_id
-    )
-    F.append('<input type="hidden" name="fmt" value="%s"/>' % fmt)
-    F.append("""</form>""")
-    return "\n".join(F)
+def tsp_form_primo_group(
+    only_primo, no_grouping, formsemestre_id, fmt, groups_infos=None
+) -> str:
+    """Element de formulaire pour choisir si restriction aux primos entrants,
+    groupement par lycees et groupes
+    """
+    primo_checked = 'checked="1"' if only_primo else ""
+    no_grouping_checked = 'checked="1"' if no_grouping else ""
+    return f"""
+        <form id="group_selector" name="f" method="get" action="{request.base_url}">
+        <input type="checkbox" name="only_primo"
+            onchange="document.f.submit()" {primo_checked}>Restreindre aux primo-entrants</input>
+        <input type="checkbox" name="no_grouping" onchange="document.f.submit()"
+            {no_grouping_checked}>Lister chaque étudiant</input>
+
+        <span style="margin: 12px;">
+            Restreindre au(x) groupe(s)&nbsp;:
+            {sco_groups_view.menu_groups_choice(groups_infos, submit_on_change=True)
+                if groups_infos else ''}
+        </span>
+        <input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/>
+        <input type="hidden" name="fmt" value="{fmt}"/>
+        </form>
+        """
 
 
 def formsemestre_suivi_cursus(
@@ -1337,7 +1354,11 @@ def formsemestre_suivi_cursus(
     t = tab.make_page(fmt=fmt, with_html_headers=False)
     if fmt != "html":
         return t
-    F = [tsp_form_primo_group(only_primo, no_grouping, formsemestre_id, fmt)]
+    F = [
+        tsp_form_primo_group(
+            only_primo, no_grouping, formsemestre_id, fmt, groups_infos=None
+        )
+    ]
 
     H = [
         html_sco_header.sco_header(
@@ -1356,6 +1377,7 @@ def formsemestre_suivi_cursus(
 # -------------
 def graph_cursus(
     formsemestre_id,
+    groups_infos: sco_groups_view.DisplayedGroupsInfos | None = None,
     fmt="svg",
     only_primo=False,
     bac="",  # selection sur type de bac
@@ -1376,6 +1398,7 @@ def graph_cursus(
         statuts,
     ) = tsp_etud_list(
         formsemestre_id,
+        groups_infos=groups_infos,
         only_primo=only_primo,
         bac=bac,
         bacspecialite=bacspecialite,
@@ -1606,6 +1629,7 @@ def graph_cursus(
 
 def formsemestre_graph_cursus(
     formsemestre_id,
+    group_ids: list[int] = None,  # si indiqué, ne prend que ces groupes
     fmt="html",
     only_primo=False,
     bac="",  # selection sur type de bac
@@ -1619,7 +1643,12 @@ def formsemestre_graph_cursus(
     """Graphe suivi cohortes"""
     annee_bac = str(annee_bac or "")
     annee_admission = str(annee_admission or "")
-    # log("formsemestre_graph_cursus")
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    groups_infos = sco_groups_view.DisplayedGroupsInfos(
+        group_ids,
+        formsemestre_id=formsemestre.id,
+        select_all_when_unspecified=True,
+    )
     sem = sco_formsemestre.get_formsemestre(formsemestre_id)
     if fmt == "pdf":
         (
@@ -1633,6 +1662,7 @@ def formsemestre_graph_cursus(
             statuts,
         ) = graph_cursus(
             formsemestre_id,
+            groups_infos=groups_infos,
             fmt="pdf",
             only_primo=only_primo,
             bac=bac,
@@ -1657,6 +1687,7 @@ def formsemestre_graph_cursus(
             statuts,
         ) = graph_cursus(
             formsemestre_id,
+            groups_infos=groups_infos,
             fmt="png",
             only_primo=only_primo,
             bac=bac,
@@ -1695,6 +1726,7 @@ def formsemestre_graph_cursus(
             statuts,
         ) = graph_cursus(
             formsemestre_id,
+            groups_infos=groups_infos,
             only_primo=only_primo,
             bac=bac,
             bacspecialite=bacspecialite,
@@ -1706,14 +1738,17 @@ def formsemestre_graph_cursus(
 
         H = [
             html_sco_header.sco_header(
+                cssstyles=sco_groups_view.CSSSTYLES,
+                javascripts=sco_groups_view.JAVASCRIPTS,
                 page_title="Graphe cursus de %(titreannee)s" % sem,
                 no_side_bar=True,
             ),
             """<h2 class="formsemestre">Cursus des étudiants de ce semestre</h2>""",
             doc,
-            "<p>%d étudiants sélectionnés</p>" % len(etuds),
+            f"<p>{len(etuds)} étudiants sélectionnés</p>",
             _gen_form_selectetuds(
                 formsemestre_id,
+                groups_infos=groups_infos,
                 only_primo=only_primo,
                 bac=bac,
                 bacspecialite=bacspecialite,
@@ -1743,6 +1778,10 @@ def formsemestre_graph_cursus(
               passant d'un semestre à l'autre (s'il y en a moins de {MAX_ETUD_IN_DESCR}, vous
               pouvez visualiser leurs noms en passant le curseur sur le chiffre).
             </p>
+            <p class="help">
+            Le menu <em>Restreindre au(x) groupe(s)</em> permet de restreindre l'étude aux
+            étudiants appartenant aux groupes indiqués <em>dans le semestre d'origine</em>.
+            </p>
             """,
             html_sco_header.sco_footer(),
         ]
diff --git a/app/static/css/gt_table.css b/app/static/css/gt_table.css
index d40c493c4..075fb3b83 100644
--- a/app/static/css/gt_table.css
+++ b/app/static/css/gt_table.css
@@ -141,111 +141,111 @@ table.dataTable.with-highlight tr:hover td {
   background-color: rgba(255, 255, 0, 0.415);
 }
 
-table.dataTable.order-column tbody tr > .sorting_1,
-table.dataTable.order-column tbody tr > .sorting_2,
-table.dataTable.order-column tbody tr > .sorting_3,
-table.dataTable.display tbody tr > .sorting_1,
-table.dataTable.display tbody tr > .sorting_2,
-table.dataTable.display tbody tr > .sorting_3 {
+table.dataTable.order-column tbody tr>.sorting_1,
+table.dataTable.order-column tbody tr>.sorting_2,
+table.dataTable.order-column tbody tr>.sorting_3,
+table.dataTable.display tbody tr>.sorting_1,
+table.dataTable.display tbody tr>.sorting_2,
+table.dataTable.display tbody tr>.sorting_3 {
   background-color: #f9f9f9;
 }
 
-table.dataTable.order-column tbody tr.selected > .sorting_1,
-table.dataTable.order-column tbody tr.selected > .sorting_2,
-table.dataTable.order-column tbody tr.selected > .sorting_3,
-table.dataTable.display tbody tr.selected > .sorting_1,
-table.dataTable.display tbody tr.selected > .sorting_2,
-table.dataTable.display tbody tr.selected > .sorting_3 {
+table.dataTable.order-column tbody tr.selected>.sorting_1,
+table.dataTable.order-column tbody tr.selected>.sorting_2,
+table.dataTable.order-column tbody tr.selected>.sorting_3,
+table.dataTable.display tbody tr.selected>.sorting_1,
+table.dataTable.display tbody tr.selected>.sorting_2,
+table.dataTable.display tbody tr.selected>.sorting_3 {
   background-color: #acbad4;
 }
 
-table.dataTable.display tbody tr.odd > .sorting_1,
-table.dataTable.order-column.stripe tbody tr.odd > .sorting_1 {
+table.dataTable.display tbody tr.odd>.sorting_1,
+table.dataTable.order-column.stripe tbody tr.odd>.sorting_1 {
   background-color: #f1f1f1;
 }
 
-table.dataTable.display tbody tr.odd > .sorting_2,
-table.dataTable.order-column.stripe tbody tr.odd > .sorting_2 {
+table.dataTable.display tbody tr.odd>.sorting_2,
+table.dataTable.order-column.stripe tbody tr.odd>.sorting_2 {
   background-color: #f3f3f3;
 }
 
-table.dataTable.display tbody tr.odd > .sorting_3,
-table.dataTable.order-column.stripe tbody tr.odd > .sorting_3 {
+table.dataTable.display tbody tr.odd>.sorting_3,
+table.dataTable.order-column.stripe tbody tr.odd>.sorting_3 {
   background-color: whitesmoke;
 }
 
-table.dataTable.display tbody tr.odd.selected > .sorting_1,
-table.dataTable.order-column.stripe tbody tr.odd.selected > .sorting_1 {
+table.dataTable.display tbody tr.odd.selected>.sorting_1,
+table.dataTable.order-column.stripe tbody tr.odd.selected>.sorting_1 {
   background-color: #a6b3cd;
 }
 
-table.dataTable.display tbody tr.odd.selected > .sorting_2,
-table.dataTable.order-column.stripe tbody tr.odd.selected > .sorting_2 {
+table.dataTable.display tbody tr.odd.selected>.sorting_2,
+table.dataTable.order-column.stripe tbody tr.odd.selected>.sorting_2 {
   background-color: #a7b5ce;
 }
 
-table.dataTable.display tbody tr.odd.selected > .sorting_3,
-table.dataTable.order-column.stripe tbody tr.odd.selected > .sorting_3 {
+table.dataTable.display tbody tr.odd.selected>.sorting_3,
+table.dataTable.order-column.stripe tbody tr.odd.selected>.sorting_3 {
   background-color: #a9b6d0;
 }
 
-table.dataTable.display tbody tr.even > .sorting_1,
-table.dataTable.order-column.stripe tbody tr.even > .sorting_1 {
+table.dataTable.display tbody tr.even>.sorting_1,
+table.dataTable.order-column.stripe tbody tr.even>.sorting_1 {
   background-color: #f9f9f9;
 }
 
-table.dataTable.display tbody tr.even > .sorting_2,
-table.dataTable.order-column.stripe tbody tr.even > .sorting_2 {
+table.dataTable.display tbody tr.even>.sorting_2,
+table.dataTable.order-column.stripe tbody tr.even>.sorting_2 {
   background-color: #fbfbfb;
 }
 
-table.dataTable.display tbody tr.even > .sorting_3,
-table.dataTable.order-column.stripe tbody tr.even > .sorting_3 {
+table.dataTable.display tbody tr.even>.sorting_3,
+table.dataTable.order-column.stripe tbody tr.even>.sorting_3 {
   background-color: #fdfdfd;
 }
 
-table.dataTable.display tbody tr.even.selected > .sorting_1,
-table.dataTable.order-column.stripe tbody tr.even.selected > .sorting_1 {
+table.dataTable.display tbody tr.even.selected>.sorting_1,
+table.dataTable.order-column.stripe tbody tr.even.selected>.sorting_1 {
   background-color: #acbad4;
 }
 
-table.dataTable.display tbody tr.even.selected > .sorting_2,
-table.dataTable.order-column.stripe tbody tr.even.selected > .sorting_2 {
+table.dataTable.display tbody tr.even.selected>.sorting_2,
+table.dataTable.order-column.stripe tbody tr.even.selected>.sorting_2 {
   background-color: #adbbd6;
 }
 
-table.dataTable.display tbody tr.even.selected > .sorting_3,
-table.dataTable.order-column.stripe tbody tr.even.selected > .sorting_3 {
+table.dataTable.display tbody tr.even.selected>.sorting_3,
+table.dataTable.order-column.stripe tbody tr.even.selected>.sorting_3 {
   background-color: #afbdd8;
 }
 
-table.dataTable.display tbody tr:hover > .sorting_1,
-table.dataTable.order-column.hover tbody tr:hover > .sorting_1 {
+table.dataTable.display tbody tr:hover>.sorting_1,
+table.dataTable.order-column.hover tbody tr:hover>.sorting_1 {
   background-color: #eaeaea;
 }
 
-table.dataTable.display tbody tr:hover > .sorting_2,
-table.dataTable.order-column.hover tbody tr:hover > .sorting_2 {
+table.dataTable.display tbody tr:hover>.sorting_2,
+table.dataTable.order-column.hover tbody tr:hover>.sorting_2 {
   background-color: #ebebeb;
 }
 
-table.dataTable.display tbody tr:hover > .sorting_3,
-table.dataTable.order-column.hover tbody tr:hover > .sorting_3 {
+table.dataTable.display tbody tr:hover>.sorting_3,
+table.dataTable.order-column.hover tbody tr:hover>.sorting_3 {
   background-color: #eeeeee;
 }
 
-table.dataTable.display tbody tr:hover.selected > .sorting_1,
-table.dataTable.order-column.hover tbody tr:hover.selected > .sorting_1 {
+table.dataTable.display tbody tr:hover.selected>.sorting_1,
+table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_1 {
   background-color: #a1aec7;
 }
 
-table.dataTable.display tbody tr:hover.selected > .sorting_2,
-table.dataTable.order-column.hover tbody tr:hover.selected > .sorting_2 {
+table.dataTable.display tbody tr:hover.selected>.sorting_2,
+table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_2 {
   background-color: #a2afc8;
 }
 
-table.dataTable.display tbody tr:hover.selected > .sorting_3,
-table.dataTable.order-column.hover tbody tr:hover.selected > .sorting_3 {
+table.dataTable.display tbody tr:hover.selected>.sorting_3,
+table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_3 {
   background-color: #a4b2cb;
 }
 
@@ -420,13 +420,11 @@ table.dataTable td {
   color: #333333 !important;
   border: 1px solid #979797;
   background-color: white;
-  background: -webkit-gradient(
-    linear,
-    left top,
-    left bottom,
-    color-stop(0%, white),
-    color-stop(100%, gainsboro)
-  );
+  background: -webkit-gradient(linear,
+      left top,
+      left bottom,
+      color-stop(0%, white),
+      color-stop(100%, gainsboro));
   /* Chrome,Safari4+ */
   background: -webkit-linear-gradient(top, white 0%, gainsboro 100%);
   /* Chrome10+,Safari5.1+ */
@@ -454,13 +452,11 @@ table.dataTable td {
   color: white !important;
   border: 1px solid #111111;
   background-color: #585858;
-  background: -webkit-gradient(
-    linear,
-    left top,
-    left bottom,
-    color-stop(0%, #585858),
-    color-stop(100%, #111111)
-  );
+  background: -webkit-gradient(linear,
+      left top,
+      left bottom,
+      color-stop(0%, #585858),
+      color-stop(100%, #111111));
   /* Chrome,Safari4+ */
   background: -webkit-linear-gradient(top, #585858 0%, #111111 100%);
   /* Chrome10+,Safari5.1+ */
@@ -477,13 +473,11 @@ table.dataTable td {
 .dataTables_wrapper .dataTables_paginate .paginate_button:active {
   outline: none;
   background-color: #2b2b2b;
-  background: -webkit-gradient(
-    linear,
-    left top,
-    left bottom,
-    color-stop(0%, #2b2b2b),
-    color-stop(100%, #0c0c0c)
-  );
+  background: -webkit-gradient(linear,
+      left top,
+      left bottom,
+      color-stop(0%, #2b2b2b),
+      color-stop(100%, #0c0c0c));
   /* Chrome,Safari4+ */
   background: -webkit-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%);
   /* Chrome10+,Safari5.1+ */
@@ -514,50 +508,38 @@ table.dataTable td {
   text-align: center;
   font-size: 1.2em;
   background-color: white;
-  background: -webkit-gradient(
-    linear,
-    left top,
-    right top,
-    color-stop(0%, rgba(255, 255, 255, 0)),
-    color-stop(25%, rgba(255, 255, 255, 0.9)),
-    color-stop(75%, rgba(255, 255, 255, 0.9)),
-    color-stop(100%, rgba(255, 255, 255, 0))
-  );
-  background: -webkit-linear-gradient(
-    left,
-    rgba(255, 255, 255, 0) 0%,
-    rgba(255, 255, 255, 0.9) 25%,
-    rgba(255, 255, 255, 0.9) 75%,
-    rgba(255, 255, 255, 0) 100%
-  );
-  background: -moz-linear-gradient(
-    left,
-    rgba(255, 255, 255, 0) 0%,
-    rgba(255, 255, 255, 0.9) 25%,
-    rgba(255, 255, 255, 0.9) 75%,
-    rgba(255, 255, 255, 0) 100%
-  );
-  background: -ms-linear-gradient(
-    left,
-    rgba(255, 255, 255, 0) 0%,
-    rgba(255, 255, 255, 0.9) 25%,
-    rgba(255, 255, 255, 0.9) 75%,
-    rgba(255, 255, 255, 0) 100%
-  );
-  background: -o-linear-gradient(
-    left,
-    rgba(255, 255, 255, 0) 0%,
-    rgba(255, 255, 255, 0.9) 25%,
-    rgba(255, 255, 255, 0.9) 75%,
-    rgba(255, 255, 255, 0) 100%
-  );
-  background: linear-gradient(
-    to right,
-    rgba(255, 255, 255, 0) 0%,
-    rgba(255, 255, 255, 0.9) 25%,
-    rgba(255, 255, 255, 0.9) 75%,
-    rgba(255, 255, 255, 0) 100%
-  );
+  background: -webkit-gradient(linear,
+      left top,
+      right top,
+      color-stop(0%, rgba(255, 255, 255, 0)),
+      color-stop(25%, rgba(255, 255, 255, 0.9)),
+      color-stop(75%, rgba(255, 255, 255, 0.9)),
+      color-stop(100%, rgba(255, 255, 255, 0)));
+  background: -webkit-linear-gradient(left,
+      rgba(255, 255, 255, 0) 0%,
+      rgba(255, 255, 255, 0.9) 25%,
+      rgba(255, 255, 255, 0.9) 75%,
+      rgba(255, 255, 255, 0) 100%);
+  background: -moz-linear-gradient(left,
+      rgba(255, 255, 255, 0) 0%,
+      rgba(255, 255, 255, 0.9) 25%,
+      rgba(255, 255, 255, 0.9) 75%,
+      rgba(255, 255, 255, 0) 100%);
+  background: -ms-linear-gradient(left,
+      rgba(255, 255, 255, 0) 0%,
+      rgba(255, 255, 255, 0.9) 25%,
+      rgba(255, 255, 255, 0.9) 75%,
+      rgba(255, 255, 255, 0) 100%);
+  background: -o-linear-gradient(left,
+      rgba(255, 255, 255, 0) 0%,
+      rgba(255, 255, 255, 0.9) 25%,
+      rgba(255, 255, 255, 0.9) 75%,
+      rgba(255, 255, 255, 0) 100%);
+  background: linear-gradient(to right,
+      rgba(255, 255, 255, 0) 0%,
+      rgba(255, 255, 255, 0.9) 25%,
+      rgba(255, 255, 255, 0.9) 75%,
+      rgba(255, 255, 255, 0) 100%);
 }
 
 .dataTables_wrapper .dataTables_length,
@@ -577,69 +559,17 @@ table.dataTable td {
   -webkit-overflow-scrolling: touch;
 }
 
-.dataTables_wrapper
-  .dataTables_scroll
-  div.dataTables_scrollBody
-  > table
-  > thead
-  > tr
-  > th,
-.dataTables_wrapper
-  .dataTables_scroll
-  div.dataTables_scrollBody
-  > table
-  > thead
-  > tr
-  > td,
-.dataTables_wrapper
-  .dataTables_scroll
-  div.dataTables_scrollBody
-  > table
-  > tbody
-  > tr
-  > th,
-.dataTables_wrapper
-  .dataTables_scroll
-  div.dataTables_scrollBody
-  > table
-  > tbody
-  > tr
-  > td {
+.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>th,
+.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>td,
+.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>th,
+.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>td {
   vertical-align: middle;
 }
 
-.dataTables_wrapper
-  .dataTables_scroll
-  div.dataTables_scrollBody
-  > table
-  > thead
-  > tr
-  > th
-  > div.dataTables_sizing,
-.dataTables_wrapper
-  .dataTables_scroll
-  div.dataTables_scrollBody
-  > table
-  > thead
-  > tr
-  > td
-  > div.dataTables_sizing,
-.dataTables_wrapper
-  .dataTables_scroll
-  div.dataTables_scrollBody
-  > table
-  > tbody
-  > tr
-  > th
-  > div.dataTables_sizing,
-.dataTables_wrapper
-  .dataTables_scroll
-  div.dataTables_scrollBody
-  > table
-  > tbody
-  > tr
-  > td
-  > div.dataTables_sizing {
+.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>th>div.dataTables_sizing,
+.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>td>div.dataTables_sizing,
+.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>th>div.dataTables_sizing,
+.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>td>div.dataTables_sizing {
   height: 0;
   overflow: hidden;
   margin: 0 !important;
@@ -650,8 +580,8 @@ table.dataTable td {
   border-bottom: 1px solid #111111;
 }
 
-.dataTables_wrapper.no-footer div.dataTables_scrollHead > table,
-.dataTables_wrapper.no-footer div.dataTables_scrollBody > table {
+.dataTables_wrapper.no-footer div.dataTables_scrollHead>table,
+.dataTables_wrapper.no-footer div.dataTables_scrollBody>table {
   border-bottom: none;
 }
 
@@ -664,6 +594,7 @@ table.dataTable td {
 }
 
 @media screen and (max-width: 767px) {
+
   .dataTables_wrapper .dataTables_info,
   .dataTables_wrapper .dataTables_paginate {
     float: none;
@@ -676,6 +607,7 @@ table.dataTable td {
 }
 
 @media screen and (max-width: 640px) {
+
   .dataTables_wrapper .dataTables_length,
   .dataTables_wrapper .dataTables_filter {
     float: none;
@@ -703,6 +635,10 @@ table.table_leftalign tr td {
   text-align: left;
 }
 
+p.gt_caption {
+  margin-top: 8px;
+}
+
 /* Ligne(s) de titre */
 table.dataTable thead tr th {
   background-color: rgb(90%, 90%, 90%);
@@ -757,7 +693,8 @@ table.dataTable.gt_table {
 table.dataTable.gt_table.gt_left {
   margin-left: 16px;
 }
+
 table.dataTable.gt_table.gt_left td,
 table.dataTable.gt_table.gt_left th {
   text-align: left;
-}
+}
\ No newline at end of file
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index 5a91809bd..105b2e36d 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -1188,10 +1188,11 @@ a.discretelink:hover {
 
 .help {
   max-width: var(--sco-content-max-width);
+  font-style: italic;
 }
 
-.help {
-  font-style: italic;
+.help em {
+  font-style: normal;
 }
 
 .help_important {
diff --git a/app/static/js/groups_view.js b/app/static/js/groups_view.js
index 74433b643..e555a9eb7 100644
--- a/app/static/js/groups_view.js
+++ b/app/static/js/groups_view.js
@@ -23,7 +23,7 @@ function groups_view_url() {
   url.param()["formsemestre_id"] =
     $("#group_selector")[0].formsemestre_id.value;
 
-  var selected_groups = $("#group_selector select").val();
+  var selected_groups = $("#group_selector select#group_ids_sel").val();
   url.param()["group_ids"] = selected_groups; // remplace par groupes selectionnes
 
   return url;
-- 
GitLab