ajoute la recherche plein texte dans les cours et labs (bloc C)

Elasticsearch interne (jamais exposé, sécurité x-pack désactivée,
cohérent avec le reste des services internes de ce serveur), tas JVM
fixé à 512 Mo plutôt que le calcul automatique (jusqu'à 50 % de la
RAM système, désastreux sur ce serveur à 31 Go réels). Appels HTTP
directs depuis Laravel, pas de bibliothèque cliente pour un besoin
aussi simple. Index reconstruit après chaque synchronisation du
catalogue, page de recherche commune aux profs et aux élèves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-14 13:24:13 +02:00
parent e549f03e1b
commit 777dd1cbc8
20 changed files with 545 additions and 0 deletions

11
ansible/elasticsearch.yml Normal file
View file

@ -0,0 +1,11 @@
---
# Bloc C : recherche plein texte dans les cours et labs.
# Rejoue aussi `portail` : c'est lui qui connaît {{ portail_dossier_app }}
# pour écrire ELASTICSEARCH_HOTE/PORT dans son .env.
- name: Portail et Elasticsearch
hosts: syllabus
become: true
gather_facts: true
roles:
- portail
- elasticsearch

View file

@ -0,0 +1,7 @@
---
elasticsearch_port: 9200
# Catalogue de cours/labs seulement, volume très faible : un tas aussi
# grand que le calcul automatique d'Elasticsearch (jusqu'à 50 % de la RAM
# système) serait désastreux sur ce serveur à 31 Go partagés avec
# OpenStack et le portail. Voir docs/runbooks/elasticsearch.md.
elasticsearch_tas_mo: 512

View file

@ -0,0 +1,6 @@
---
- name: redémarrer elasticsearch
ansible.builtin.systemd:
name: elasticsearch
state: restarted
enabled: true

View file

@ -0,0 +1,63 @@
---
# Bloc C (CLAUDE.md) : recherche plein texte dans les cours et labs.
# Instance interne seule (voir elasticsearch.yml.j2) : jamais exposée,
# jamais de réseau à configurer dans Nginx — seul le portail lui parle, en
# PHP, sur 127.0.0.1.
- name: ajouter la clé de signature du dépôt Elastic
ansible.builtin.get_url:
url: https://artifacts.elastic.co/GPG-KEY-elasticsearch
dest: /tmp/elastic-gpg-key.asc
mode: "0644"
register: elasticsearch_cle_brute
- name: déposer la clé au format attendu par apt
ansible.builtin.shell: gpg --dearmor < /tmp/elastic-gpg-key.asc > /usr/share/keyrings/elastic.gpg
args:
creates: /usr/share/keyrings/elastic.gpg
- name: ajouter le dépôt Elastic 8.x
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/elastic.gpg] https://artifacts.elastic.co/packages/8.x/apt stable main"
filename: elastic-8.x
state: present
update_cache: true
- name: installer Elasticsearch
ansible.builtin.apt:
name: elasticsearch
state: present
register: elasticsearch_installation
- name: déployer la configuration (sécurité désactivée, écoute locale seulement)
ansible.builtin.template:
src: elasticsearch.yml.j2
dest: /etc/elasticsearch/elasticsearch.yml
owner: root
group: elasticsearch
mode: "0660"
notify: redémarrer elasticsearch
- name: réduire le tas JVM (catalogue de cours/labs, volume très faible)
ansible.builtin.template:
src: heap.options.j2
dest: /etc/elasticsearch/jvm.options.d/heap.options
owner: root
group: elasticsearch
mode: "0660"
notify: redémarrer elasticsearch
- name: activer et démarrer Elasticsearch
ansible.builtin.systemd:
name: elasticsearch
state: started
enabled: true
- name: ajouter/mettre à jour les variables Elasticsearch dans .env du portail
ansible.builtin.lineinfile:
path: "{{ portail_dossier_app }}/.env"
regexp: "^{{ item.cle }}="
line: "{{ item.cle }}={{ item.valeur }}"
loop:
- { cle: "ELASTICSEARCH_HOTE", valeur: "127.0.0.1" }
- { cle: "ELASTICSEARCH_PORT", valeur: "{{ elasticsearch_port }}" }
notify: redémarrer horizon

View file

