Compare commits
15 Commits
e5e6f72f92
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a1b37c453d | |||
| 96b598c6f4 | |||
| 2984b9f410 | |||
| 507d77246c | |||
| dede70f70f | |||
| 54f2c0e9ab | |||
| e8c6733b40 | |||
| 63240f2e52 | |||
| ab4f2cb403 | |||
| 4d560a03cc | |||
| b568bc63a9 | |||
| 1e6557729f | |||
| ad6b6eb047 | |||
| 3b7b527462 | |||
| 79c902cf32 |
@@ -69,4 +69,19 @@ AWS_DEFAULT_REGION=us-east-1
|
|||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
# --- Gitea (tickets de suggestion créés depuis le site) ---
|
||||||
|
# Token d'accès Gitea avec le scope "write:issue" (Paramètres > Applications > Générer un token).
|
||||||
|
# À garder secret (ne jamais committer la vraie valeur).
|
||||||
|
GITEA_URL=https://gitea.sam-coffre.duckdns.org
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GITEA_REPO=Groupe1/Ressources-relationnel
|
||||||
|
|
||||||
|
# --- Applications mobiles (page /telecharger) ---
|
||||||
|
# Laisser vide pour servir les fichiers de public/downloads/ (.apk / .ipa),
|
||||||
|
# ou renseigner un lien de store / TestFlight (prioritaire sur le fichier local).
|
||||||
|
MOBILE_ANDROID_URL=
|
||||||
|
MOBILE_IOS_URL=
|
||||||
|
MOBILE_ANDROID_VERSION=1.0.0
|
||||||
|
MOBILE_IOS_VERSION=1.0.0
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
+35
-7
@@ -15,16 +15,28 @@ jobs:
|
|||||||
quality-and-tests:
|
quality-and-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Jetons SonarQube, définis dans les secrets du dépôt Gitea.
|
||||||
|
# Exposés au niveau du job pour pouvoir conditionner l'étape d'analyse
|
||||||
|
# (elle ne s'exécute que si un jeton est configuré).
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupération du code
|
- name: Récupération du code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Historique complet : SonarQube en a besoin pour dater le code
|
||||||
|
# et calculer correctement le "nouveau code".
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Installation de PHP 8.2
|
- name: Installation de PHP 8.2
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.2'
|
php-version: '8.2'
|
||||||
extensions: pdo_mysql, mbstring, exif, pcntl, bcmath, gd
|
extensions: pdo_mysql, pdo_sqlite, sqlite3, mbstring, exif, pcntl, bcmath, gd
|
||||||
coverage: none
|
# pcov : moteur de couverture rapide, requis pour le rapport SonarQube.
|
||||||
|
coverage: pcov
|
||||||
|
|
||||||
- name: Cache des dépendances Composer
|
- name: Cache des dépendances Composer
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -43,8 +55,24 @@ jobs:
|
|||||||
- name: Vérification du style de code (Laravel Pint)
|
- name: Vérification du style de code (Laravel Pint)
|
||||||
run: vendor/bin/pint --test
|
run: vendor/bin/pint --test
|
||||||
|
|
||||||
- name: Exécution des tests automatisés (PHPUnit)
|
- name: Analyse statique (Larastan / PHPStan)
|
||||||
env:
|
run: vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=512M --no-progress
|
||||||
DB_CONNECTION: sqlite
|
|
||||||
DB_DATABASE: ':memory:'
|
- name: Exécution des tests + couverture (PHPUnit)
|
||||||
run: php artisan test
|
# phpunit.xml définit déjà l'environnement de test (sqlite en mémoire).
|
||||||
|
# On génère le rapport de couverture (clover) et le rapport de tests
|
||||||
|
# (junit) consommés ensuite par SonarQube.
|
||||||
|
run: |
|
||||||
|
mkdir -p coverage
|
||||||
|
vendor/bin/phpunit \
|
||||||
|
--coverage-clover=coverage/clover.xml \
|
||||||
|
--log-junit=coverage/junit.xml
|
||||||
|
|
||||||
|
- name: Analyse SonarQube
|
||||||
|
# Ne s'exécute que si le secret SONAR_TOKEN est configuré dans Gitea,
|
||||||
|
# sinon l'étape est ignorée (la CI reste verte tant que le serveur
|
||||||
|
# SonarQube n'est pas branché).
|
||||||
|
if: ${{ env.SONAR_TOKEN != '' }}
|
||||||
|
uses: sonarsource/sonarqube-scan-action@v5
|
||||||
|
# Le scanner lit sonar-project.properties et utilise SONAR_HOST_URL /
|
||||||
|
# SONAR_TOKEN exposés au niveau du job.
|
||||||
|
|||||||
@@ -30,8 +30,21 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
cd /home/sam/mon-app-laravel
|
cd /home/sam/mon-app-laravel
|
||||||
|
|
||||||
# Récupération de la dernière version du code
|
DEPLOY_UID=$(id -u)
|
||||||
git pull origin master
|
|
||||||
|
# L'utilisateur de déploiement doit posséder storage/ & bootstrap/cache
|
||||||
|
# AVANT toute opération git (un déploiement précédent a pu les passer à
|
||||||
|
# www-data, ce qui bloquerait git). chown via le conteneur (= root).
|
||||||
|
docker exec -u 0 laravel_php chown -R "${DEPLOY_UID}:www-data" \
|
||||||
|
/var/www/storage /var/www/bootstrap/cache
|
||||||
|
|
||||||
|
# Récupération propre de la dernière version : on ÉCRASE toute modif
|
||||||
|
# locale parasite (le serveur est une copie de déploiement, aucune
|
||||||
|
# édition manuelle n'est censée y vivre). Évite les abandons du type
|
||||||
|
# "local changes would be overwritten by merge". Les fichiers non
|
||||||
|
# suivis (cert.pem, .env…) ne sont PAS touchés par reset --hard.
|
||||||
|
git fetch origin master
|
||||||
|
git reset --hard origin/master
|
||||||
|
|
||||||
# Reconstruction et redémarrage des conteneurs
|
# Reconstruction et redémarrage des conteneurs
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@@ -44,6 +57,14 @@ jobs:
|
|||||||
docker exec laravel_php php artisan route:cache
|
docker exec laravel_php php artisan route:cache
|
||||||
docker exec laravel_php php artisan view:cache
|
docker exec laravel_php php artisan view:cache
|
||||||
|
|
||||||
# Correction des permissions sur les dossiers écrits par Laravel
|
# Correction des permissions sur les dossiers écrits par Laravel.
|
||||||
docker exec -u 0 laravel_php chown -R www-data:www-data \
|
# Laravel (www-data) doit pouvoir écrire dans storage/ et
|
||||||
/var/www/storage /var/www/bootstrap/cache
|
# bootstrap/cache, MAIS l'utilisateur SSH doit rester propriétaire
|
||||||
|
# pour que les futurs "git pull/reset" ne soient pas bloqués (sinon
|
||||||
|
# les .gitignore suivis passent à www-data et git échoue).
|
||||||
|
# => propriétaire = utilisateur de déploiement, groupe = www-data,
|
||||||
|
# avec écriture de groupe + setgid (héritage du groupe www-data).
|
||||||
|
docker exec -u 0 laravel_php sh -c "\
|
||||||
|
chown -R ${DEPLOY_UID}:www-data /var/www/storage /var/www/bootstrap/cache && \
|
||||||
|
chmod -R ug+rwX /var/www/storage /var/www/bootstrap/cache && \
|
||||||
|
find /var/www/storage /var/www/bootstrap/cache -type d -exec chmod g+s {} +"
|
||||||
|
|||||||
@@ -9,13 +9,17 @@
|
|||||||
/.idea
|
/.idea
|
||||||
/.nova
|
/.nova
|
||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
|
/.scannerwork
|
||||||
/.vscode
|
/.vscode
|
||||||
|
/coverage
|
||||||
/.zed
|
/.zed
|
||||||
/auth.json
|
/auth.json
|
||||||
/node_modules
|
/node_modules
|
||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/public/downloads/*.apk
|
||||||
|
/public/downloads/*.ipa
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
FROM php:8.2-fpm
|
FROM php:8.2-fpm
|
||||||
|
|
||||||
# Installation des dépendances système
|
# Installation des dépendances système
|
||||||
|
# libicu-dev est requis pour l'extension PHP intl.
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
git curl libpng-dev libonig-dev libxml2-dev zip unzip
|
git curl libpng-dev libonig-dev libxml2-dev libicu-dev zip unzip
|
||||||
|
|
||||||
# Nettoyage du cache
|
# Nettoyage du cache
|
||||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Installation des extensions PHP pour Laravel et MySQL
|
# Installation des extensions PHP pour Laravel et MySQL
|
||||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd intl
|
||||||
|
|
||||||
# Récupération de Composer
|
# Récupération de Composer
|
||||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
class DownloadController extends Controller
|
||||||
|
{
|
||||||
|
// Page publique de téléchargement des applications mobiles
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return view('telecharger', [
|
||||||
|
'android' => $this->resoudre('android'),
|
||||||
|
'ios' => $this->resoudre('ios'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine, pour une plateforme, le lien de téléchargement et sa disponibilité.
|
||||||
|
*
|
||||||
|
* @return array{version: string, url: string|null, disponible: bool}
|
||||||
|
*/
|
||||||
|
private function resoudre(string $plateforme): array
|
||||||
|
{
|
||||||
|
$config = config("mobile.{$plateforme}");
|
||||||
|
if (! is_array($config)) {
|
||||||
|
$config = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = is_string($config['version'] ?? null) ? $config['version'] : '1.0.0';
|
||||||
|
$url = (is_string($config['url'] ?? null) && $config['url'] !== '') ? $config['url'] : null;
|
||||||
|
$file = is_string($config['file'] ?? null) ? $config['file'] : null;
|
||||||
|
|
||||||
|
// À défaut d'URL explicite, on propose le fichier précompilé s'il existe.
|
||||||
|
if ($url === null && $file !== null && file_exists(public_path($file))) {
|
||||||
|
$url = asset($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => $version,
|
||||||
|
'url' => $url,
|
||||||
|
'disponible' => $url !== null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Suggestion;
|
||||||
|
use App\Models\Utilisateur;
|
||||||
|
use App\Services\GiteaIssueService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class SuggestionController extends Controller
|
||||||
|
{
|
||||||
|
// Afficher le formulaire de suggestion
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
return view('suggestions.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter la suggestion et créer un ticket dans Gitea
|
||||||
|
public function store(Request $request, GiteaIssueService $gitea)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'type' => ['required', Rule::in(['Modification', 'Ajout', 'Correction'])],
|
||||||
|
'titre' => ['required', 'string', 'min:5', 'max:120'],
|
||||||
|
'description' => ['required', 'string', 'min:10', 'max:5000'],
|
||||||
|
], [
|
||||||
|
'type.required' => 'Veuillez choisir un type de demande.',
|
||||||
|
'type.in' => 'Type de demande invalide.',
|
||||||
|
'titre.required' => 'Le titre est requis.',
|
||||||
|
'titre.min' => 'Le titre doit contenir au moins 5 caractères.',
|
||||||
|
'titre.max' => 'Le titre ne peut pas dépasser 120 caractères.',
|
||||||
|
'description.required' => 'La description est requise.',
|
||||||
|
'description.min' => 'Merci de détailler un peu plus votre suggestion (10 caractères minimum).',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var Utilisateur $user */
|
||||||
|
$user = Auth::user();
|
||||||
|
$auteur = $user->Pseudo ?? 'Utilisateur';
|
||||||
|
|
||||||
|
$title = "[{$data['type']}] {$data['titre']}";
|
||||||
|
|
||||||
|
$body = "**Type de demande :** {$data['type']}\n"
|
||||||
|
."**Proposé par :** {$auteur} (via le site (RE)Sources Relationnelles)\n\n"
|
||||||
|
."### Description\n"
|
||||||
|
.$data['description']."\n\n"
|
||||||
|
."---\n"
|
||||||
|
.'_Ticket créé automatiquement depuis le formulaire de suggestions du site._';
|
||||||
|
|
||||||
|
$numero = $gitea->createIssue($title, $body);
|
||||||
|
|
||||||
|
if ($numero === null) {
|
||||||
|
return back()->withInput()->withErrors([
|
||||||
|
'gitea' => "L'envoi de votre suggestion a échoué. Merci de réessayer plus tard.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// La suggestion est conservée en base, liée au ticket, pour le suivi.
|
||||||
|
Suggestion::create([
|
||||||
|
'ID_Utilisateur' => $user->ID_Utilisateur,
|
||||||
|
'Type' => $data['type'],
|
||||||
|
'Titre' => $data['titre'],
|
||||||
|
'Description' => $data['description'],
|
||||||
|
'GiteaIssueNumber' => $numero,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('suggestions.index')
|
||||||
|
->with('success', 'Merci ! Votre suggestion a bien été transmise à l\'équipe. Vous pouvez suivre sa prise en compte ici.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste des suggestions de l'utilisateur, avec l'état de chaque ticket
|
||||||
|
public function index(GiteaIssueService $gitea)
|
||||||
|
{
|
||||||
|
$suggestions = Suggestion::where('ID_Utilisateur', Auth::user()->ID_Utilisateur)
|
||||||
|
->orderByDesc('DateCreation')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// État des tickets (ouvert/fermé, nb de réponses), mis en cache 60 s
|
||||||
|
// pour ne pas appeler l'API Gitea à chaque affichage de la page.
|
||||||
|
$etats = [];
|
||||||
|
|
||||||
|
foreach ($suggestions as $suggestion) {
|
||||||
|
if ($suggestion->GiteaIssueNumber === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$etat = Cache::remember(
|
||||||
|
'gitea.issue.'.$suggestion->GiteaIssueNumber,
|
||||||
|
60,
|
||||||
|
fn () => $gitea->getIssue($suggestion->GiteaIssueNumber) ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$etats[$suggestion->ID_Suggestion] = $etat ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('suggestions.index', compact('suggestions', 'etats'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détail d'une suggestion : fil de discussion du ticket Gitea
|
||||||
|
public function show(Suggestion $suggestion, GiteaIssueService $gitea)
|
||||||
|
{
|
||||||
|
abort_unless($suggestion->ID_Utilisateur === Auth::user()->ID_Utilisateur, 403);
|
||||||
|
|
||||||
|
$issue = null;
|
||||||
|
$commentaires = [];
|
||||||
|
|
||||||
|
if ($suggestion->GiteaIssueNumber !== null) {
|
||||||
|
$issue = $gitea->getIssue($suggestion->GiteaIssueNumber);
|
||||||
|
$commentaires = $gitea->getComments($suggestion->GiteaIssueNumber);
|
||||||
|
|
||||||
|
// Tout est lu : le badge « nouvelle réponse » disparaît de la liste.
|
||||||
|
$suggestion->CommentairesVus = count($commentaires);
|
||||||
|
$suggestion->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('suggestions.show', compact('suggestion', 'issue', 'commentaires'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Répondre sur le ticket : le commentaire est publié sur Gitea
|
||||||
|
public function repondre(Request $request, Suggestion $suggestion, GiteaIssueService $gitea)
|
||||||
|
{
|
||||||
|
abort_unless($suggestion->ID_Utilisateur === Auth::user()->ID_Utilisateur, 403);
|
||||||
|
abort_if($suggestion->GiteaIssueNumber === null, 404);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'message' => ['required', 'string', 'min:2', 'max:2000'],
|
||||||
|
], [
|
||||||
|
'message.required' => 'Votre message est vide.',
|
||||||
|
'message.min' => 'Votre message est trop court.',
|
||||||
|
'message.max' => 'Votre message ne peut pas dépasser 2000 caractères.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var Utilisateur $user */
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$corps = '**'.($user->Pseudo ?? 'Utilisateur')."** (via le site) :\n\n".$data['message'];
|
||||||
|
|
||||||
|
if (! $gitea->addComment($suggestion->GiteaIssueNumber, $corps)) {
|
||||||
|
return back()->withInput()->withErrors([
|
||||||
|
'gitea' => "L'envoi de votre réponse a échoué. Merci de réessayer plus tard.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'état du ticket a changé : on invalide le cache de la liste.
|
||||||
|
Cache::forget('gitea.issue.'.$suggestion->GiteaIssueNumber);
|
||||||
|
|
||||||
|
return redirect()->route('suggestions.show', $suggestion)
|
||||||
|
->with('success', 'Votre réponse a été publiée sur le ticket.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,10 +38,12 @@ class SecurityHeaders
|
|||||||
// Content-Security-Policy : réduit fortement le risque d'injection de scripts (XSS).
|
// Content-Security-Policy : réduit fortement le risque d'injection de scripts (XSS).
|
||||||
// Politique volontairement compatible avec Laravel/Vite (styles et scripts inline autorisés).
|
// Politique volontairement compatible avec Laravel/Vite (styles et scripts inline autorisés).
|
||||||
// Pour durcir davantage, remplacer 'unsafe-inline' par un système de nonce.
|
// Pour durcir davantage, remplacer 'unsafe-inline' par un système de nonce.
|
||||||
|
// NB : l'application charge Tailwind via CDN (cdn.tailwindcss.com) et
|
||||||
|
// les avatars via ui-avatars.com : ces sources externes sont donc autorisées.
|
||||||
$response->headers->set('Content-Security-Policy', implode('; ', [
|
$response->headers->set('Content-Security-Policy', implode('; ', [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline'",
|
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com",
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com",
|
||||||
"img-src 'self' data: https:",
|
"img-src 'self' data: https:",
|
||||||
"font-src 'self' data:",
|
"font-src 'self' data:",
|
||||||
"connect-src 'self'",
|
"connect-src 'self'",
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apprend à un modèle Eloquent à gérer une clé primaire COMPOSITE
|
||||||
|
* (plusieurs colonnes), ce qu'Eloquent ne sait pas faire nativement.
|
||||||
|
*
|
||||||
|
* Eloquent suppose que `$primaryKey` est une seule colonne (chaîne). On garde
|
||||||
|
* donc `$primaryKey` sous forme de chaîne (contrat respecté) et on déclare les
|
||||||
|
* colonnes de la clé composite via compositeKeyColumns(). Les requêtes de
|
||||||
|
* mise à jour / suppression / sélection par clé filtrent alors sur CHAQUE
|
||||||
|
* colonne de la clé.
|
||||||
|
*
|
||||||
|
* Le modèle qui l'utilise doit :
|
||||||
|
* - déclarer `public $incrementing = false;`
|
||||||
|
* - implémenter compositeKeyColumns() en listant les colonnes de la clé.
|
||||||
|
*/
|
||||||
|
trait HasCompositePrimaryKey
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Colonnes qui composent la clé primaire composite.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
abstract protected function compositeKeyColumns(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeur de la clé sous forme de tableau « colonne => valeur ».
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getKey()
|
||||||
|
{
|
||||||
|
$key = [];
|
||||||
|
|
||||||
|
foreach ($this->compositeKeyColumns() as $column) {
|
||||||
|
$key[$column] = $this->getAttribute($column);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cible chaque colonne de la clé composite lors d'un save / update.
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
protected function setKeysForSaveQuery($query)
|
||||||
|
{
|
||||||
|
foreach ($this->compositeKeyColumns() as $column) {
|
||||||
|
$query->where($column, '=', $this->getKeyForSaveQuery($column));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeur d'une colonne de la clé pour un save (valeur d'origine si dispo).
|
||||||
|
*
|
||||||
|
* @param string|null $column
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
protected function getKeyForSaveQuery($column = null)
|
||||||
|
{
|
||||||
|
$column ??= $this->getKeyName();
|
||||||
|
|
||||||
|
return $this->original[$column] ?? $this->getAttribute($column);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cible chaque colonne de la clé composite lors d'une sélection par clé.
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
protected function setKeysForSelectQuery($query)
|
||||||
|
{
|
||||||
|
foreach ($this->compositeKeyColumns() as $column) {
|
||||||
|
$query->where($column, '=', $this->getKeyForSelectQuery($column));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeur d'une colonne de la clé pour une sélection.
|
||||||
|
*
|
||||||
|
* @param string|null $column
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
protected function getKeyForSelectQuery($column = null)
|
||||||
|
{
|
||||||
|
$column ??= $this->getKeyName();
|
||||||
|
|
||||||
|
return $this->original[$column] ?? $this->getAttribute($column);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $ID_Suggestion
|
||||||
|
* @property int $ID_Utilisateur
|
||||||
|
* @property string $Type
|
||||||
|
* @property string $Titre
|
||||||
|
* @property string $Description
|
||||||
|
* @property int|null $GiteaIssueNumber
|
||||||
|
* @property int $CommentairesVus
|
||||||
|
* @property Carbon $DateCreation
|
||||||
|
*/
|
||||||
|
class Suggestion extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'suggestions';
|
||||||
|
|
||||||
|
protected $primaryKey = 'ID_Suggestion';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'ID_Utilisateur',
|
||||||
|
'Type',
|
||||||
|
'Titre',
|
||||||
|
'Description',
|
||||||
|
'GiteaIssueNumber',
|
||||||
|
'CommentairesVus',
|
||||||
|
'DateCreation',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'DateCreation' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function utilisateur(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Utilisateur::class, 'ID_Utilisateur', 'ID_Utilisateur');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,33 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasCompositePrimaryKey;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class Utilisateur_Progression extends Model
|
class Utilisateur_Progression extends Model
|
||||||
{
|
{
|
||||||
|
use HasCompositePrimaryKey;
|
||||||
|
|
||||||
protected $table = 'utilisateur_progressions';
|
protected $table = 'utilisateur_progressions';
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
|
|
||||||
protected $primaryKey = ['ID_Utilisateur', 'ID_Ressource'];
|
// Clé primaire composite (ID_Utilisateur + ID_Ressource), gérée par le
|
||||||
|
// trait via compositeKeyColumns(). Eloquent impose une chaîne pour
|
||||||
|
// $primaryKey : on y met la 1re colonne pour rester conforme au contrat.
|
||||||
|
protected $primaryKey = 'ID_Utilisateur';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colonnes composant la clé primaire composite.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
protected function compositeKeyColumns(): array
|
||||||
|
{
|
||||||
|
return ['ID_Utilisateur', 'ID_Ressource'];
|
||||||
|
}
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'ID_Utilisateur',
|
'ID_Utilisateur',
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class GiteaIssueService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Crée un ticket (issue) dans le dépôt Gitea configuré.
|
||||||
|
*
|
||||||
|
* @return int|null le numéro du ticket créé, ou null en cas d'échec.
|
||||||
|
*/
|
||||||
|
public function createIssue(string $title, string $body): ?int
|
||||||
|
{
|
||||||
|
$client = $this->client();
|
||||||
|
|
||||||
|
if ($client === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $client->post($this->repoPath().'/issues', [
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Appel API Gitea impossible : '.$e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('Création du ticket Gitea échouée', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $response->json('number');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère l'état d'un ticket (état ouvert/fermé, nombre de commentaires).
|
||||||
|
*
|
||||||
|
* @return array{state: string, comments: int, title: string}|null
|
||||||
|
*/
|
||||||
|
public function getIssue(int $number): ?array
|
||||||
|
{
|
||||||
|
$client = $this->client();
|
||||||
|
|
||||||
|
if ($client === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $client->get($this->repoPath().'/issues/'.$number);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Lecture du ticket Gitea impossible : '.$e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'state' => (string) $response->json('state'),
|
||||||
|
'comments' => (int) $response->json('comments'),
|
||||||
|
'title' => (string) $response->json('title'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les commentaires (réponses) d'un ticket.
|
||||||
|
*
|
||||||
|
* @return list<array{auteur: string, corps: string, date: string}>
|
||||||
|
*/
|
||||||
|
public function getComments(int $number): array
|
||||||
|
{
|
||||||
|
$client = $this->client();
|
||||||
|
|
||||||
|
if ($client === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $client->get($this->repoPath().'/issues/'.$number.'/comments');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Lecture des commentaires Gitea impossible : '.$e->getMessage());
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentaires = [];
|
||||||
|
|
||||||
|
foreach ($response->json() ?? [] as $commentaire) {
|
||||||
|
$commentaires[] = [
|
||||||
|
'auteur' => (string) ($commentaire['user']['username'] ?? $commentaire['user']['login'] ?? 'inconnu'),
|
||||||
|
'corps' => (string) ($commentaire['body'] ?? ''),
|
||||||
|
'date' => (string) ($commentaire['created_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $commentaires;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un commentaire (réponse de l'utilisateur) sur un ticket.
|
||||||
|
*/
|
||||||
|
public function addComment(int $number, string $body): bool
|
||||||
|
{
|
||||||
|
$client = $this->client();
|
||||||
|
|
||||||
|
if ($client === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $client->post($this->repoPath().'/issues/'.$number.'/comments', [
|
||||||
|
'body' => $body,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Ajout du commentaire Gitea impossible : '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('Ajout du commentaire Gitea échoué', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client HTTP configuré, ou null si la configuration Gitea est incomplète.
|
||||||
|
*/
|
||||||
|
private function client(): ?PendingRequest
|
||||||
|
{
|
||||||
|
$url = rtrim((string) config('services.gitea.url'), '/');
|
||||||
|
$token = (string) config('services.gitea.token');
|
||||||
|
|
||||||
|
if ($url === '' || $this->repo() === '' || $token === '') {
|
||||||
|
Log::warning('Gitea non configuré (URL / token / dépôt manquant).');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea attend l'en-tête "Authorization: token <TOKEN>".
|
||||||
|
return Http::baseUrl($url)
|
||||||
|
->withToken($token, 'token')
|
||||||
|
->acceptJson()
|
||||||
|
->timeout(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function repo(): string
|
||||||
|
{
|
||||||
|
return trim((string) config('services.gitea.repo'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function repoPath(): string
|
||||||
|
{
|
||||||
|
return '/api/v1/repos/'.$this->repo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
// L'application tourne derrière nginx-proxy-manager : on fait confiance
|
||||||
|
// au reverse proxy pour lire X-Forwarded-Proto/Host (sinon Laravel croit
|
||||||
|
// être en HTTP et génère de mauvaises URL / redirections).
|
||||||
|
$middleware->trustProxies(at: '*');
|
||||||
|
|
||||||
$middleware->append(ForceHttps::class);
|
$middleware->append(ForceHttps::class);
|
||||||
$middleware->append(SecurityHeaders::class);
|
$middleware->append(SecurityHeaders::class);
|
||||||
})
|
})
|
||||||
|
|||||||
+7
-1
@@ -12,11 +12,13 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
"larastan/larastan": "^3.10",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.24",
|
"laravel/pint": "^1.24",
|
||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"phpro/grumphp": "^2.22",
|
||||||
"phpunit/phpunit": "^11.5.50"
|
"phpunit/phpunit": "^11.5.50"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -76,9 +78,13 @@
|
|||||||
"optimize-autoloader": true,
|
"optimize-autoloader": true,
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
|
"platform": {
|
||||||
|
"php": "8.2.32"
|
||||||
|
},
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"pestphp/pest-plugin": true,
|
"pestphp/pest-plugin": true,
|
||||||
"php-http/discovery": true
|
"php-http/discovery": true,
|
||||||
|
"phpro/grumphp": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
|
|||||||
Generated
+2760
-327
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Applications mobiles (téléchargement)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Pour chaque plateforme, la page de téléchargement utilise en priorité
|
||||||
|
| l'URL fournie (lien de store OU URL directe). À défaut, elle propose le
|
||||||
|
| fichier précompilé déposé dans public/downloads/. Si aucun des deux n'est
|
||||||
|
| disponible, le bouton affiche « bientôt disponible ».
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'android' => [
|
||||||
|
'version' => env('MOBILE_ANDROID_VERSION', '1.0.0'),
|
||||||
|
'url' => env('MOBILE_ANDROID_URL'),
|
||||||
|
'file' => 'downloads/ressources-relationnelles.apk',
|
||||||
|
],
|
||||||
|
|
||||||
|
'ios' => [
|
||||||
|
'version' => env('MOBILE_IOS_VERSION', '1.0.0'),
|
||||||
|
'url' => env('MOBILE_IOS_URL'),
|
||||||
|
'file' => 'downloads/ressources-relationnelles.ipa',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -35,4 +35,12 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Gitea : création automatique de tickets (issues) depuis le formulaire
|
||||||
|
// de suggestions du site. Le token doit avoir le scope "write:issue".
|
||||||
|
'gitea' => [
|
||||||
|
'url' => env('GITEA_URL', 'https://gitea.sam-coffre.duckdns.org'),
|
||||||
|
'token' => env('GITEA_TOKEN'),
|
||||||
|
'repo' => env('GITEA_REPO', 'Groupe1/Ressources-relationnel'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Suivi des suggestions : chaque suggestion est liée au ticket Gitea
|
||||||
|
// créé automatiquement, pour permettre à l'utilisateur de suivre les
|
||||||
|
// réponses de l'équipe depuis son interface web.
|
||||||
|
Schema::create('suggestions', function (Blueprint $table) {
|
||||||
|
$table->id('ID_Suggestion');
|
||||||
|
$table->foreignId('ID_Utilisateur')->constrained('utilisateurs', 'ID_Utilisateur')->onDelete('cascade');
|
||||||
|
$table->string('Type', 50);
|
||||||
|
$table->string('Titre', 150);
|
||||||
|
$table->text('Description');
|
||||||
|
$table->unsignedInteger('GiteaIssueNumber')->nullable();
|
||||||
|
// Nombre de commentaires déjà consultés (pour le badge « nouvelle réponse »)
|
||||||
|
$table->unsignedInteger('CommentairesVus')->default(0);
|
||||||
|
$table->timestamp('DateCreation')->useCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('suggestions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Serveur SonarQube (auto-hébergé) pour l'analyse de qualité du code.
|
||||||
|
#
|
||||||
|
# Démarrage : docker compose -f docker-compose.sonar.yml up -d
|
||||||
|
# Interface : http://localhost:9000 (identifiants par défaut : admin / admin)
|
||||||
|
# Arrêt : docker compose -f docker-compose.sonar.yml down
|
||||||
|
#
|
||||||
|
# ⚠️ Prérequis hôte (Elasticsearch embarqué dans SonarQube) :
|
||||||
|
# Linux : sudo sysctl -w vm.max_map_count=262144
|
||||||
|
# (à rendre permanent dans /etc/sysctl.conf)
|
||||||
|
# Prévoir ~2-3 Go de RAM disponibles pour le conteneur.
|
||||||
|
|
||||||
|
services:
|
||||||
|
sonarqube:
|
||||||
|
image: sonarqube:community
|
||||||
|
container_name: sonarqube
|
||||||
|
depends_on:
|
||||||
|
- sonar-db
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
environment:
|
||||||
|
SONAR_JDBC_URL: jdbc:postgresql://sonar-db:5432/sonar
|
||||||
|
SONAR_JDBC_USERNAME: sonar
|
||||||
|
SONAR_JDBC_PASSWORD: sonar
|
||||||
|
volumes:
|
||||||
|
- sonarqube_data:/opt/sonarqube/data
|
||||||
|
- sonarqube_extensions:/opt/sonarqube/extensions
|
||||||
|
- sonarqube_logs:/opt/sonarqube/logs
|
||||||
|
|
||||||
|
sonar-db:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: sonar-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: sonar
|
||||||
|
POSTGRES_PASSWORD: sonar
|
||||||
|
POSTGRES_DB: sonar
|
||||||
|
volumes:
|
||||||
|
- sonar_postgresql:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sonarqube_data:
|
||||||
|
sonarqube_extensions:
|
||||||
|
sonarqube_logs:
|
||||||
|
sonar_postgresql:
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 05 — Mise en place de Gitea Actions (CI/CD)
|
||||||
|
|
||||||
|
Guide d'installation du moteur d'automatisation (CI/CD) pour le dépôt.
|
||||||
|
Contexte : **Gitea en Docker / docker-compose**, accès **SSH** au serveur.
|
||||||
|
|
||||||
|
Gitea Actions nécessite deux choses :
|
||||||
|
1. **Activer** la fonctionnalité Actions dans Gitea.
|
||||||
|
2. Enregistrer au moins un **runner** (`act_runner`) qui exécute les workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 1 — Activer Actions dans Gitea
|
||||||
|
|
||||||
|
Se connecter en SSH au serveur, puis se placer dans le dossier du `docker-compose.yml`
|
||||||
|
**de Gitea** (⚠️ celui de Gitea, pas celui de l'application Laravel).
|
||||||
|
|
||||||
|
### Option A (recommandée) — via variable d'environnement
|
||||||
|
|
||||||
|
Dans le service Gitea du `docker-compose.yml`, ajouter la variable :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
server: # nom du service Gitea (peut s'appeler "gitea")
|
||||||
|
image: gitea/gitea:latest
|
||||||
|
environment:
|
||||||
|
- GITEA__actions__ENABLED=true
|
||||||
|
# ... reste de la configuration inchangé
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis recharger Gitea :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B — via le fichier app.ini
|
||||||
|
|
||||||
|
Éditer le fichier `app.ini` (dans le volume Gitea, souvent
|
||||||
|
`./gitea/gitea/conf/app.ini`) et ajouter :
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[actions]
|
||||||
|
ENABLED = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis redémarrer : `docker compose restart`.
|
||||||
|
|
||||||
|
### Vérification
|
||||||
|
|
||||||
|
Dans l'interface web du dépôt, un onglet **« Actions »** doit apparaître.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 2 — Obtenir un jeton d'enregistrement du runner
|
||||||
|
|
||||||
|
Deux niveaux possibles :
|
||||||
|
|
||||||
|
- **Niveau instance** (runner partagé par tous les dépôts) :
|
||||||
|
`Site Administration → Actions → Runners → Create new Runner`.
|
||||||
|
- **Niveau dépôt** (runner dédié au projet) :
|
||||||
|
`Dépôt → Settings → Actions → Runners → Create new Runner`.
|
||||||
|
|
||||||
|
Copier le **jeton (registration token)** affiché.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 3 — Lancer un runner (act_runner) en Docker
|
||||||
|
|
||||||
|
Créer un dossier de travail sur le serveur, par exemple `~/gitea-runner`, avec un
|
||||||
|
fichier `docker-compose.yml` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
runner:
|
||||||
|
image: gitea/act_runner:latest
|
||||||
|
container_name: gitea_runner
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
GITEA_INSTANCE_URL: "https://gitea.sam-coffre.duckdns.org"
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN: "COLLER_LE_JETON_ICI"
|
||||||
|
GITEA_RUNNER_NAME: "runner-docker-1"
|
||||||
|
# Images utilisées pour `runs-on: ubuntu-latest`
|
||||||
|
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
> Le montage de `/var/run/docker.sock` permet au runner de créer les conteneurs
|
||||||
|
> qui exécutent chaque job. L'image `catthehacker/ubuntu:act-latest` fournit un
|
||||||
|
> environnement compatible `ubuntu-latest` (Node, outils de base).
|
||||||
|
|
||||||
|
Démarrer le runner :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/gitea-runner
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f runner # doit afficher "runner registered successfully"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le runner apparaît alors en vert dans `Settings → Actions → Runners` du dépôt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 4 — Ajouter les secrets de déploiement
|
||||||
|
|
||||||
|
Dans `Dépôt → Settings → Actions → Secrets`, créer :
|
||||||
|
|
||||||
|
| Secret | Valeur |
|
||||||
|
|--------|--------|
|
||||||
|
| `SSH_HOST` | IP ou domaine du serveur de production |
|
||||||
|
| `SSH_USER` | utilisateur SSH (ex. `sam`) |
|
||||||
|
| `SSH_PRIVATE_KEY` | clé privée SSH autorisée sur le serveur |
|
||||||
|
| `SSH_PORT` | port SSH (ex. `22`) |
|
||||||
|
|
||||||
|
> Générer une paire de clés dédiée au déploiement :
|
||||||
|
> `ssh-keygen -t ed25519 -C "gitea-deploy" -f deploy_key`
|
||||||
|
> puis ajouter `deploy_key.pub` dans `~/.ssh/authorized_keys` du serveur, et
|
||||||
|
> coller le contenu de `deploy_key` (clé privée) dans le secret `SSH_PRIVATE_KEY`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 5 — Déclencher et vérifier
|
||||||
|
|
||||||
|
Un simple push sur `master` déclenche les workflows :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit --allow-empty -m "test: déclenche la CI"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans l'onglet **Actions** du dépôt :
|
||||||
|
- le workflow **CI** (`ci.yml`) exécute Pint puis PHPUnit ;
|
||||||
|
- le workflow **CD** (`deploy.yml`) déploie via SSH.
|
||||||
|
|
||||||
|
Une exécution en vert constitue une **preuve idéale à intégrer au dossier** (capture d'écran).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dépannage rapide
|
||||||
|
|
||||||
|
| Symptôme | Cause probable / solution |
|
||||||
|
|----------|---------------------------|
|
||||||
|
| Pas d'onglet Actions | Actions non activé (Étape 1) ou Gitea non redémarré |
|
||||||
|
| « No runner available » | Runner non enregistré / hors ligne (Étape 3) |
|
||||||
|
| `ubuntu-latest` introuvable | Label non défini → variable `GITEA_RUNNER_LABELS` |
|
||||||
|
| `actions/checkout` échoue | Le serveur doit accéder à github.com (Gitea proxy les actions) |
|
||||||
|
| Déploiement SSH refusé | Vérifier la clé dans `authorized_keys` et les secrets SSH_* |
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# Configuration GrumPHP : vérifications automatiques avant chaque commit.
|
||||||
|
# À chaque `git commit`, GrumPHP lance ces tâches ; si l'une échoue,
|
||||||
|
# le commit est annulé. On évite ainsi de pousser du code non formaté
|
||||||
|
# ou contenant des erreurs détectables statiquement (= CI qui reste verte).
|
||||||
|
grumphp:
|
||||||
|
# On laisse le temps aux analyses (Larastan peut être un peu long).
|
||||||
|
process_timeout: 120
|
||||||
|
|
||||||
|
# On n'arrête pas au premier échec : on veut voir TOUS les problèmes
|
||||||
|
# (Pint ET PHPStan) en une seule fois.
|
||||||
|
stop_on_failure: false
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
# 1) Style de code (Laravel Pint) — mêmes règles qu'en CI.
|
||||||
|
shell:
|
||||||
|
scripts:
|
||||||
|
- ['-c', 'vendor/bin/pint --test']
|
||||||
|
|
||||||
|
# 2) Analyse statique (Larastan / PHPStan) — détecte les vrais bugs.
|
||||||
|
phpstan:
|
||||||
|
configuration: phpstan.neon
|
||||||
|
# false => on analyse les chemins définis dans phpstan.neon (app/)
|
||||||
|
# plutôt que seulement les fichiers modifiés : analyse plus fiable.
|
||||||
|
use_grumphp_paths: false
|
||||||
|
# Larastan charge tout le framework : 128M par défaut ne suffit pas.
|
||||||
|
memory_limit: '512M'
|
||||||
+7
-6
@@ -1,14 +1,15 @@
|
|||||||
|
# Ce serveur nginx est DERRIÈRE nginx-proxy-manager (NPM), qui termine le TLS
|
||||||
|
# public (certificat Let's Encrypt) et force déjà HTTP -> HTTPS pour le domaine.
|
||||||
|
# Ici, on se contente donc de SERVIR l'application, sans redirection interne
|
||||||
|
# (l'ancienne redirection vers https://$server_name renvoyait vers "localhost").
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
|
||||||
return 301 https://$server_name$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name localhost;
|
server_name app.sam-coffre.duckdns.org localhost _;
|
||||||
|
|
||||||
ssl_certificate /var/www/cert.pem;
|
ssl_certificate /var/www/cert.pem;
|
||||||
ssl_certificate_key /var/www/cert.key;
|
ssl_certificate_key /var/www/cert.key;
|
||||||
|
|
||||||
index index.php index.html;
|
index index.php index.html;
|
||||||
root /var/www/public;
|
root /var/www/public;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
includes:
|
||||||
|
- vendor/larastan/larastan/extension.neon
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
paths:
|
||||||
|
- app/
|
||||||
|
|
||||||
|
# Niveau d'analyse statique (0 = souple, 9/max = très strict).
|
||||||
|
# On démarre à 5 : bon compromis pour attraper les vrais bugs
|
||||||
|
# (typage, null, méthodes inexistantes) sans bloquer sur des détails.
|
||||||
|
level: 5
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Applications mobiles précompilées
|
||||||
|
|
||||||
|
Déposez ici les binaires servis par la page **/telecharger** :
|
||||||
|
|
||||||
|
- `ressources-relationnelles.apk` — application Android
|
||||||
|
- `ressources-relationnelles.ipa` — application Apple (iOS)
|
||||||
|
|
||||||
|
La page détecte automatiquement la présence de ces fichiers.
|
||||||
|
|
||||||
|
## Alternative : liens de store
|
||||||
|
|
||||||
|
Plutôt que des fichiers locaux, vous pouvez renseigner des liens (Play Store,
|
||||||
|
App Store, TestFlight…) dans le fichier `.env` du serveur :
|
||||||
|
|
||||||
|
```
|
||||||
|
MOBILE_ANDROID_URL=https://play.google.com/store/apps/details?id=...
|
||||||
|
MOBILE_IOS_URL=https://apps.apple.com/app/id...
|
||||||
|
MOBILE_ANDROID_VERSION=1.0.0
|
||||||
|
MOBILE_IOS_VERSION=1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Si une URL est renseignée, elle est prioritaire sur le fichier local.
|
||||||
|
Si ni URL ni fichier ne sont disponibles, le bouton affiche « Bientôt disponible ».
|
||||||
|
|
||||||
|
> Les binaires (`*.apk`, `*.ipa`) ne sont pas versionnés dans git (voir `.gitignore`).
|
||||||
Binary file not shown.
@@ -23,7 +23,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex items-center gap-2 whitespace-nowrap">
|
<nav class="flex items-center gap-2 whitespace-nowrap">
|
||||||
|
<a href="{{ route('telecharger') }}" class="hidden lg:inline-flex items-center px-4 py-2.5 text-sm font-bold text-gray-700 rounded-lg hover:bg-gray-100 transition-smooth duration-200">
|
||||||
|
📱 Télécharger l'app
|
||||||
|
</a>
|
||||||
@auth
|
@auth
|
||||||
|
<a href="{{ route('suggestions.create') }}"
|
||||||
|
title="Proposer une amélioration"
|
||||||
|
aria-label="Proposer une amélioration"
|
||||||
|
class="inline-flex items-center px-3 sm:px-4 py-2.5 text-sm font-bold text-niagara border-2 border-niagara rounded-lg hover:bg-niagara hover:text-white transition-smooth duration-200">
|
||||||
|
💡<span class="hidden sm:inline"> Proposer une amélioration</span>
|
||||||
|
</a>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<button class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-smooth duration-200">
|
<button class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-smooth duration-200">
|
||||||
<img
|
<img
|
||||||
@@ -36,7 +45,10 @@
|
|||||||
|
|
||||||
<!-- Menu déroulant -->
|
<!-- Menu déroulant -->
|
||||||
<div class="absolute right-0 mt-0 w-48 bg-white rounded-lg shadow-xl border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
<div class="absolute right-0 mt-0 w-48 bg-white rounded-lg shadow-xl border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||||
<a href="{{ route('profile.settings') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200 rounded-t-lg">
|
<a href="{{ route('suggestions.index') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200 rounded-t-lg">
|
||||||
|
💬 Mes suggestions
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('profile.settings') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200">
|
||||||
⚙️ Paramètres du profil
|
⚙️ Paramètres du profil
|
||||||
</a>
|
</a>
|
||||||
<form action="{{ route('logout') }}" method="POST">
|
<form action="{{ route('logout') }}" method="POST">
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
@extends('app')
|
||||||
|
|
||||||
|
@section('title', 'Proposer une amélioration - (RE)Sources Relationnelles')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section class="container mx-auto px-4 py-8 sm:py-12">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="mb-6 sm:mb-8">
|
||||||
|
<h1 class="text-2xl sm:text-4xl font-bold text-gray-900 mb-2">💡 Proposer une amélioration</h1>
|
||||||
|
<p class="text-sm sm:text-base text-gray-600">
|
||||||
|
Une idée, une correction, un ajout ? Votre suggestion est directement transmise à
|
||||||
|
l'équipe sous forme de ticket. Merci de votre contribution !
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message de succès -->
|
||||||
|
@if (session('success'))
|
||||||
|
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<p class="text-green-700 font-semibold text-sm sm:text-base">✅ {{ session('success') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Erreurs -->
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
@foreach ($errors->all() as $error)
|
||||||
|
<p class="text-red-700 text-sm">{{ $error }}</p>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-5 sm:p-8">
|
||||||
|
<form action="{{ route('suggestions.store') }}" method="POST" class="space-y-5 sm:space-y-6">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<!-- Type de demande -->
|
||||||
|
<div>
|
||||||
|
<label for="type" class="block text-sm font-bold mb-2 text-gray-700">Type de demande</label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
name="type"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-3 text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('type') border-red-500 @enderror">
|
||||||
|
@php($typeChoisi = old('type'))
|
||||||
|
<option value="Modification" @selected($typeChoisi === 'Modification')>✏️ Modification d'un contenu existant</option>
|
||||||
|
<option value="Ajout" @selected($typeChoisi === 'Ajout')>➕ Ajout d'un contenu / fonctionnalité</option>
|
||||||
|
<option value="Correction" @selected($typeChoisi === 'Correction')>🐛 Correction d'une erreur / d'un bug</option>
|
||||||
|
</select>
|
||||||
|
@error('type')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Titre -->
|
||||||
|
<div>
|
||||||
|
<label for="titre" class="block text-sm font-bold mb-2 text-gray-700">Titre</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="titre"
|
||||||
|
name="titre"
|
||||||
|
required
|
||||||
|
minlength="5"
|
||||||
|
maxlength="120"
|
||||||
|
value="{{ old('titre') }}"
|
||||||
|
placeholder="Résumez votre suggestion en une phrase"
|
||||||
|
class="w-full px-4 py-3 text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('titre') border-red-500 @enderror">
|
||||||
|
@error('titre')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-baseline justify-between mb-2">
|
||||||
|
<label for="description" class="block text-sm font-bold text-gray-700">Description détaillée</label>
|
||||||
|
<span id="desc-count" class="text-xs text-gray-400 tabular-nums">0 / 5000</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
required
|
||||||
|
minlength="10"
|
||||||
|
maxlength="5000"
|
||||||
|
rows="6"
|
||||||
|
placeholder="Décrivez précisément votre proposition : quoi, où, pourquoi…"
|
||||||
|
class="w-full px-4 py-3 text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('description') border-red-500 @enderror">{{ old('description') }}</textarea>
|
||||||
|
@error('description')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boutons : empilés pleine largeur sur mobile, en ligne sur desktop -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 pt-2">
|
||||||
|
<button type="submit" class="w-full sm:w-auto px-8 py-3 gradient-niagara text-white font-bold rounded-lg hover:shadow-lg transition-smooth duration-200 text-center">
|
||||||
|
📨 Envoyer ma suggestion
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('home') }}" class="w-full sm:w-auto px-8 py-3 bg-gray-200 text-gray-900 font-bold rounded-lg hover:bg-gray-300 transition-smooth duration-200 text-center">
|
||||||
|
Annuler
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500 mt-4">
|
||||||
|
ℹ️ Votre suggestion sera examinée par l'équipe avant toute prise en compte. Merci de rester courtois et constructif.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const ta = document.getElementById('description');
|
||||||
|
const compteur = document.getElementById('desc-count');
|
||||||
|
if (!ta || !compteur) return;
|
||||||
|
const maj = () => { compteur.textContent = ta.value.length + ' / 5000'; };
|
||||||
|
ta.addEventListener('input', maj);
|
||||||
|
maj();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
@extends('app')
|
||||||
|
|
||||||
|
@section('title', 'Mes suggestions - (RE)Sources Relationnelles')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section class="container mx-auto px-4 py-12">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="flex items-start justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-2">💬 Mes suggestions</h1>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Suivez ici la prise en compte de vos suggestions par l'équipe : état du ticket,
|
||||||
|
réponses reçues, et poursuivez la discussion.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('suggestions.create') }}" class="shrink-0 inline-flex items-center px-4 py-2.5 text-sm font-bold text-niagara border-2 border-niagara rounded-lg hover:bg-niagara hover:text-white transition-smooth duration-200">
|
||||||
|
💡 Nouvelle suggestion
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message de succès -->
|
||||||
|
@if (session('success'))
|
||||||
|
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<p class="text-green-700 font-semibold">✅ {{ session('success') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@forelse ($suggestions as $suggestion)
|
||||||
|
@php($etat = $etats[$suggestion->ID_Suggestion] ?? null)
|
||||||
|
@php($nouvelles = $etat ? max(0, $etat['comments'] - $suggestion->CommentairesVus) : 0)
|
||||||
|
<a href="{{ route('suggestions.show', $suggestion) }}"
|
||||||
|
class="block bg-white rounded-xl border border-gray-200 p-6 mb-4 hover:border-niagara hover:shadow-md transition-smooth duration-200">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-700">{{ $suggestion->Type }}</span>
|
||||||
|
@if ($etat === null)
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-500">⚪ État indisponible</span>
|
||||||
|
@elseif ($etat['state'] === 'closed')
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-purple-100 text-purple-700">✔️ Traitée</span>
|
||||||
|
@else
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-green-100 text-green-700">🟢 En cours</span>
|
||||||
|
@endif
|
||||||
|
@if ($nouvelles > 0)
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-candlelight text-gray-900">
|
||||||
|
🔔 {{ $nouvelles }} {{ $nouvelles > 1 ? 'nouvelles réponses' : 'nouvelle réponse' }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<p class="font-bold text-gray-900 truncate">{{ $suggestion->Titre }}</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Envoyée le {{ $suggestion->DateCreation->format('d/m/Y à H\hi') }}
|
||||||
|
@if ($suggestion->GiteaIssueNumber)
|
||||||
|
· ticket n°{{ $suggestion->GiteaIssueNumber }}
|
||||||
|
@endif
|
||||||
|
@if ($etat)
|
||||||
|
· {{ $etat['comments'] }} {{ $etat['comments'] > 1 ? 'réponses' : 'réponse' }}
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-400 text-xl shrink-0">→</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@empty
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-10 text-center">
|
||||||
|
<p class="text-gray-600 mb-4">Vous n'avez pas encore envoyé de suggestion.</p>
|
||||||
|
<a href="{{ route('suggestions.create') }}" class="inline-flex items-center px-6 py-3 font-bold bg-candlelight text-gray-900 rounded-lg shadow-md hover:shadow-lg transition-smooth duration-200">
|
||||||
|
💡 Proposer une amélioration
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
@extends('app')
|
||||||
|
|
||||||
|
@section('title', 'Suivi de ma suggestion - (RE)Sources Relationnelles')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section class="container mx-auto px-4 py-12">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<a href="{{ route('suggestions.index') }}" class="inline-block text-sm text-gray-500 hover:text-niagara mb-4">← Retour à mes suggestions</a>
|
||||||
|
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-700">{{ $suggestion->Type }}</span>
|
||||||
|
@if ($issue === null)
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-500">⚪ État indisponible</span>
|
||||||
|
@elseif ($issue['state'] === 'closed')
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-purple-100 text-purple-700">✔️ Traitée</span>
|
||||||
|
@else
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-green-100 text-green-700">🟢 En cours de traitement</span>
|
||||||
|
@endif
|
||||||
|
@if ($suggestion->GiteaIssueNumber)
|
||||||
|
<span class="text-xs text-gray-400">Ticket n°{{ $suggestion->GiteaIssueNumber }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">{{ $suggestion->Titre }}</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Envoyée le {{ $suggestion->DateCreation->format('d/m/Y à H\hi') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
@if (session('success'))
|
||||||
|
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<p class="text-green-700 font-semibold">✅ {{ session('success') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
@foreach ($errors->all() as $error)
|
||||||
|
<p class="text-red-700 text-sm">{{ $error }}</p>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Ma demande -->
|
||||||
|
<div class="bg-white rounded-xl border-2 border-niagara p-6 mb-6">
|
||||||
|
<p class="text-xs font-bold text-niagara uppercase mb-2">Votre demande</p>
|
||||||
|
<p class="text-gray-800 whitespace-pre-line">{{ $suggestion->Description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fil de discussion -->
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
Réponses {{ count($commentaires) > 0 ? '('.count($commentaires).')' : '' }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
@forelse ($commentaires as $commentaire)
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-5 mb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<img
|
||||||
|
src="https://ui-avatars.com/api/?name={{ urlencode($commentaire['auteur']) }}&background=06989e&color=fff&size=32"
|
||||||
|
alt=""
|
||||||
|
class="w-8 h-8 rounded-full"
|
||||||
|
>
|
||||||
|
<span class="font-bold text-gray-900 text-sm">{{ $commentaire['auteur'] }}</span>
|
||||||
|
@if ($commentaire['date'])
|
||||||
|
<span class="text-xs text-gray-400">· {{ \Illuminate\Support\Carbon::parse($commentaire['date'])->format('d/m/Y à H\hi') }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-800 text-sm whitespace-pre-line">{{ $commentaire['corps'] }}</p>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="bg-gray-50 rounded-xl border border-dashed border-gray-300 p-6 mb-3 text-center">
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Pas encore de réponse de l'équipe — vous serez notifié ici dès qu'une réponse sera publiée.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
<!-- Répondre -->
|
||||||
|
@if ($suggestion->GiteaIssueNumber && ($issue['state'] ?? 'open') !== 'closed')
|
||||||
|
<form action="{{ route('suggestions.repondre', $suggestion) }}" method="POST" class="bg-white rounded-xl border border-gray-200 p-6 mt-6">
|
||||||
|
@csrf
|
||||||
|
<label for="message" class="block text-sm font-bold mb-2 text-gray-700">Répondre sur le ticket</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
minlength="2"
|
||||||
|
maxlength="2000"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Apportez une précision, répondez à l'équipe…"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('message') border-red-500 @enderror">{{ old('message') }}</textarea>
|
||||||
|
@error('message')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
<button type="submit" class="mt-3 px-6 py-2.5 font-bold gradient-niagara text-white rounded-lg hover:shadow-lg transition-smooth duration-200">
|
||||||
|
📨 Envoyer ma réponse
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@elseif (($issue['state'] ?? null) === 'closed')
|
||||||
|
<p class="text-sm text-gray-500 mt-6 text-center">
|
||||||
|
Cette suggestion a été traitée : le ticket est clôturé. Merci pour votre contribution !
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
@extends('app')
|
||||||
|
|
||||||
|
@section('title', "Télécharger l'application - (RE)Sources Relationnelles")
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section class="container mx-auto px-4 py-12">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="text-center mb-10">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-3">📱 Télécharger l'application</h1>
|
||||||
|
<p class="text-gray-600 max-w-xl mx-auto">
|
||||||
|
Emportez (RE)Sources Relationnelles partout avec vous. Choisissez votre plateforme
|
||||||
|
et installez l'application mobile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
|
||||||
|
{{-- Carte Android --}}
|
||||||
|
<div class="bg-white rounded-2xl border border-gray-200 p-8 flex flex-col items-center text-center shadow-sm">
|
||||||
|
<div class="w-16 h-16 rounded-2xl bg-green-50 flex items-center justify-center mb-4">
|
||||||
|
<span class="text-4xl">🤖</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-1">Android</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">Version {{ $android['version'] }}</p>
|
||||||
|
|
||||||
|
@if ($android['disponible'])
|
||||||
|
<a href="{{ $android['url'] }}"
|
||||||
|
@if (str_starts_with($android['url'], url('/'))) download @else target="_blank" rel="noopener" @endif
|
||||||
|
class="w-full px-6 py-3 gradient-niagara text-white font-bold rounded-lg hover:shadow-lg transition-smooth duration-200">
|
||||||
|
⬇️ Télécharger (.apk)
|
||||||
|
</a>
|
||||||
|
<p class="text-xs text-gray-400 mt-3">
|
||||||
|
Sur Android, autorisez l'installation depuis « sources inconnues » si nécessaire.
|
||||||
|
</p>
|
||||||
|
@else
|
||||||
|
<button type="button" disabled
|
||||||
|
class="w-full px-6 py-3 bg-gray-200 text-gray-500 font-bold rounded-lg cursor-not-allowed">
|
||||||
|
Bientôt disponible
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Carte iOS --}}
|
||||||
|
<div class="bg-white rounded-2xl border border-gray-200 p-8 flex flex-col items-center text-center shadow-sm">
|
||||||
|
<div class="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mb-4">
|
||||||
|
<span class="text-4xl"></span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-1">Apple (iOS)</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">Version {{ $ios['version'] }}</p>
|
||||||
|
|
||||||
|
@if ($ios['disponible'])
|
||||||
|
<a href="{{ $ios['url'] }}"
|
||||||
|
@if (str_starts_with($ios['url'], url('/'))) download @else target="_blank" rel="noopener" @endif
|
||||||
|
class="w-full px-6 py-3 bg-gray-900 text-white font-bold rounded-lg hover:bg-black transition-smooth duration-200">
|
||||||
|
⬇️ Télécharger
|
||||||
|
</a>
|
||||||
|
<p class="text-xs text-gray-400 mt-3">
|
||||||
|
L'installation d'un fichier .ipa nécessite TestFlight ou un profil adapté.
|
||||||
|
</p>
|
||||||
|
@else
|
||||||
|
<button type="button" disabled
|
||||||
|
class="w-full px-6 py-3 bg-gray-200 text-gray-500 font-bold rounded-lg cursor-not-allowed">
|
||||||
|
Bientôt disponible
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 text-center">
|
||||||
|
<a href="{{ route('home') }}" class="text-sm font-semibold text-niagara hover:underline">
|
||||||
|
← Retour à l'accueil
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\DownloadController;
|
||||||
use App\Http\Controllers\LoginController;
|
use App\Http\Controllers\LoginController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use App\Http\Controllers\SuggestionController;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -34,6 +36,9 @@ Route::get('/resources', function () {
|
|||||||
return view('resources');
|
return view('resources');
|
||||||
})->name('resources.index');
|
})->name('resources.index');
|
||||||
|
|
||||||
|
// Téléchargement des applications mobiles (public)
|
||||||
|
Route::get('/telecharger', [DownloadController::class, 'index'])->name('telecharger');
|
||||||
|
|
||||||
// Routes d'authentification
|
// Routes d'authentification
|
||||||
Route::get('/login', [LoginController::class, 'login'])->name('login');
|
Route::get('/login', [LoginController::class, 'login'])->name('login');
|
||||||
Route::post('/login', [LoginController::class, 'store'])->name('login.store');
|
Route::post('/login', [LoginController::class, 'store'])->name('login.store');
|
||||||
@@ -48,4 +53,13 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/profile/settings', [ProfileController::class, 'settings'])->name('profile.settings');
|
Route::get('/profile/settings', [ProfileController::class, 'settings'])->name('profile.settings');
|
||||||
Route::put('/profile/update', [ProfileController::class, 'update'])->name('profile.update');
|
Route::put('/profile/update', [ProfileController::class, 'update'])->name('profile.update');
|
||||||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
||||||
|
|
||||||
|
// Suggestions des utilisateurs (créent un ticket dans Gitea) + suivi
|
||||||
|
Route::get('/suggestions', [SuggestionController::class, 'index'])->name('suggestions.index');
|
||||||
|
Route::get('/suggestions/nouveau', [SuggestionController::class, 'create'])->name('suggestions.create');
|
||||||
|
Route::post('/suggestions', [SuggestionController::class, 'store'])->name('suggestions.store');
|
||||||
|
Route::get('/suggestions/{suggestion}', [SuggestionController::class, 'show'])
|
||||||
|
->whereNumber('suggestion')->name('suggestions.show');
|
||||||
|
Route::post('/suggestions/{suggestion}/repondre', [SuggestionController::class, 'repondre'])
|
||||||
|
->whereNumber('suggestion')->name('suggestions.repondre');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Configuration de l'analyse SonarQube.
|
||||||
|
# L'URL du serveur (sonar.host.url) et le jeton (sonar.token) NE sont PAS ici :
|
||||||
|
# ils sont fournis à la CI via les secrets SONAR_HOST_URL / SONAR_TOKEN.
|
||||||
|
|
||||||
|
sonar.projectKey=ressources-relationnelles
|
||||||
|
sonar.projectName=Ressources Relationnelles
|
||||||
|
|
||||||
|
# Code source analysé et code de test.
|
||||||
|
sonar.sources=app
|
||||||
|
sonar.tests=tests
|
||||||
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
|
# Rapports produits par PHPUnit (générés dans la CI, voir ci.yml).
|
||||||
|
sonar.php.coverage.reportPaths=coverage/clover.xml
|
||||||
|
sonar.php.tests.reportPath=coverage/junit.xml
|
||||||
|
|
||||||
|
# On n'analyse pas le code tiers, généré ou compilé.
|
||||||
|
sonar.exclusions=vendor/**,storage/**,bootstrap/cache/**,public/build/**,node_modules/**
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\Suggestion;
|
||||||
|
use App\Models\Utilisateur;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SuggestionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function utilisateur(string $pseudo = 'JeanTest', string $email = 'jean.test@example.com'): Utilisateur
|
||||||
|
{
|
||||||
|
$role = Role::firstOrCreate(['Nom' => 'Utilisateur']);
|
||||||
|
|
||||||
|
return Utilisateur::create([
|
||||||
|
'Pseudo' => $pseudo,
|
||||||
|
'Email' => $email,
|
||||||
|
'MotDePasse' => Hash::make('motdepasse123'),
|
||||||
|
'ID_Role' => $role->ID_Role,
|
||||||
|
'Statut' => 'Actif',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function suggestion(Utilisateur $utilisateur, ?int $numero = 42): Suggestion
|
||||||
|
{
|
||||||
|
return Suggestion::create([
|
||||||
|
'ID_Utilisateur' => $utilisateur->ID_Utilisateur,
|
||||||
|
'Type' => 'Correction',
|
||||||
|
'Titre' => 'Faute dans une ressource',
|
||||||
|
'Description' => 'Il y a une faute à corriger sur la page des ressources.',
|
||||||
|
'GiteaIssueNumber' => $numero,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configurerGitea(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'services.gitea.url' => 'https://gitea.test',
|
||||||
|
'services.gitea.token' => 'fake-token',
|
||||||
|
'services.gitea.repo' => 'Groupe1/Ressources-relationnel',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_le_formulaire_de_suggestion_exige_une_authentification(): void
|
||||||
|
{
|
||||||
|
$this->get('/suggestions/nouveau')->assertRedirect('/login');
|
||||||
|
$this->get('/suggestions')->assertRedirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_une_suggestion_valide_cree_un_ticket_gitea_et_est_conservee(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake([
|
||||||
|
'*/api/v1/repos/*/issues' => Http::response(['number' => 42], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$utilisateur = $this->utilisateur();
|
||||||
|
|
||||||
|
$response = $this->actingAs($utilisateur)->post('/suggestions', [
|
||||||
|
'type' => 'Correction',
|
||||||
|
'titre' => 'Faute dans une ressource',
|
||||||
|
'description' => 'Il y a une faute à corriger sur la page des ressources.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// L'utilisateur arrive sur la page de suivi de ses suggestions
|
||||||
|
$response->assertRedirect(route('suggestions.index'));
|
||||||
|
$response->assertSessionHas('success');
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return str_contains($request->url(), '/api/v1/repos/Groupe1/Ressources-relationnel/issues')
|
||||||
|
&& $request['title'] === '[Correction] Faute dans une ressource'
|
||||||
|
&& str_contains($request['body'], 'JeanTest');
|
||||||
|
});
|
||||||
|
|
||||||
|
// La suggestion est liée au ticket pour permettre le suivi
|
||||||
|
$this->assertDatabaseHas('suggestions', [
|
||||||
|
'ID_Utilisateur' => $utilisateur->ID_Utilisateur,
|
||||||
|
'Titre' => 'Faute dans une ressource',
|
||||||
|
'GiteaIssueNumber' => 42,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_la_liste_affiche_l_etat_du_ticket_et_les_nouvelles_reponses(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake([
|
||||||
|
'gitea.test/api/v1/repos/*/issues/42' => Http::response([
|
||||||
|
'number' => 42, 'state' => 'open', 'comments' => 2, 'title' => '[Correction] Faute',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$utilisateur = $this->utilisateur();
|
||||||
|
$this->suggestion($utilisateur);
|
||||||
|
|
||||||
|
$response = $this->actingAs($utilisateur)->get('/suggestions');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertSee('Faute dans une ressource')
|
||||||
|
->assertSee('En cours')
|
||||||
|
->assertSee('2 nouvelles réponses');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_la_liste_ne_montre_pas_les_suggestions_des_autres(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$autre = $this->utilisateur('AutreUser', 'autre@example.com');
|
||||||
|
$this->suggestion($autre);
|
||||||
|
|
||||||
|
$moi = $this->utilisateur('Moi', 'moi@example.com');
|
||||||
|
|
||||||
|
$this->actingAs($moi)->get('/suggestions')
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('Faute dans une ressource');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_le_detail_affiche_les_reponses_du_ticket_et_les_marque_comme_vues(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake([
|
||||||
|
'gitea.test/api/v1/repos/*/issues/42/comments' => Http::response([
|
||||||
|
[
|
||||||
|
'user' => ['username' => 'sam-depardieu'],
|
||||||
|
'body' => 'Merci, correction prévue dans la v1.1 !',
|
||||||
|
'created_at' => '2026-07-08T10:00:00Z',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'gitea.test/api/v1/repos/*/issues/42' => Http::response([
|
||||||
|
'number' => 42, 'state' => 'open', 'comments' => 1, 'title' => '[Correction] Faute',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$utilisateur = $this->utilisateur();
|
||||||
|
$suggestion = $this->suggestion($utilisateur);
|
||||||
|
|
||||||
|
$response = $this->actingAs($utilisateur)->get('/suggestions/'.$suggestion->ID_Suggestion);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertSee('sam-depardieu')
|
||||||
|
->assertSee('Merci, correction prévue dans la v1.1 !');
|
||||||
|
|
||||||
|
// Les réponses consultées ne comptent plus comme « nouvelles »
|
||||||
|
$this->assertSame(1, $suggestion->fresh()->CommentairesVus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_le_detail_d_une_suggestion_d_un_autre_est_interdit(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$autre = $this->utilisateur('AutreUser', 'autre@example.com');
|
||||||
|
$suggestion = $this->suggestion($autre);
|
||||||
|
|
||||||
|
$moi = $this->utilisateur('Moi', 'moi@example.com');
|
||||||
|
|
||||||
|
$this->actingAs($moi)->get('/suggestions/'.$suggestion->ID_Suggestion)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_l_utilisateur_peut_repondre_sur_le_ticket(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake([
|
||||||
|
'gitea.test/api/v1/repos/*/issues/42/comments' => Http::response(['id' => 7], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$utilisateur = $this->utilisateur();
|
||||||
|
$suggestion = $this->suggestion($utilisateur);
|
||||||
|
|
||||||
|
$response = $this->actingAs($utilisateur)
|
||||||
|
->post('/suggestions/'.$suggestion->ID_Suggestion.'/repondre', [
|
||||||
|
'message' => 'Précision : la faute est dans le deuxième paragraphe.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('suggestions.show', $suggestion));
|
||||||
|
$response->assertSessionHas('success');
|
||||||
|
|
||||||
|
// Le commentaire est publié sur le ticket Gitea, signé du pseudo
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return $request->method() === 'POST'
|
||||||
|
&& str_contains($request->url(), '/issues/42/comments')
|
||||||
|
&& str_contains($request['body'], 'JeanTest')
|
||||||
|
&& str_contains($request['body'], 'deuxième paragraphe');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_une_suggestion_invalide_est_rejetee(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->utilisateur())->post('/suggestions', [
|
||||||
|
'type' => 'Inconnu',
|
||||||
|
'titre' => 'x',
|
||||||
|
'description' => 'court',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors(['type', 'titre', 'description']);
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_echec_gitea_affiche_une_erreur(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake([
|
||||||
|
'*/api/v1/repos/*/issues' => Http::response(['message' => 'nope'], 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->utilisateur())->post('/suggestions', [
|
||||||
|
'type' => 'Ajout',
|
||||||
|
'titre' => 'Nouvelle fonctionnalité utile',
|
||||||
|
'description' => 'Ce serait bien d\'ajouter un filtre par thème.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors('gitea');
|
||||||
|
|
||||||
|
// Rien n'est conservé si le ticket n'a pas pu être créé
|
||||||
|
$this->assertDatabaseCount('suggestions', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TelechargementTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_la_page_de_telechargement_est_accessible_publiquement(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/telecharger');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertSee('Android');
|
||||||
|
$response->assertSee('iOS');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_lien_de_store_configure_est_utilise(): void
|
||||||
|
{
|
||||||
|
config(['mobile.android.url' => 'https://play.google.com/store/apps/details?id=test']);
|
||||||
|
|
||||||
|
$response = $this->get('/telecharger');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertSee('play.google.com/store/apps/details?id=test');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sans_binaire_ni_lien_le_bouton_est_indisponible(): void
|
||||||
|
{
|
||||||
|
config(['mobile.ios.url' => null]);
|
||||||
|
|
||||||
|
$response = $this->get('/telecharger');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertSee('Bientôt disponible');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user