Pacchetto Windows: build.ps1 (Nuitka standalone + migrazioni .pyc + Inno Setup), installer .iss, icona (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,81 @@
|
|||||||
|
; Installer di TieMeasureFlow (TieMeasureFlow#76).
|
||||||
|
; Cosa fa: copia la cartella standalone di Nuitka in Program Files\Tielogic\TieMeasureFlow,
|
||||||
|
; crea in ProgramData le cartelle scrivibili da tutti gli utenti (licenze, uploads, camera, log)
|
||||||
|
; e i collegamenti in menu Start e sul desktop.
|
||||||
|
; Come si usa: lo lancia build.ps1 con /DVersione /DVersione4 /DOrigine /DUscita; a mano:
|
||||||
|
; ISCC /DVersione=3.0.0 /DVersione4=3.0.0.0 /DOrigine=build\nuitka\avvio.dist /DUscita=dist TieMeasureFlow.iss
|
||||||
|
; Setup silenzioso: TieMeasureFlow-setup-X.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||||
|
; (/CURRENTUSER per installare senza amministratore, in %LOCALAPPDATA%\Programs).
|
||||||
|
; ATTENZIONE: la licenza NON e' nel setup: va copiata in {commonappdata}\Tielogic\licenze\tiemeasureflow.json
|
||||||
|
; (la cartella e' scrivibile dagli utenti). La disinstallazione lascia licenze, database (tmf.db),
|
||||||
|
; uploads e config.env: sono dati del cliente.
|
||||||
|
|
||||||
|
#ifndef Versione
|
||||||
|
#define Versione "0.0.0"
|
||||||
|
#endif
|
||||||
|
#ifndef Versione4
|
||||||
|
#define Versione4 "0.0.0.0"
|
||||||
|
#endif
|
||||||
|
#ifndef Origine
|
||||||
|
#define Origine "..\..\build\nuitka\avvio.dist"
|
||||||
|
#endif
|
||||||
|
#ifndef Uscita
|
||||||
|
#define Uscita "..\..\dist"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; AppId proprio di TieMeasureFlow (diverso da quello di TieVision Studio)
|
||||||
|
AppId={{B7D4E2A1-5C93-4F6E-8A0B-3D1F7E9C2A64}
|
||||||
|
AppName=TieMeasureFlow
|
||||||
|
AppVersion={#Versione}
|
||||||
|
AppVerName=TieMeasureFlow {#Versione}
|
||||||
|
AppPublisher=Tielogic
|
||||||
|
DefaultDirName={autopf}\Tielogic\TieMeasureFlow
|
||||||
|
DefaultGroupName=Tielogic\TieMeasureFlow
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
; /CURRENTUSER solo da riga di comando: serve alle prove senza amministratore
|
||||||
|
PrivilegesRequiredOverridesAllowed=commandline
|
||||||
|
ArchitecturesAllowed=x64compatible
|
||||||
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
|
OutputDir={#Uscita}
|
||||||
|
OutputBaseFilename=TieMeasureFlow-setup-{#Versione}
|
||||||
|
SetupIconFile=TieMeasureFlow.ico
|
||||||
|
UninstallDisplayIcon={app}\TieMeasureFlow.exe
|
||||||
|
VersionInfoVersion={#Versione4}
|
||||||
|
VersionInfoCompany=Tielogic
|
||||||
|
VersionInfoProductName=TieMeasureFlow
|
||||||
|
Compression=lzma2/max
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
; il programma in esecuzione tiene aperti i file: Inno chiede di chiuderlo (in silenzioso lo chiude)
|
||||||
|
CloseApplications=yes
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "italiano"; MessagesFile: "compiler:Languages\Italian.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "desktopicon"; Description: "Crea un collegamento sul desktop"; GroupDescription: "Collegamenti:"
|
||||||
|
|
||||||
|
[Dirs]
|
||||||
|
; scrivibili da tutti gli utenti: l'exe gira con l'utente che lo lancia, non con admin
|
||||||
|
Name: "{commonappdata}\Tielogic\licenze"; Permissions: users-modify
|
||||||
|
Name: "{commonappdata}\Tielogic\TieMeasureFlow"; Permissions: users-modify
|
||||||
|
Name: "{commonappdata}\Tielogic\TieMeasureFlow\uploads"; Permissions: users-modify
|
||||||
|
Name: "{commonappdata}\Tielogic\TieMeasureFlow\camera"; Permissions: users-modify
|
||||||
|
Name: "{commonappdata}\Tielogic\TieMeasureFlow\log"; Permissions: users-modify
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "{#Origine}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{group}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"
|
||||||
|
Name: "{group}\Impronta di questo computer"; Filename: "{app}\TieMeasureFlow.exe"; Parameters: "--impronta"
|
||||||
|
; l'exe non ha finestra: per fermarlo si chiude il processo
|
||||||
|
Name: "{group}\Arresta TieMeasureFlow"; Filename: "{sys}\taskkill.exe"; Parameters: "/IM TieMeasureFlow.exe /F"; Flags: runminimized
|
||||||
|
Name: "{group}\Cartella dati e configurazione"; Filename: "{commonappdata}\Tielogic\TieMeasureFlow"
|
||||||
|
Name: "{group}\Disinstalla TieMeasureFlow"; Filename: "{uninstallexe}"
|
||||||
|
Name: "{autodesktop}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\TieMeasureFlow.exe"; Description: "Avvia TieMeasureFlow"; Flags: nowait postinstall skipifsilent
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<#
|
||||||
|
Pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
|
||||||
|
|
||||||
|
Cosa fa: uv sync in un ambiente Python 3.13 separato (build\venv313), frontend (Tailwind +
|
||||||
|
traduzioni), compila avvio.py con Nuitka in modalita' --standalone (cartella, NON onefile:
|
||||||
|
l'onefile si decomprime in %TEMP% ad ogni avvio), aggiunge le migrazioni Alembic come soli
|
||||||
|
.pyc, le DLL di WeasyPrint (piano B) e il commit del motore di visione, poi Inno Setup
|
||||||
|
produce dist\TieMeasureFlow-setup-<versione>.exe. Sul PC del cliente non finisce nessun
|
||||||
|
.py; i .pyc ci sono solo in src\backend\migrations (Alembic le carica dal disco).
|
||||||
|
|
||||||
|
Come si usa (dalla radice del repo, con il submodule vendor/visionsuite inizializzato):
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 [-Versione 3.0.0]
|
||||||
|
-Versione accetta anche un tag (v3.0.0, v3.0.0-rc1): si usano i numeri iniziali.
|
||||||
|
Senza -Versione si usa la variabile GITHUB_REF_NAME (tag nella CI) o 0.0.0.
|
||||||
|
-SenzaSetup si ferma dopo Nuitka (cartella build\nuitka\avvio.dist).
|
||||||
|
|
||||||
|
Dipende da: uv, node/npx (Tailwind 3.4.19 da npm: serve Internet SOLO a chi costruisce),
|
||||||
|
Visual Studio con carico C++ (MSVC; Nuitka lo trova da solo), Inno Setup 6
|
||||||
|
(`winget install --id JRSoftware.InnoSetup -e`; ISCC.exe nel PATH, in Program Files o in
|
||||||
|
%LOCALAPPDATA%\Programs). Nuitka e' nel gruppo uv "windows" (pyproject.toml).
|
||||||
|
|
||||||
|
⚠️ La prima compilazione e' lunga (decine di minuti: numpy/opencv/scipy/plotly); le successive
|
||||||
|
riusano la cache di Nuitka (%LOCALAPPDATA%\Nuitka) e la cartella build\nuitka.
|
||||||
|
⚠️ I pacchetti di Core condividono il namespace visionsuite (extend_path): Nuitka non lo
|
||||||
|
segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ installati.
|
||||||
|
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
||||||
|
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
||||||
|
funziona ma e' piu' lento nel matching.
|
||||||
|
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
||||||
|
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$Versione = $env:GITHUB_REF_NAME,
|
||||||
|
[switch]$SenzaSetup
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
|
Set-Location $radice
|
||||||
|
|
||||||
|
# Versione numerica a 4 parti per i metadati dell'exe (Nuitka la vuole cosi')
|
||||||
|
if (-not $Versione) { $Versione = '0.0.0' }
|
||||||
|
if ($Versione -notmatch '^v?(\d+)\.(\d+)\.(\d+)') { throw "Versione non valida: '$Versione' (atteso es. v3.0.0)" }
|
||||||
|
$ver3 = "$($Matches[1]).$($Matches[2]).$($Matches[3])"
|
||||||
|
$ver4 = "$ver3.0"
|
||||||
|
Write-Host "== TieMeasureFlow $ver3 =="
|
||||||
|
|
||||||
|
# Controlli: se manca qualcosa il messaggio dice come installarlo
|
||||||
|
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "uv non trovato. Installarlo: https://docs.astral.sh/uv/getting-started/installation/"
|
||||||
|
}
|
||||||
|
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "node/npx non trovato (serve a compilare Tailwind). Installarlo da https://nodejs.org"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path (Join-Path $radice 'vendor\visionsuite\packages\vs-license'))) {
|
||||||
|
throw "Submodule vendor/visionsuite mancante o vecchio: git submodule update --init --recursive"
|
||||||
|
}
|
||||||
|
$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
|
||||||
|
if (-not $iscc) {
|
||||||
|
foreach ($c in @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||||
|
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")) {
|
||||||
|
if (Test-Path $c) { $iscc = $c; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $iscc -and -not $SenzaSetup) { throw "Inno Setup 6 non trovato. Installarlo: winget install --id JRSoftware.InnoSetup -e" }
|
||||||
|
|
||||||
|
# Ambiente separato da quello di sviluppo (3.11): extra server + client + vision-worker + gruppo windows
|
||||||
|
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313'
|
||||||
|
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
|
||||||
|
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
|
||||||
|
$vn = & $py -m nuitka --version # niente Select-Object -First: chiude la pipe e falsa l'exit code
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Nuitka non disponibile (serve anche un compilatore C: Visual Studio con 'Sviluppo di applicazioni desktop con C++')" }
|
||||||
|
|
||||||
|
# Frontend: tailwind.css non e' nel repo (stessa versione esatta di Dockerfile.frontend) e traduzioni .mo
|
||||||
|
$flask = Join-Path $radice 'src\frontend\flask_app'
|
||||||
|
Push-Location $flask
|
||||||
|
try {
|
||||||
|
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
|
||||||
|
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') compile -d translations
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
|
||||||
|
} finally { Pop-Location }
|
||||||
|
|
||||||
|
# Sottopacchetti del namespace visionsuite (core, camera, task, tools, license, ...)
|
||||||
|
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
||||||
|
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
||||||
|
# Metadati delle distribuzioni di Core (importlib.metadata): visionsuite, vs-*
|
||||||
|
$distribuzioni = & $py -c "import importlib.metadata as m; print('\n'.join(sorted({d.metadata['Name'] for d in m.distributions() if d.metadata['Name'].lower() in ('visionsuite',) or d.metadata['Name'].lower().startswith('vs-')})))"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Elenco delle distribuzioni di Core non riuscito" }
|
||||||
|
$includi += @($distribuzioni -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-distribution-metadata=$_" })
|
||||||
|
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
||||||
|
|
||||||
|
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen...): righe "--include-..." di pdf\LEGGIMI.md
|
||||||
|
$flagPdf = @()
|
||||||
|
$leggimi = Join-Path $PSScriptRoot 'pdf\LEGGIMI.md'
|
||||||
|
if (Test-Path $leggimi) {
|
||||||
|
$flagPdf = @(Get-Content $leggimi | ForEach-Object {
|
||||||
|
if ($_ -match '^\s*(?:[-*]\s*)?`?(--(?:include|nofollow|noinclude|enable|plugin)[^\s`]*)`?\s*$') { $Matches[1] }
|
||||||
|
})
|
||||||
|
Write-Host "Flag Nuitka da pdf\LEGGIMI.md: $($flagPdf -join ' ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
||||||
|
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
||||||
|
|
||||||
|
$out = Join-Path $radice 'build\nuitka'
|
||||||
|
New-Item -ItemType Directory -Force $out | Out-Null
|
||||||
|
$nuitka = @(
|
||||||
|
'--standalone',
|
||||||
|
'--assume-yes-for-downloads',
|
||||||
|
'--msvc=latest',
|
||||||
|
'--windows-console-mode=disable', # nessuna finestra nera: gli errori escono in MessageBox e nel log
|
||||||
|
"--output-dir=$out",
|
||||||
|
'--output-filename=TieMeasureFlow.exe',
|
||||||
|
'--windows-icon-from-ico=packaging\windows\TieMeasureFlow.ico',
|
||||||
|
'--company-name=Tielogic',
|
||||||
|
'--product-name=TieMeasureFlow',
|
||||||
|
"--file-version=$ver4",
|
||||||
|
"--product-version=$ver4",
|
||||||
|
'--file-description=TieMeasureFlow',
|
||||||
|
'--copyright=Tielogic',
|
||||||
|
# codice del prodotto: backend, worker di visione, runner (src e' un pacchetto, flask_app no)
|
||||||
|
'--include-package=src.backend',
|
||||||
|
'--include-package=src.vision',
|
||||||
|
'--include-package=src.vision_worker',
|
||||||
|
# le migrazioni NON si compilano: Alembic le carica dal disco, vanno come .pyc (sotto)
|
||||||
|
'--nofollow-import-to=src.backend.migrations',
|
||||||
|
'--nofollow-import-to=src.backend.tests',
|
||||||
|
'--nofollow-import-to=src.vision.tests',
|
||||||
|
'--nofollow-import-to=src.vision_worker.tests',
|
||||||
|
# frontend Flask e servizio camera: moduli di primo livello trovati via PYTHONPATH
|
||||||
|
'--include-module=app',
|
||||||
|
'--include-module=config',
|
||||||
|
'--include-package=blueprints',
|
||||||
|
'--include-package=services',
|
||||||
|
'--include-module=camera_service',
|
||||||
|
'--nofollow-import-to=blueprints.tests',
|
||||||
|
'--nofollow-import-to=tests',
|
||||||
|
'--include-package=uvicorn', # loop e protocolli di uvicorn si caricano per nome
|
||||||
|
'--include-package=waitress',
|
||||||
|
'--include-package=alembic',
|
||||||
|
'--include-module=aiosqlite',
|
||||||
|
'--include-module=sqlalchemy.dialects.sqlite.aiosqlite',
|
||||||
|
'--include-package=harvesters', # camere GenTL (GigE): il producer .cti e' del vendor
|
||||||
|
'--include-package=genicam',
|
||||||
|
'--include-package-data=genicam',
|
||||||
|
'--include-package-data=babel', # locale-data di Babel (flask-babel)
|
||||||
|
'--nofollow-import-to=numba', # vedi nota in testa: il ripiego in Python puro
|
||||||
|
'--nofollow-import-to=llvmlite',
|
||||||
|
# dati letti dal disco con __file__: setup page, report, Flask (templates/static/translations alla radice)
|
||||||
|
'--include-data-dir=src\backend\templates=src\backend\templates',
|
||||||
|
'--include-data-files=src\backend\migrations\alembic.ini=src\backend\migrations\alembic.ini',
|
||||||
|
'--include-data-dir=src\frontend\flask_app\templates=templates',
|
||||||
|
'--include-data-dir=src\frontend\flask_app\static=static',
|
||||||
|
'--include-data-dir=src\frontend\flask_app\translations=translations',
|
||||||
|
'--include-data-files=pyproject.toml=pyproject.toml' # config.py ne legge la versione per il piede di pagina
|
||||||
|
) + $includi + $flagPdf + @('packaging\windows\avvio.py')
|
||||||
|
|
||||||
|
$t0 = Get-Date
|
||||||
|
& $py -m nuitka @nuitka
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Nuitka fallito (codice $LASTEXITCODE)" }
|
||||||
|
Write-Host ("Nuitka: {0:N0} minuti" -f ((Get-Date) - $t0).TotalMinutes)
|
||||||
|
|
||||||
|
$sorgente = Join-Path $out 'avvio.dist'
|
||||||
|
if (-not (Test-Path (Join-Path $sorgente 'TieMeasureFlow.exe'))) { throw "exe non trovato in $sorgente" }
|
||||||
|
|
||||||
|
# Migrazioni Alembic: env.py e versions\ come soli .pyc (compileall -b: .pyc accanto al sorgente, che si cancella)
|
||||||
|
$mig = Join-Path $sorgente 'src\backend\migrations'
|
||||||
|
New-Item -ItemType Directory -Force (Join-Path $mig 'versions') | Out-Null
|
||||||
|
Copy-Item 'src\backend\migrations\env.py' $mig -Force
|
||||||
|
Copy-Item 'src\backend\migrations\versions\*.py' (Join-Path $mig 'versions') -Force
|
||||||
|
& $py -m compileall -b -q $mig
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "compileall delle migrazioni fallito" }
|
||||||
|
Get-ChildItem $mig -Recurse -Filter *.py -File | Remove-Item -Force
|
||||||
|
Get-ChildItem $mig -Recurse -Directory -Filter __pycache__ | Remove-Item -Recurse -Force
|
||||||
|
|
||||||
|
# Commit del motore di visione: avvio.py lo legge in VISION_ENGINE_VERSION
|
||||||
|
$motore = (git -C vendor/visionsuite rev-parse HEAD).Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $motore) { throw "Commit di vendor/visionsuite non leggibile" }
|
||||||
|
[System.IO.File]::WriteAllText((Join-Path $sorgente 'vision-engine-version.txt'), $motore, (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
|
||||||
|
# DLL di WeasyPrint (piano PDF): lo script le scarica con sha256 pinnato
|
||||||
|
$gtk = Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1'
|
||||||
|
if (Test-Path $gtk) {
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File $gtk -Destinazione (Join-Path $sorgente 'gtk')
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
|
||||||
|
} else {
|
||||||
|
Write-Warning "packaging\windows\pdf\prendi-gtk.ps1 assente: il pacchetto non avra' le DLL di WeasyPrint (niente report PDF)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Il pacchetto non deve contenere codice sorgente o bytecode, tranne i .pyc delle migrazioni
|
||||||
|
$sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
|
||||||
|
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
||||||
|
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
||||||
|
|
||||||
|
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
||||||
|
|
||||||
|
$dist = Join-Path $radice 'dist'
|
||||||
|
New-Item -ItemType Directory -Force $dist | Out-Null
|
||||||
|
& $iscc "/DVersione=$ver3" "/DVersione4=$ver4" "/DOrigine=$sorgente" "/DUscita=$dist" (Join-Path $radice 'packaging\windows\TieMeasureFlow.iss')
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Inno Setup fallito" }
|
||||||
|
|
||||||
|
$setup = Join-Path $dist "TieMeasureFlow-setup-$ver3.exe"
|
||||||
|
Write-Host ("Pronto: {0} ({1:N1} MB)" -f $setup, ((Get-Item $setup).Length / 1MB))
|
||||||
Reference in New Issue
Block a user