@ -0,0 +1,15 @@
# Géré par Ansible (rôle elasticsearch) — ne pas modifier à la main.
# Instance interne (consommée seulement par le portail, jamais exposée) :
# sécurité x-pack désactivée, comme les autres services internes de ce
# serveur (MariaDB/Redis de l'app sur 127.0.0.1, API OpenStack sur
# 10.10.0.1 en HTTP) — l'isolation vient du pare-feu et de l'écoute en
# local seulement, pas de TLS/authentification par service. Voir ADR 0009.
path.data: /var/lib/elasticsearch
path.logs: /var/log/elasticsearch
discovery.type: single-node
network.host: 127.0.0.1
http.port: {{ elasticsearch_port }}
xpack.security.enabled: false
xpack.security.enrollment.enabled: false
xpack.security.http.ssl.enabled: false
xpack.security.transport.ssl.enabled: false

View file

@ -0,0 +1,3 @@
# Géré par Ansible (rôle elasticsearch) — ne pas modifier à la main.
-Xms{{ elasticsearch_tas_mo }}m
-Xmx{{ elasticsearch_tas_mo }}m

View file

@ -0,0 +1,70 @@
# ADR 0009 — Elasticsearch interne, sécurité x-pack désactivée
- **Date** : 2026-10-14
- **Statut** : accepté
## Contexte
Bloc C du CLAUDE.md : « ElasticSearch : recherche plein texte dans les cours
et les labs (seulement si le besoin est réel). » Adam a confirmé le besoin
explicitement (« fonce »). Catalogue minuscule (quelques labs, un seul
`cours.md` aujourd'hui) : aucun besoin d'un cluster, d'un cache de
répertoires exotique ou d'une bibliothèque cliente PHP dédiée.
## Décisions
**Une instance Elasticsearch 8.19, locale, jamais exposée.** Installée
depuis le dépôt APT officiel Elastic (clé GPG vérifiée, même principe que
Ceph/ADR 0007). Consommée uniquement par le portail, en PHP, sur
`127.0.0.1:9200` — jamais par un navigateur.
**Sécurité x-pack désactivée (`xpack.security.enabled: false`).**
L'installation standard active TLS + authentification par défaut depuis la
8.x (mot de passe généré, certificats auto-signés) — pertinent pour un
cluster multi-nœuds exposé, disproportionné pour une instance mono-nœud qui
ne parle qu'à elle-même sur la boucle locale. Cohérent avec le reste de ce
serveur : l'API OpenStack interne (`10.10.0.1`) et le MariaDB/Redis dédiés
au portail (`127.0.0.1:3307`/`6380`) n'ont pas non plus de TLS entre
services — l'isolation vient du pare-feu (`policy drop` par défaut) et de
l'écoute en local, pas d'une authentification par service. Désactiver x-pack
évite aussi de devoir stocker et faire tourner un mot de passe Elasticsearch
supplémentaire pour rien.
**Tas JVM réduit à 512 Mo (`-Xms512m -Xmx512m`), pas le calcul automatique
d'Elasticsearch.** Par défaut, Elasticsearch vise jusqu'à 50 % de la RAM
système — sur ce serveur à 31 Go réels (voir ADR 0007), cela aurait pu
réserver ~15 Go à un service qui indexe quelques dizaines de documents.
Mesuré après coup : ~900 Mo de RSS au total pour le processus, RAM
disponible du serveur restée stable (~6 Go) pendant tout le chantier.
**Appels HTTP directs (`Illuminate\Support\Facades\Http`), pas le client
PHP officiel `elastic/elasticsearch`.** Le besoin se limite à indexer des
documents et faire une recherche plein texte avec surlignage — deux appels
REST simples. Ajouter une bibliothèque cliente complète (et ses
dépendances) pour ça aurait été une techno sans besoin réel (CLAUDE.md,
section 8).
**Analyseur `french` d'Elasticsearch sur les champs `titre`/`contenu`.**
Livré avec Elasticsearch, pas un plugin à installer : améliore réellement la
pertinence sur du contenu pédagogique en français (élision, racines des
mots) sans coût d'exploitation supplémentaire.
**Reconstruction complète de l'index à chaque synchronisation**
(`labs:synchroniser`, `cours:synchroniser`), pas une mise à jour
incrémentale. Volume du catalogue trop faible pour que ça coûte quoi que ce
soit ; plus simple et plus sûr (jamais de document orphelin si un lab est
retiré).
## Conséquences
- Recherche vérifiée en réel avec un compte élève jetable : les deux vrais
documents existants (labs catalogués + `08-deux-lan-routeur/cours.md`,
contenu réel) retrouvés par recherche plein texte, extrait surligné
affiché, compte supprimé ensuite.
- `App\Services\Recherche\IndexRecherche::disponible()` renvoie `false` si
`ELASTICSEARCH_HOTE` n'est pas renseigné : la page de recherche affiche
alors un message d'indisponibilité au lieu d'une erreur — même principe
que `StockageTravauxEleves::disponible()` pour le S3.
- En dette : pas de pagination des résultats (taille de catalogue bien en
dessous du besoin), pas de recherche à facettes (niveau, compétence) —
à ajouter si le catalogue grossit réellement.

View file

@ -40,3 +40,4 @@ retirer la ligne (et le noter dans le journal) quand c'est fait.
| Ceph : démo mono-serveur seulement (1 OSD sur fichier épars, pas de réplication, pas branché à Cinder/Glance) | `ceph_pool_default_size=1` et un seul OSD acceptés uniquement parce que c'est une démo de compréhension, sans donnée réelle. Le vrai cluster (plusieurs OSD, réplication, intégré à Cinder) attend le 2ᵉ serveur du CLAUDE.md. Voir [ADR 0007](adr/0007-ceph-demo-mono-serveur.md). | 2ᵉ serveur (bloc C) | 2026-10-14 |
| Ceph : bootstrap fait à la main, pas encore rejouable par Ansible | Comme Kolla à l'étape 2 : acceptable pour une démo, à automatiser si ce cluster doit durer. Le fichier systemd du loop device est suivi dans `infra/ceph/` mais déployé à la main. | Si la démo devient durable | 2026-10-14 |
| WebDAV de l'élève : stockage local, pas S3 ; pas de quota par élève | Dossier local (`storage/app/webdav/`), partage le disque serré avec OpenStack et le portail — pas de limite de taille par élève. rclone sait servir un backend S3 sans changement d'architecture (voir [ADR 0008](adr/0008-webdav-eleve-auth-request.md)) : à basculer en même temps que le bucket S3 des devoirs (`docs/runbooks/stockage-s3.md`), et à ajouter un quota si l'usage réel le demande. | Quand le bucket S3 existe / si l'usage le demande | 2026-10-14 |
| Recherche (Elasticsearch) : pas de pagination ni de facettes | Catalogue minuscule aujourd'hui (quelques labs, un seul vrai `cours.md`) : inutile tant que ça reste le cas. Voir [ADR 0009](adr/0009-elasticsearch-interne-sans-securite.md). | Si le catalogue grossit réellement | 2026-10-14 |

View file

@ -1251,3 +1251,26 @@ de haut en bas — une bonne question à savoir expliquer en entretien SRE.
**Suite**
Prochain chantier du bloc C : ElasticSearch.
## 2026-10-14 — Recherche plein texte, Elasticsearch (bloc C)
Troisième chantier du bloc C : recherche plein texte dans les cours et les
labs. Détail des choix : [ADR 0009](adr/0009-elasticsearch-interne-sans-securite.md).
**Fait**
- Elasticsearch 8.19 installé depuis le dépôt officiel (clé GPG vérifiée), instance strictement interne : écoute `127.0.0.1` seulement, sécurité x-pack désactivée (cohérent avec le reste du serveur — l'API OpenStack interne et le MariaDB/Redis du portail n'ont pas de TLS entre services non plus, l'isolation vient du pare-feu), tas JVM limité à 512 Mo (le calcul automatique d'Elasticsearch, jusqu'à 50 % de la RAM système, aurait pu réserver ~15 Go sur ce serveur à 31 Go réels).
- `App\Services\Recherche\IndexRecherche` : appels HTTP directs (pas de bibliothèque cliente, le besoin se limite à indexer et chercher), analyseur `french` d'Elasticsearch sur les champs texte, `disponible()` même principe que le stockage S3 (la page affiche juste « pas disponible » tant que ce n'est pas configuré, jamais une erreur).
- Index reconstruit automatiquement après `labs:synchroniser` et `cours:synchroniser` — pas de mise à jour incrémentale, le catalogue est trop petit pour que ça coûte quoi que ce soit.
- Page « Recherche » (menu du compte, profs et élèves), route commune aux deux guards (`auth:eleve,web`, déjà utilisée par « Mes cours »/« Paramètres »).
- 5 tests Pest (`Http::fake()`, jamais contre un vrai Elasticsearch) : indisponibilité propre, accès par les deux guards, surlignage affiché avec le bon lien vers la page du cours, reconstruction de l'index envoie bien un document par lab/cours, et n'envoie *aucun* appel réseau quand Elasticsearch n'est pas configuré.
**Vérifié en réel** : index reconstruit sur le serveur depuis les vraies données (2 documents : le lab 08 et son vrai `cours.md`), recherche du mot « routage » retrouve les deux avec le bon score, page `/recherche` testée avec un compte élève jetable (connexion réelle par cookies de session, pas juste un appel direct au contrôleur) — le mot recherché apparaît bien surligné (`<em>routage</em>`) et le lien vers le cours fonctionne. RAM disponible du serveur restée stable (~6 Go) pendant tout le chantier. Compte de test supprimé ensuite.
**Comprendre**
Le réflexe acquis avec Ceph et WebDAV (vérifier l'état réel avant d'agir, mesurer plutôt que deviner) a évité ici une vraie bêtise : Elasticsearch calcule par défaut un tas JVM jusqu'à 50 % de la RAM système, ce qui aurait été catastrophique sur ce serveur sans même un message d'erreur — juste un service qui prend toute la RAM disponible. Fixer explicitement le tas avant le premier démarrage, plutôt qu'après avoir constaté le problème, est le genre de précaution qui ne coûte rien et qui évite un incident.
**Suite**
Bloc C restant : WebDAV et Ceph faits, Elasticsearch fait. ElasticSearch avait été écarté du MCP développeurs par Adam (pas pour l'instant). Prochains chantiers confirmés par Adam : LTI Moodle 1.3, puis SSO ENT (Keycloak) — normalement des étapes V2/V3 du produit, avancées à la demande explicite d'Adam.

View file

@ -0,0 +1,64 @@
# Runbook — Elasticsearch (recherche dans les cours et labs)
Instance interne, jamais exposée, consommée seulement par le portail en PHP
(`App\Services\Recherche\IndexRecherche`). Détail des choix :
[ADR 0009](../adr/0009-elasticsearch-interne-sans-securite.md).
## Où sont les choses
| Élément | Emplacement |
|---|---|
| Config | `/etc/elasticsearch/elasticsearch.yml` (sécurité x-pack désactivée, écoute `127.0.0.1` seulement) |
| Tas JVM | `/etc/elasticsearch/jvm.options.d/heap.options` (512 Mo, voir ADR 0009) |
| Index | `paillasse_catalogue` |
| Déployé par | `ansible/elasticsearch.yml` (rôle `elasticsearch`, rejoue aussi `portail` pour écrire `.env`) |
```
cd ansible && script -q /dev/null ansible-playbook elasticsearch.yml -i inventory.ini
```
## Vérifier que ça fonctionne
```
systemctl is-active elasticsearch
curl -s http://127.0.0.1:9200/paillasse_catalogue/_count
```
Reconstruire l'index manuellement (après un changement de mapping, ou si le
catalogue semble désynchronisé) :
```
cd /var/www/portail
sudo -u www-data HOME=/tmp php8.3 artisan recherche:indexer
```
Autrement, l'index se reconstruit automatiquement après `labs:synchroniser`
et `cours:synchroniser` (voir ces deux commandes).
## Pourquoi la RAM est surveillée
Comme pour Ceph et WebDAV (voir les runbooks correspondants) : ce serveur a
réellement 31 Go de RAM, partagés avec OpenStack et le portail. Le tas JVM
est volontairement limité à 512 Mo (~900 Mo de RSS au total mesuré) — ne
jamais retirer `heap.options` ni laisser Elasticsearch calculer sa taille de
tas automatiquement sur ce serveur.
## Si le besoin grossit réellement
Pas de pagination ni de recherche à facettes pour l'instant (catalogue
minuscule). À ajouter dans `App\Services\Recherche\IndexRecherche` et
`RechercheController` si le catalogue de cours/labs grossit vraiment —
jamais avant, pour ne pas construire une fonction sans usage réel.
## Nettoyer entièrement (si besoin)
```
ssh paillasse
sudo systemctl disable --now elasticsearch
sudo rm -rf /var/lib/elasticsearch /etc/elasticsearch
sudo apt-get purge -y elasticsearch
sudo rm /etc/apt/sources.list.d/elastic-8.x.list /usr/share/keyrings/elastic.gpg
sudo apt-get update
```
Puis retirer `ELASTICSEARCH_HOTE`/`ELASTICSEARCH_PORT` du `.env` du portail
et redémarrer Horizon.

