add jobs and workflow support with percentage progress
This commit is contained in:
parent
9e90feb662
commit
6c430afffe
@ -20,11 +20,15 @@ #### Architektúra és Struktúra
|
||||
* **Requests**: Form Validation-re használd a Laravel FormRequest osztályait a kontrollerek metódusain belül.
|
||||
* **DTO (Data Transfer Objects)**: Használj DTO-kat vagy Typed Array-eket nagyobb adatszerkezetek továbbításakor (pl. `Extra` library).
|
||||
|
||||
##### Modellek és Auditálás
|
||||
##### Modellek, Auditálás és Workflow
|
||||
- Minden fő modellnek a `App\Models\BaseAuditable` osztályból kell származnia.
|
||||
- Ez biztosítja a `created_by`, `updated_by` és `deleted_by` mezők automatikus töltését a `ModelUserIdAppenderTrait` segítségével.
|
||||
- Használjunk `SoftDeletes`-t, ahol az adatok megőrzése fontos.
|
||||
- **Enums**: Amennyiben egy modell mezőjéhez meghatározott értékkészlet tartozik, kötelező PHP Enum használata és annak beállítása a `$casts` tömbben.
|
||||
- **Workflow Kezelés**: Összetett, több lépésből álló folyamatok (pl. árlista feldolgozás) kezeléséhez PHP Enum-okat használunk:
|
||||
- Az Enum definiálja a lépéseket, a címkéket (`label()`), a végrehajtó Job-okat (`jobClass()`) és a színeket (`color()`).
|
||||
- A modell `workflow_steps` JSON mezője tárolja a folyamat állapotát, amit a modell `creating` eseményében inicializálunk az Enum `defaultSteps()` metódusával.
|
||||
- A Service réteg felelős a státuszok léptetéséért a folyamat során.
|
||||
|
||||
##### Hibrid Frontend Architektúra (Mix & Vite) és a "Clean Cut" elv
|
||||
A projekt fokozatos modernizáció alatt áll, ezért két párhuzamos build rendszert használunk a **"Clean Cut"** (tiszta vágás) elv mentén:
|
||||
|
||||
49
app/Console/Commands/PricelistProcessCommand.php
Normal file
49
app/Console/Commands/PricelistProcessCommand.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PricelistFile;
|
||||
use App\Services\PricelistFileProcessService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PricelistProcessCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'pricelist:process {id}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Elindítja a megadott azonosítójú árlista fájl kezdeti feldolgozási láncát (Pre-process -> Validation)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(PricelistFileProcessService $service): int
|
||||
{
|
||||
$id = $this->argument('id');
|
||||
$pricelistFile = PricelistFile::find($id);
|
||||
|
||||
if (!$pricelistFile) {
|
||||
$this->error("A(z) {$id} ID-vel rendelkező árlista fájl nem található.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Folyamat elindítása: {$pricelistFile->filename} (ID: {$id})");
|
||||
|
||||
try {
|
||||
$service->dispatchInitialChain($pricelistFile);
|
||||
$this->info("Az árlista feldolgozási lánc (Pre-process -> Validation) sikeresen ütemezve.");
|
||||
return 0;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Hiba történt a folyamat indításakor: " . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
89
app/Console/Commands/QueueListCommand.php
Normal file
89
app/Console/Commands/QueueListCommand.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class QueueListCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'queue:list {--queue= : Szűrés a queue nevére} {--failed : A hibás jobok kilistázása}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Kilistázza a várólistán lévő (vagy hibás) jobokat táblázatos formában';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$queueFilter = $this->option('queue');
|
||||
$showFailed = $this->option('failed');
|
||||
|
||||
$tableName = $showFailed ? 'failed_jobs' : 'jobs';
|
||||
|
||||
if (!Schema::hasTable($tableName)) {
|
||||
$this->error("A(z) {$tableName} tábla nem létezik.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$query = DB::table($tableName);
|
||||
|
||||
if ($queueFilter) {
|
||||
$query->where('queue', $queueFilter);
|
||||
}
|
||||
|
||||
$jobs = $query->orderBy($showFailed ? 'failed_at' : 'created_at', 'desc')->get();
|
||||
|
||||
if ($jobs->isEmpty()) {
|
||||
$this->info("Nincsenek " . ($showFailed ? "hibás " : "") . "jobok a várólistán.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$tableData = $jobs->map(function ($job) use ($showFailed) {
|
||||
$payload = json_decode($job->payload, true);
|
||||
$displayName = $payload['displayName'] ?? 'Ismeretlen';
|
||||
|
||||
if ($showFailed) {
|
||||
return [
|
||||
'ID' => $job->id,
|
||||
'Queue' => $job->queue,
|
||||
'Job Class' => $displayName,
|
||||
'Failed At' => $job->failed_at,
|
||||
'Exception' => substr($job->exception, 0, 70) . '...',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ID' => $job->id,
|
||||
'Queue' => $job->queue,
|
||||
'Job Class' => $displayName,
|
||||
'Attempts' => $job->attempts,
|
||||
'Reserved At' => $job->reserved_at ? Carbon::createFromTimestamp($job->reserved_at)->format('Y.m.d H:i:s') : 'Nincs lefoglalva',
|
||||
'Available At' => Carbon::createFromTimestamp($job->available_at)->format('Y.m.d H:i:s'),
|
||||
'Created At' => Carbon::createFromTimestamp($job->created_at)->format('Y.m.d H:i:s'),
|
||||
];
|
||||
});
|
||||
|
||||
if ($showFailed) {
|
||||
$headers = ['ID', 'Queue', 'Job Class', 'Failed At', 'Exception'];
|
||||
} else {
|
||||
$headers = ['ID', 'Queue', 'Job Class', 'Próbálkozások', 'Lefoglalva', 'Elérhető', 'Létrehozva'];
|
||||
}
|
||||
|
||||
$this->table($headers, $tableData);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
76
app/Enums/PricelistWorkflowStep.php
Normal file
76
app/Enums/PricelistWorkflowStep.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Jobs\PricelistPreProcessJob;
|
||||
use App\Jobs\PricelistValidationJob;
|
||||
use App\Jobs\PricelistExecutionJob;
|
||||
|
||||
enum PricelistWorkflowStep: string
|
||||
{
|
||||
case Preprocessing = 'preprocess';
|
||||
case Validation = 'validation';
|
||||
case Approval = 'approval';
|
||||
case Execution = 'execution';
|
||||
|
||||
/**
|
||||
* Magyar megnevezés (UI megjelenítéshez)
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::Preprocessing => 'Előfeldolgozás',
|
||||
self::Validation => 'Validálás',
|
||||
self::Approval => 'Jóváhagyás',
|
||||
self::Execution => 'Végrehajtás',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A lépéshez tartozó Job osztály neve
|
||||
*/
|
||||
public function jobClass(): ?string
|
||||
{
|
||||
return match($this) {
|
||||
self::Preprocessing => PricelistPreProcessJob::class,
|
||||
self::Validation => PricelistValidationJob::class,
|
||||
self::Execution => PricelistExecutionJob::class,
|
||||
self::Approval => null, // Manuális lépésnél nincs automatikus job
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A lépéshez tartozó Job példány visszaadása
|
||||
*/
|
||||
public function getJob(\App\Models\PricelistFile $file): ?object
|
||||
{
|
||||
$jobClass = $this->jobClass();
|
||||
return $jobClass ? new $jobClass($file) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Státusz színek (UI megjelenítéshez)
|
||||
*/
|
||||
public function color(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'pending' => 'gray',
|
||||
'inprogress' => 'primary',
|
||||
'completed' => 'success',
|
||||
'failed' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Segédfüggvény az alapértelmezett workflow struktúra legenerálásához
|
||||
*/
|
||||
public static function defaultSteps(): array
|
||||
{
|
||||
return array_map(fn($step) => [
|
||||
'name' => $step->value,
|
||||
'label' => $step->label(),
|
||||
'status' => 'pending',
|
||||
], self::cases());
|
||||
}
|
||||
}
|
||||
@ -8,4 +8,9 @@
|
||||
class CreatePricelistFile extends CreateRecord
|
||||
{
|
||||
protected static string $resource = PricelistFileResource::class;
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
app(\App\Services\PricelistFileProcessService::class)->dispatchInitialChain($this->record);
|
||||
}
|
||||
}
|
||||
|
||||
30
app/Jobs/PricelistExecutionJob.php
Normal file
30
app/Jobs/PricelistExecutionJob.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\PricelistFile;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class PricelistExecutionJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public PricelistFile $pricelistFile
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(\App\Services\PricelistFileProcessService $service): void
|
||||
{
|
||||
$service->execute($this->pricelistFile);
|
||||
}
|
||||
}
|
||||
@ -23,8 +23,8 @@ public function __construct(
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
public function handle(\App\Services\PricelistFileProcessService $service): void
|
||||
{
|
||||
// Ide kerül majd az üzleti logika a PricelistFileProcessService segítségével
|
||||
$service->preProcess($this->pricelistFile);
|
||||
}
|
||||
}
|
||||
|
||||
30
app/Jobs/PricelistValidationJob.php
Normal file
30
app/Jobs/PricelistValidationJob.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\PricelistFile;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class PricelistValidationJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public PricelistFile $pricelistFile
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(\App\Services\PricelistFileProcessService $service): void
|
||||
{
|
||||
$service->validate($this->pricelistFile);
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,15 @@ class PricelistFile extends BaseAuditable
|
||||
'workflow_steps' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($pricelistFile) {
|
||||
if (empty($pricelistFile->workflow_steps)) {
|
||||
$pricelistFile->workflow_steps = \App\Enums\PricelistWorkflowStep::defaultSteps();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PricelistWorkflowStep;
|
||||
use App\Models\PricelistFile;
|
||||
use App\Enums\PricelistFileStatusEnum;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@ -14,40 +16,138 @@ public function __construct(
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Előfeldolgozás (Pre-processing) - Adatok ellenőrzése és validálása
|
||||
* Elindítja az árlista feldolgozás kezdeti láncát (Pre-processing -> Validation)
|
||||
*/
|
||||
public function dispatchInitialChain(PricelistFile $pricelistFile): void
|
||||
{
|
||||
Bus::chain([
|
||||
PricelistWorkflowStep::Preprocessing->getJob($pricelistFile),
|
||||
PricelistWorkflowStep::Validation->getJob($pricelistFile),
|
||||
])->dispatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Elindítja a végrehajtási jobot (Approval után)
|
||||
*/
|
||||
public function dispatchExecutionJob(PricelistFile $pricelistFile): void
|
||||
{
|
||||
$job = PricelistWorkflowStep::Execution->getJob($pricelistFile);
|
||||
if ($job) {
|
||||
dispatch($job);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frissíti egy workflow lépés állapotát és az aktuális folyamat jelzőket
|
||||
*/
|
||||
public function updateStepStatus(
|
||||
PricelistFile $pricelistFile,
|
||||
PricelistWorkflowStep $stepEnum,
|
||||
string $status,
|
||||
?string $message = null,
|
||||
int $percentage = 0
|
||||
): void {
|
||||
$effectiveMessage = $message ?? $stepEnum->label();
|
||||
|
||||
// Frissítjük a modellt az adatbázisból, hogy a legfrissebb workflow_steps-szel dolgozzunk
|
||||
// Ez különösen fontos a láncolt joboknál (Bus::chain), ahol az objektum "stale" maradhat a sorban következő job számára
|
||||
if ($pricelistFile->exists) {
|
||||
$pricelistFile->refresh();
|
||||
}
|
||||
|
||||
$steps = $pricelistFile->workflow_steps ?? [];
|
||||
$found = false;
|
||||
|
||||
foreach ($steps as &$step) {
|
||||
if ($step['name'] === $stepEnum->value) {
|
||||
$step['status'] = $status;
|
||||
$step['message'] = $effectiveMessage;
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($step);
|
||||
|
||||
// Ha valamiért nem találtuk meg a lépést (pl. régi rekord), adjuk hozzá a listához
|
||||
if (!$found) {
|
||||
$steps[] = [
|
||||
'name' => $stepEnum->value,
|
||||
'label' => $stepEnum->label(),
|
||||
'status' => $status,
|
||||
'message' => $effectiveMessage,
|
||||
];
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'workflow_steps' => $steps,
|
||||
'processing_current_step' => ($message && $message !== $stepEnum->label())
|
||||
? $stepEnum->label() . ': ' . $message
|
||||
: $effectiveMessage,
|
||||
'processing_current_step_percentage' => $percentage,
|
||||
];
|
||||
|
||||
if ($status === 'inprogress') {
|
||||
$updateData['status'] = PricelistFileStatusEnum::inprogress;
|
||||
} elseif ($status === 'failed') {
|
||||
$updateData['status'] = PricelistFileStatusEnum::fail;
|
||||
}
|
||||
|
||||
$pricelistFile->update($updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Előfeldolgozás (Pre-processing) - Adatok ellenőrzése és beolvasása
|
||||
*
|
||||
* @param PricelistFile $pricelistFile
|
||||
* @param array $data Az XLS-ből beolvasott adatok
|
||||
* @return bool
|
||||
*/
|
||||
public function preProcess(PricelistFile $pricelistFile, array $data): bool
|
||||
public function preProcess(PricelistFile $pricelistFile): bool
|
||||
{
|
||||
$pricelistFile->update([
|
||||
'status' => PricelistFileStatusEnum::inprogress,
|
||||
'processing_current_step' => 'Előfeldolgozás: Adatok ellenőrzése...',
|
||||
'processing_current_step_percentage' => 10,
|
||||
]);
|
||||
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'inprogress', 'Adatok ellenőrzése...', 10);
|
||||
|
||||
try {
|
||||
// Itt hívjuk majd meg a PriceListService ellenőrző metódusait
|
||||
// TODO: Itt hívjuk majd meg a PriceListService-t a fájl beolvasásához
|
||||
// $data = $this->priceListService->readFromFile($pricelistFile->path);
|
||||
// $results = $this->priceListService->importPriceListCheck($pricelistFile->supplier_id, $data);
|
||||
|
||||
// TODO: Eredmények mentése (pl. preview_data JSON mezőbe vagy fájlba)
|
||||
sleep(15); // Szimuláció
|
||||
|
||||
$pricelistFile->update([
|
||||
'status' => PricelistFileStatusEnum::todo, // Visszaállítjuk, vagy egy új 'waiting_for_approval' státuszra, ha létezik
|
||||
'processing_current_step' => 'Előfeldolgozás kész, jóváhagyásra vár.',
|
||||
'processing_current_step_percentage' => 100,
|
||||
]);
|
||||
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'completed', 'Adatok ellenőrizve.', 100);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Pricelist pre-process error: ' . $e->getMessage());
|
||||
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Preprocessing, 'failed', $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validálás (Validation) - Üzleti szabályok ellenőrzése
|
||||
*
|
||||
* @param PricelistFile $pricelistFile
|
||||
* @return bool
|
||||
*/
|
||||
public function validate(PricelistFile $pricelistFile): bool
|
||||
{
|
||||
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', 'Üzleti szabályok ellenőrzése...', 60);
|
||||
|
||||
try {
|
||||
// TODO: Validációs logika
|
||||
sleep(1); // Szimuláció
|
||||
|
||||
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'completed', 'Validálás sikeres.', 100);
|
||||
|
||||
$pricelistFile->update([
|
||||
'status' => PricelistFileStatusEnum::fail,
|
||||
'processing_current_step' => 'Hiba az előfeldolgozás során: ' . $e->getMessage(),
|
||||
'status' => PricelistFileStatusEnum::waiting_for_approval,
|
||||
'processing_current_step' => 'Előfeldolgozás és validálás kész, jóváhagyásra vár.',
|
||||
]);
|
||||
return false;
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Pricelist validation error: ' . $e->getMessage());
|
||||
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'failed', $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,36 +155,29 @@ public function preProcess(PricelistFile $pricelistFile, array $data): bool
|
||||
* Végrehajtás (Execution) - Módosítások tényleges alkalmazása tranzakcióban
|
||||
*
|
||||
* @param PricelistFile $pricelistFile
|
||||
* @param array $dataToImport A véglegesítendő adatok
|
||||
* @return bool
|
||||
*/
|
||||
public function execute(PricelistFile $pricelistFile, array $dataToImport): bool
|
||||
public function execute(PricelistFile $pricelistFile): bool
|
||||
{
|
||||
$pricelistFile->update([
|
||||
'status' => PricelistFileStatusEnum::inprogress,
|
||||
'processing_current_step' => 'Végrehajtás: Árlista frissítése...',
|
||||
'processing_current_step_percentage' => 0,
|
||||
]);
|
||||
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', 'Árlista frissítése...', 0);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Tényleges importálás végrehajtása a PriceListService segítségével
|
||||
// TODO: Tényleges importálás végrehajtása
|
||||
// $this->priceListService->importPriceList(...);
|
||||
sleep(1); // Szimuláció
|
||||
|
||||
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'completed', 'Sikeresen befejezve.', 100);
|
||||
|
||||
DB::commit();
|
||||
$pricelistFile->update([
|
||||
'status' => PricelistFileStatusEnum::done,
|
||||
'processing_current_step' => 'Sikeresen befejezve.',
|
||||
'processing_current_step_percentage' => 100,
|
||||
]);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Pricelist execution error: ' . $e->getMessage());
|
||||
$pricelistFile->update([
|
||||
'status' => PricelistFileStatusEnum::fail,
|
||||
'processing_current_step' => 'Hiba a végrehajtás során: ' . $e->getMessage(),
|
||||
]);
|
||||
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'failed', $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"keywords": ["framework", "laravel"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.4",
|
||||
"ahmedhakeem/extra": "dev-master",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"barryvdh/laravel-ide-helper": "^3.5",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user