Add site asset CSV import with fuzzy reconciliation
Sites seed inventory from the Asset Collector script or spreadsheet exports, but lookup names drift between sources (Dell vs Dell Inc.), which would otherwise create duplicate vendors/models/types on every import. The importer now normalizes and fuzzy-matches near-miss references onto existing lookup rows within an operator-set similarity threshold, auto-applying the match while reporting it for review so nothing merges silently. Operating systems and asset rows are never reconciled. Adds the dedicated Import Site Assets page with script/template download and a multi-file dry-run/commit workflow.
This commit is contained in:
659
frontend/src/views/settings/ImportSiteAssets.vue
Normal file
659
frontend/src/views/settings/ImportSiteAssets.vue
Normal file
@@ -0,0 +1,659 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Import Site Assets</h1>
|
||||
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="lead">
|
||||
There are several ways to seed this facility's inventory - Machines, PCs,
|
||||
Applications, and their properties - into ShopDB. You can combine them:
|
||||
each source writes to the same set of importable CSVs, and a later run
|
||||
fills gaps without wiping data an earlier run captured.
|
||||
</p>
|
||||
|
||||
<div class="methods">
|
||||
<div class="method is-primary">
|
||||
<div class="method-head">
|
||||
<span class="badge">Recommended</span>
|
||||
<h3>Asset Collector PowerShell script</h3>
|
||||
</div>
|
||||
<p>
|
||||
Deploy <code>Collect-ShopDB.ps1</code> to your existing endpoints in a
|
||||
way of your choosing (logon script, scheduled task, remote execution,
|
||||
or a manual run). It runs read-only, extracts each PC and any attached
|
||||
eDNC/DNC machine, and extrapolates vendor, model, OS, network, and
|
||||
installed-application details. Every run UPSERTs rows into the shared
|
||||
CSV store below, so a site builds its inventory one PC at a time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<div class="method-head">
|
||||
<h3>Existing databases</h3>
|
||||
</div>
|
||||
<p>
|
||||
Export from an existing MES, CMDB, or asset spreadsheet into the same
|
||||
CSV schema and drop the files in the collection location.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<div class="method-head">
|
||||
<h3>Intune / Azure exports</h3>
|
||||
</div>
|
||||
<p>
|
||||
Map a device or application export (for example from Microsoft Graph)
|
||||
onto the computers and applications CSVs, then place them alongside the
|
||||
script output.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="section-title">Asset Collector Script</h3>
|
||||
<p class="muted">
|
||||
Download <code>Collect-ShopDB.ps1</code> and deploy it to your endpoints
|
||||
(logon script, scheduled task, remote execution, or a manual run). The CSV
|
||||
Collections Location you save below is baked into the script's
|
||||
<code>$NetworkStoreRoot</code> variable, so every endpoint publishes into
|
||||
the same store without editing PowerShell by hand.
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="muted">Loading...</div>
|
||||
<template v-else>
|
||||
<label class="field">
|
||||
<span>CSV Collections Location</span>
|
||||
<input
|
||||
v-model="location"
|
||||
type="text"
|
||||
placeholder="\\server.fqdn\share\folder"
|
||||
/>
|
||||
<small class="muted">
|
||||
UNC path or folder where the script deposits its CSVs (e.g.
|
||||
\\server.fqdn\share\folder). Saved here and embedded into the script
|
||||
download.
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save Location' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" :disabled="downloadingScript" @click="downloadScript">
|
||||
{{ downloadingScript ? 'Preparing...' : 'Download Script' }}
|
||||
</button>
|
||||
<span v-if="saved" class="muted">Saved.</span>
|
||||
<span v-if="error" class="error">{{ error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="section-title">CSV Import Templates</h3>
|
||||
<p class="muted">
|
||||
These are early-draft import templates generated from the live ShopDB
|
||||
schema. Each file has a header row and a commented example. Fill one out
|
||||
(or let the collector script populate them) and import them in the order
|
||||
below - a table never references something that has not been loaded yet.
|
||||
Foreign keys accept the referenced row's name or its numeric id, and
|
||||
re-importing a file updates matching rows instead of duplicating them.
|
||||
</p>
|
||||
|
||||
<p v-if="templatesLoading" class="muted">Loading...</p>
|
||||
<ol v-else class="templates">
|
||||
<li v-for="t in templates" :key="t.table">
|
||||
<div class="template-row">
|
||||
<code>{{ t.filename }}</code>
|
||||
<button class="btn btn-secondary btn-sm" @click="downloadTemplate(t)">
|
||||
Download Template
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="templatesError" class="error">{{ templatesError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="section-title">Import CSVs</h3>
|
||||
<p class="muted">
|
||||
Select one or more filled-in CSVs. They are ordered by dependency
|
||||
automatically, so you can pick the whole set at once. Preview first: it
|
||||
writes nothing and reports exactly what each file would create or update,
|
||||
plus any rows that need fixing. Nothing is imported until you press
|
||||
Commit, and the whole set is applied together - if any row has a problem,
|
||||
nothing is written.
|
||||
</p>
|
||||
|
||||
<div class="import-controls">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
multiple
|
||||
@change="onFilesPicked"
|
||||
/>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="!selectedFiles.length || previewing || committing"
|
||||
@click="preview"
|
||||
>
|
||||
{{ previewing ? 'Checking...' : 'Preview (dry run)' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!canCommit"
|
||||
@click="commit"
|
||||
>
|
||||
{{ committing ? 'Importing...' : 'Commit Import' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedFiles.length"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="previewing || committing"
|
||||
@click="clearSelection"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedFiles.length" class="muted">
|
||||
{{ selectedFiles.length }} file(s) selected:
|
||||
{{ selectedFiles.map(f => f.name).join(', ') }}
|
||||
</p>
|
||||
|
||||
<p v-if="importError" class="error">{{ importError }}</p>
|
||||
|
||||
<div v-if="report" class="report">
|
||||
<p v-if="report.request_error" class="error">
|
||||
{{ report.request_error }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-else
|
||||
:class="['report-banner', report.committed ? 'ok' : (report.problem_count ? 'warn' : 'info')]"
|
||||
>
|
||||
<template v-if="report.committed">
|
||||
Imported: {{ report.total_created }} created,
|
||||
{{ report.total_updated }} updated.
|
||||
</template>
|
||||
<template v-else-if="report.problem_count">
|
||||
{{ report.problem_count }} problem(s) found - nothing was written.
|
||||
Fix the rows below and preview again.
|
||||
</template>
|
||||
<template v-else>
|
||||
Looks good: {{ report.total_created }} would be created,
|
||||
{{ report.total_updated }} updated. Press Commit Import to apply.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<p v-if="report.reconciled_count" class="report-banner reconcile">
|
||||
{{ report.reconciled_count }} name(s) reconciled to an existing record
|
||||
within the similarity threshold ({{ report.reconcile_threshold }}).
|
||||
Review the matches below before committing.
|
||||
</p>
|
||||
|
||||
<table v-if="report.files && report.files.length" class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Table</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Reconciled</th>
|
||||
<th>Problems</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in report.files" :key="f.table">
|
||||
<td><code>{{ f.filename }}</code></td>
|
||||
<td>{{ f.table }}</td>
|
||||
<td>{{ f.created }}</td>
|
||||
<td>{{ f.updated }}</td>
|
||||
<td :class="{ 'has-reconcile': (f.reconciliations || []).length }">
|
||||
{{ (f.reconciliations || []).length }}
|
||||
</td>
|
||||
<td :class="{ 'has-problems': f.problems.length }">
|
||||
{{ f.problems.length }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="allReconciliations.length" class="reconciliations">
|
||||
<h4>Reconciled matches</h4>
|
||||
<p class="muted">
|
||||
These names did not match exactly but were close enough to bind to an
|
||||
existing record. Confirm each is correct; if any is wrong, add the
|
||||
exact name to the matching CSV (or lower/raise the reconciliation
|
||||
threshold in Site settings) and preview again.
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="(r, i) in allReconciliations" :key="i">
|
||||
<code>{{ r.filename }}</code>
|
||||
line {{ r.line }}<template v-if="r.column">, column
|
||||
<code>{{ r.column }}</code></template>:
|
||||
"{{ r.input }}" matched <strong>{{ r.matched }}</strong>
|
||||
({{ r.table }}, {{ Math.round(r.score * 100) }}%)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="allProblems.length" class="problems">
|
||||
<h4>Problems to fix</h4>
|
||||
<ul>
|
||||
<li v-for="(p, i) in allProblems" :key="i">
|
||||
<code>{{ p.filename }}</code>
|
||||
line {{ p.line }}<template v-if="p.column">, column
|
||||
<code>{{ p.column }}</code></template>: {{ p.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p v-if="report.skipped && report.skipped.length" class="muted warn">
|
||||
Skipped (not importable): {{ report.skipped.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="section-title">PC to Machine Relationships</h3>
|
||||
<p class="muted">
|
||||
A PC that drives a machine (a CMM PC, a DNC/eDNC host) is linked to that
|
||||
machine so the two show up connected across ShopDB. That link is built
|
||||
automatically by the Asset Collector, not by this CSV import: the script
|
||||
reads the four-digit workstation number from the PC it runs on and
|
||||
reports it, and ShopDB matches that number to a machine's
|
||||
<code>assetnumber</code> to create the connection.
|
||||
</p>
|
||||
<p class="muted">
|
||||
What this means for import order: load your machines first (so their
|
||||
<code>assetnumber</code> values exist), then let the collector run on
|
||||
each PC. A machine number the collector reports that does not match an
|
||||
existing machine is flagged rather than guessed, so nothing links to the
|
||||
wrong asset. Building this relationship straight from a CSV column is
|
||||
planned as a follow-up.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const LOCATION_KEY = 'asset_import_csv_location'
|
||||
|
||||
const location = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const error = ref('')
|
||||
const downloadingScript = ref(false)
|
||||
|
||||
const templates = ref([])
|
||||
const templatesLoading = ref(false)
|
||||
const templatesError = ref('')
|
||||
|
||||
const fileInput = ref(null)
|
||||
const selectedFiles = ref([])
|
||||
const previewing = ref(false)
|
||||
const committing = ref(false)
|
||||
const importError = ref('')
|
||||
const report = ref(null)
|
||||
|
||||
// Commit is allowed only after a clean preview of the CURRENT selection: a
|
||||
// report with no problems and no request-level error that has not already been
|
||||
// committed.
|
||||
const canCommit = computed(() => {
|
||||
if (!report.value || committing.value || previewing.value) return false
|
||||
if (report.value.committed) return false
|
||||
if (report.value.request_error) return false
|
||||
return report.value.problem_count === 0
|
||||
})
|
||||
|
||||
const allProblems = computed(() => {
|
||||
if (!report.value?.files) return []
|
||||
const out = []
|
||||
for (const file of report.value.files) {
|
||||
for (const problem of file.problems) {
|
||||
out.push({ filename: file.filename, ...problem })
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const allReconciliations = computed(() => {
|
||||
if (!report.value?.files) return []
|
||||
const out = []
|
||||
for (const file of report.value.files) {
|
||||
for (const event of (file.reconciliations || [])) {
|
||||
out.push({ filename: file.filename, ...event })
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
async function loadLocation() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await settingsApi.get(LOCATION_KEY).catch(nullOn404)
|
||||
location.value = readValue(response) || ''
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not load the CSV location.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function nullOn404(err) {
|
||||
if (err.response?.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
|
||||
function readValue(response) {
|
||||
if (!response) return ''
|
||||
const setting = response.data?.data || response.data
|
||||
return setting?.value ?? ''
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
error.value = ''
|
||||
try {
|
||||
await settingsApi.update(LOCATION_KEY, location.value)
|
||||
saved.value = true
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Save failed.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveBlob(data, filename) {
|
||||
const url = window.URL.createObjectURL(new Blob([data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function downloadScript() {
|
||||
downloadingScript.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await settingsApi.downloadCollectorScript()
|
||||
saveBlob(response.data, 'Collect-ShopDB.ps1')
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Script download failed.')
|
||||
} finally {
|
||||
downloadingScript.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
templatesLoading.value = true
|
||||
templatesError.value = ''
|
||||
try {
|
||||
const response = await settingsApi.listImportTemplates()
|
||||
const data = response.data?.data || response.data || {}
|
||||
templates.value = data.tables || []
|
||||
} catch (err) {
|
||||
templatesError.value = apiError(err, 'Could not list import templates.')
|
||||
} finally {
|
||||
templatesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTemplate(t) {
|
||||
templatesError.value = ''
|
||||
try {
|
||||
const response = await settingsApi.downloadImportTemplate(t.table)
|
||||
saveBlob(response.data, t.filename)
|
||||
} catch (err) {
|
||||
templatesError.value = apiError(err, 'Template download failed.')
|
||||
}
|
||||
}
|
||||
|
||||
function onFilesPicked(event) {
|
||||
selectedFiles.value = Array.from(event.target.files || [])
|
||||
// A new selection invalidates any previous preview so a stale clean report
|
||||
// cannot be committed against different files.
|
||||
report.value = null
|
||||
importError.value = ''
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedFiles.value = []
|
||||
report.value = null
|
||||
importError.value = ''
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
async function runImport(commit) {
|
||||
importError.value = ''
|
||||
try {
|
||||
const response = await settingsApi.importAssetCsvs(selectedFiles.value, commit)
|
||||
report.value = response.data?.data || response.data || null
|
||||
} catch (err) {
|
||||
importError.value = apiError(err, commit ? 'Import failed.' : 'Preview failed.')
|
||||
report.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
previewing.value = true
|
||||
try {
|
||||
await runImport(false)
|
||||
} finally {
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
committing.value = true
|
||||
try {
|
||||
await runImport(true)
|
||||
} finally {
|
||||
committing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLocation()
|
||||
await loadTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lead {
|
||||
margin-top: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.methods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.method {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.method.is-primary {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.method-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.method-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.method p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.badge {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 640px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.field > span {
|
||||
font-weight: 600;
|
||||
}
|
||||
.field input[type='text'] {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.templates {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.templates li {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.template-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.template-row code {
|
||||
min-width: 200px;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.muted.warn {
|
||||
color: var(--warning, #c47f00);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.import-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.report {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.report-banner {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.report-banner.ok {
|
||||
background: var(--success-bg, rgba(40, 160, 80, 0.15));
|
||||
color: var(--success, #2e7d32);
|
||||
}
|
||||
.report-banner.info {
|
||||
background: var(--info-bg, rgba(60, 120, 200, 0.15));
|
||||
color: var(--text);
|
||||
}
|
||||
.report-banner.warn {
|
||||
background: var(--warning-bg, rgba(196, 127, 0, 0.15));
|
||||
color: var(--warning, #c47f00);
|
||||
}
|
||||
.report-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.report-table th,
|
||||
.report-table td {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.report-table td.has-problems {
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
.report-table td.has-reconcile {
|
||||
color: var(--warning, #c47f00);
|
||||
font-weight: 700;
|
||||
}
|
||||
.report-banner.reconcile {
|
||||
background: var(--warning-bg, rgba(196, 127, 0, 0.15));
|
||||
color: var(--warning, #c47f00);
|
||||
}
|
||||
.reconciliations {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.reconciliations h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--warning, #c47f00);
|
||||
}
|
||||
.reconciliations ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.reconciliations li {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.problems {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.problems h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.problems ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.problems li {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user