View file

@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Services\Recherche\IndexRecherche;
use Illuminate\Console\Command;
/**
* Rejoue l'index de recherche (bloc C) depuis les tables labs/cours —
* appelé après labs:synchroniser et cours:synchroniser, et disponible ici
* pour une reconstruction manuelle (changement de mapping, par exemple).
*/
class IndexerRecherche extends Command
{
protected $signature = 'recherche:indexer';
protected $description = 'Reconstruit l’index de recherche (cours et labs) dans Elasticsearch';
public function handle(): void
{
if (! IndexRecherche::disponible()) {
$this->warn('Elasticsearch non configuré : rien à indexer.');
return;
}
IndexRecherche::reconstruire();
$this->info('Index reconstruit.');
}
}

View file

@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Models\Cours;
use App\Services\Catalogue\CacheCatalogue;
use App\Services\Recherche\IndexRecherche;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Symfony\Component\Yaml\Yaml;
@ -44,5 +45,6 @@ class SynchroniserCours extends Command
}
CacheCatalogue::viderCours();
IndexRecherche::reconstruire();
}
}

View file

@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Models\Lab;
use App\Services\Catalogue\CacheCatalogue;
use App\Services\Labs\DefinitionLab;
use App\Services\Recherche\IndexRecherche;
use Illuminate\Console\Command;
/**
@ -32,5 +33,6 @@ class SynchroniserLabs extends Command
}
CacheCatalogue::viderLabs();
IndexRecherche::reconstruire();
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Services\Recherche\IndexRecherche;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Recherche plein texte dans les cours et labs (bloc C, CLAUDE.md), commune
* aux profs et aux élèves — pas de donnée sensible dans le catalogue.
*/
class RechercheController extends Controller
{
public function chercher(Request $request): View
{
$texte = trim((string) $request->query('q', ''));
return view('recherche', [
'texte' => $texte,
'resultats' => IndexRecherche::chercher($texte),
'disponible' => IndexRecherche::disponible(),
]);
}
}

View file

@ -0,0 +1,101 @@
<?php
namespace App\Services\Recherche;
use App\Models\Cours;
use App\Models\Lab;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
/**
* Bloc C (CLAUDE.md) : recherche plein texte dans les cours et labs.
* Instance Elasticsearch interne (voir ansible/roles/elasticsearch), appels
* HTTP directs plutôt qu'une bibliothèque cliente : le besoin se limite à
* indexer et chercher, pas de dépendance ajoutée pour ça.
*/
class IndexRecherche
{
public static function disponible(): bool
{
return filled(config('elasticsearch.hote'));
}
public static function reconstruire(): void
{
if (! self::disponible()) {
return;
}
Http::delete(self::url());
Http::put(self::url(), [
'mappings' => [
'properties' => [
'identifiant' => ['type' => 'keyword'],
'type' => ['type' => 'keyword'],
'titre' => ['type' => 'text', 'analyzer' => 'french'],
'contenu' => ['type' => 'text', 'analyzer' => 'french'],
],
],
]);
$documents = Lab::all()
->map(fn (Lab $lab) => [
'identifiant' => $lab->identifiant,
'type' => 'lab',
'titre' => $lab->titre,
'contenu' => trim($lab->titre.' '.implode(' ', $lab->competences ?? [])),
])
->concat(Cours::all()->map(fn (Cours $cours) => [
'identifiant' => $cours->identifiant,
'type' => 'cours',
'titre' => $cours->titre,
'contenu' => trim($cours->titre.' '.self::texteBrut($cours->contenu_markdown)),
]));
foreach ($documents as $document) {
Http::put(self::url("/_doc/{$document['type']}-{$document['identifiant']}"), $document);
}
}
/**
* @return array<int, array{type: string, identifiant: string, titre: string, extrait: string}>
*/
public static function chercher(string $texte): array
{
if (! self::disponible() || trim($texte) === '') {
return [];
}
$reponse = Http::post(self::url('/_search'), [
'query' => ['multi_match' => ['query' => $texte, 'fields' => ['titre^3', 'contenu']]],
'highlight' => ['fields' => ['contenu' => new \stdClass]],
'size' => 20,
]);
if ($reponse->failed()) {
return [];
}
/** @var array<int, array<string, mixed>> $resultats */
$resultats = $reponse->json('hits.hits') ?? [];
return collect($resultats)
->map(fn (array $hit) => [
'type' => $hit['_source']['type'],
'identifiant' => $hit['_source']['identifiant'],
'titre' => $hit['_source']['titre'],
'extrait' => $hit['highlight']['contenu'][0] ?? Str::limit($hit['_source']['contenu'], 200),
])
->all();
}
private static function url(string $chemin = ''): string
{
return 'http://'.config('elasticsearch.hote').':'.config('elasticsearch.port').'/'.config('elasticsearch.index').$chemin;
}
private static function texteBrut(string $markdown): string
{
return trim(preg_replace('/[#*`_\[\]()>-]/', ' ', $markdown) ?? '');
}
}

View file

@ -0,0 +1,10 @@
<?php
return [
// Bloc C (CLAUDE.md) : recherche plein texte dans les cours et labs,
// instance interne (voir ansible/roles/elasticsearch), jamais de
// dépendance ajoutée : appels HTTP directs (App\Services\Recherche\IndexRecherche).
'hote' => env('ELASTICSEARCH_HOTE'),
'port' => env('ELASTICSEARCH_PORT', 9200),
'index' => 'paillasse_catalogue',
];

View file

@ -37,6 +37,10 @@
Mes fichiers
</a>
@endif
<a href="{{ route('recherche') }}" role="menuitem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5 21 21"/></svg>
Recherche
</a>
<a href="{{ route('aide.app') }}" role="menuitem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1 .8-1 1.7v.5"/><path d="M12 17h.01"/></svg>
Aide et assistance

View file

@ -0,0 +1,37 @@
@extends('layouts.app')
@section('titre', 'Recherche')
@section('contenu')
<div class="mx-auto max-w-3xl px-5 py-10">
<h1 class="text-3xl font-extrabold" style="color:var(--sy-ink)">Recherche</h1>
<p class="mt-2 font-semibold" style="color:var(--sy-muted)">Dans les cours et les labs du catalogue.</p>
<form action="{{ route('recherche') }}" method="get" class="mt-6 flex gap-2">
<input type="search" name="q" value="{{ $texte }}" placeholder="Adressage IPv4, routage, chiffrement…" class="flex-1 rounded-xl border border-[#c4c7c5] bg-white px-4 py-2 font-semibold" autofocus>
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Chercher</button>
</form>
@if (! $disponible)
<p class="mt-6 rounded-2xl border p-4 font-semibold" style="border-color:var(--sy-line);background:var(--sy-surface);color:var(--sy-muted)">
La recherche n'est pas disponible pour le moment.
</p>
@elseif ($texte !== '' && empty($resultats))
<p class="mt-6 font-semibold" style="color:var(--sy-muted)">Rien trouvé pour « {{ $texte }} ».</p>
@elseif (! empty($resultats))
<ul class="mt-6 grid gap-3">
@foreach ($resultats as $resultat)
<li class="rounded-2xl border p-5" style="border-color:var(--sy-line);background:var(--sy-surface)">
<p class="font-mono text-xs font-semibold uppercase" style="color:var(--sy-muted)">{{ $resultat['type'] === 'cours' ? 'Cours' : 'Lab' }}</p>
@if ($resultat['type'] === 'cours')
<a href="{{ route('mes-cours.show', $resultat['identifiant']) }}" class="mt-1 block text-lg font-extrabold underline underline-offset-4" style="color:var(--sy-ink)">{{ $resultat['titre'] }}</a>
@else
<p class="mt-1 text-lg font-extrabold" style="color:var(--sy-ink)">{{ $resultat['titre'] }}</p>
@endif
<p class="mt-2 font-semibold" style="color:var(--sy-muted)">{!! $resultat['extrait'] !!}</p>
</li>
@endforeach
</ul>
@endif
</div>
@endsection

View file

@ -29,6 +29,7 @@ use App\Http\Controllers\QuizController;
use App\Http\Controllers\QuizEleveController;
use App\Http\Controllers\RaccourciController;
use App\Http\Controllers\RapportCspController;
use App\Http\Controllers\RechercheController;
use App\Http\Controllers\RejoindreClasseController;
use App\Http\Controllers\ResultatsController;
use App\Http\Controllers\SeanceController;
@ -122,6 +123,7 @@ Route::middleware(['auth:eleve,web', 'session.unique:eleve', 'session.unique:web
Route::post('/raccourcis/{raccourci}/supprimer', [RaccourciController::class, 'destroy'])->name('raccourcis.destroy');
Route::get('/emploi-du-temps', [EmploiDuTempsController::class, 'show'])->name('emploi-du-temps');
Route::get('/mes-cours/{cours:identifiant}', [MesCoursController::class, 'montrer'])->name('mes-cours.show');
Route::get('/recherche', [RechercheController::class, 'chercher'])->middleware('throttle:30,1')->name('recherche');
});
// --- Espace élève (protégé, session unique) ---

View file

@ -0,0 +1,69 @@
<?php
use App\Models\Classe;
use App\Models\Cours;
use App\Models\Eleve;
use App\Models\Lab;
use App\Services\Recherche\IndexRecherche;
use Illuminate\Support\Facades\Http;
test('la recherche affiche un message d’indisponibilité tant qu’Elasticsearch n’est pas configuré', function () {
$eleve = Eleve::factory()->create();
eleve($this, $eleve)->get(route('recherche', ['q' => 'routage']))
->assertOk()
->assertSee('pas disponible');
});
test('un prof et un élève peuvent tous les deux utiliser la recherche', function () {
$eleve = Eleve::factory()->create();
eleve($this, $eleve)->get(route('recherche'))->assertOk();
$classe = Classe::factory()->create();
commeProf($this, $classe->prof)->get(route('recherche'))->assertOk();
});
test('un résultat de type cours renvoie vers la page du cours, avec l’extrait surligné', function () {
config(['elasticsearch.hote' => '127.0.0.1']);
Cours::create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'contenu_markdown' => 'Le routage statique...']);
Http::fake([
'*/_search' => Http::response([
'hits' => ['hits' => [
[
'_source' => ['type' => 'cours', 'identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'contenu' => 'Le routage statique...'],
'highlight' => ['contenu' => ['Le <em>routage</em> statique...']],
],
]],
]),
]);
$eleve = Eleve::factory()->create();
eleve($this, $eleve)->get(route('recherche', ['q' => 'routage']))
->assertOk()
->assertSee('Deux LAN et un routeur')
->assertSee(route('mes-cours.show', '08-deux-lan-routeur'), false)
->assertSee('<em>routage</em>', false);
});
test('reconstruire l’index envoie un document par lab et par cours', function () {
config(['elasticsearch.hote' => '127.0.0.1']);
Http::fake();
Lab::factory()->create(['identifiant' => 'lab-test', 'titre' => 'Lab de test', 'competences' => ['routage']]);
Cours::create(['identifiant' => 'cours-test', 'titre' => 'Cours de test', 'contenu_markdown' => '# Titre']);
IndexRecherche::reconstruire();
Http::assertSent(fn ($r) => $r->method() === 'DELETE' && str_ends_with($r->url(), '/paillasse_catalogue'));
Http::assertSent(fn ($r) => $r->method() === 'PUT' && str_ends_with($r->url(), '/_doc/lab-lab-test'));
Http::assertSent(fn ($r) => $r->method() === 'PUT' && str_ends_with($r->url(), '/_doc/cours-cours-test'));
});
test('sans Elasticsearch configuré, reconstruire l’index ne fait aucun appel réseau', function () {
Http::fake();
IndexRecherche::reconstruire();
Http::assertNothingSent();
});