Add GE-Enforce export-to-share + fleet-compliance UI (Milestone 1 UX)
Rounds out the Milestone 1 admin experience: author + publish in shopdb, push to the share by a button, and see what the fleet actually did. Export to share: - GET/PUT /api/geenforce/config stores the on-share export root (Setting geenforce_share_root); POST /scopes/<id>/export-share writes the current published JSON to <shareroot>/<scope>/manifest.json (preinstall.json for the preinstall phase), backing up the existing file to _meta/history first. geenforce.publish gated. The engine and PCs are untouched - this is the safe Milestone 1 push whose rollback is restoring the history backup. - Editor: a share-root config row + an "Export to Share" button per scope. - 3 tests (config roundtrip, export writes the file, second export backs up). Fleet-compliance UI (Settings > Enforcement Reports): - New page over GET /reports + /reports/<id>: latest report per PC with received (applied vs latest published version), status (ok/selfhealed/failed), and install/skip/fail counts; row detail shows per-entry outcomes with self-heal flags, exit codes, and messages. Hostname/PC-type filters. - ADR-010 settings card + ADR-009 plugin-gated route. Full suite 883 green; frontend build + naming green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,5 +11,11 @@ export default [
|
|||||||
name: 'imagingpctypes',
|
name: 'imagingpctypes',
|
||||||
component: () => import('../../views/settings/ImagingPCTypes.vue'),
|
component: () => import('../../views/settings/ImagingPCTypes.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/enforcementreports',
|
||||||
|
name: 'enforcementreports',
|
||||||
|
component: () => import('../../views/settings/EnforcementReports.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
120
frontend/src/views/settings/EnforcementReports.vue
Normal file
120
frontend/src/views/settings/EnforcementReports.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="enforcement-reports">
|
||||||
|
<div class="page-header"><h1>Enforcement Reports</h1></div>
|
||||||
|
<p class="intro">
|
||||||
|
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
|
||||||
|
up the current published manifest; status shows self-heal and failures.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<input v-model="filterHost" placeholder="Filter hostname" @keyup.enter="load" />
|
||||||
|
<input v-model="filterScope" placeholder="Filter PC type" @keyup.enter="load" />
|
||||||
|
<button class="btn" @click="load">Filter</button>
|
||||||
|
<button class="btn" @click="clearFilters">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Host</th><th>PC type</th><th>Received</th><th>Version</th>
|
||||||
|
<th>Status</th><th>Installed</th><th>Skipped</th><th>Failed</th>
|
||||||
|
<th>Last check-in</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in reports" :key="r.reportid">
|
||||||
|
<td>{{ r.hostname }}</td>
|
||||||
|
<td>{{ r.scopename }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="r.receivedlatest ? 'badge-success' : 'badge-warning'">
|
||||||
|
{{ r.receivedlatest ? 'yes' : 'behind' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="dim">{{ r.appliedversion ?? '-' }} / {{ r.latestversion ?? '-' }}</td>
|
||||||
|
<td><span class="badge" :class="statusClass(r.status)">{{ r.status }}</span></td>
|
||||||
|
<td>{{ r.installed }}</td>
|
||||||
|
<td class="dim">{{ r.skipped }}</td>
|
||||||
|
<td :class="{ 'fail-count': r.failed }">{{ r.failed }}</td>
|
||||||
|
<td class="dim">{{ formatDate(r.lastcheckin || r.receivedat) }}</td>
|
||||||
|
<td><button class="btn btn-small" @click="openDetail(r.reportid)">Detail</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail modal -->
|
||||||
|
<div v-if="detail" class="modal-overlay" @click.self="detail = null">
|
||||||
|
<div class="modal modal-wide">
|
||||||
|
<h2>{{ detail.hostname }} - {{ detail.scopename }}</h2>
|
||||||
|
<p class="dim">Applied v{{ detail.appliedversion ?? '-' }} of v{{ detail.latestversion ?? '-' }};
|
||||||
|
status {{ detail.status }}</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Entry</th><th>Action</th><th>Self-heal</th><th>Exit</th><th>Message</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(result, i) in detail.results" :key="i">
|
||||||
|
<td>{{ result.entryname }}</td>
|
||||||
|
<td><span class="badge" :class="actionClass(result.action)">{{ result.action }}</span></td>
|
||||||
|
<td>{{ result.selfhealed ? 'yes' : '' }}</td>
|
||||||
|
<td class="dim">{{ result.exitcode ?? '' }}</td>
|
||||||
|
<td class="dim">{{ result.message }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!detail.results.length"><td colspan="5" class="empty">No per-entry detail.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="modal-actions"><button class="btn" @click="detail = null">Close</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import api from '../../api'
|
||||||
|
|
||||||
|
const reports = ref([])
|
||||||
|
const detail = ref(null)
|
||||||
|
const filterHost = ref('')
|
||||||
|
const filterScope = ref('')
|
||||||
|
|
||||||
|
function payload(response) { return response.data.data }
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const params = {}
|
||||||
|
if (filterHost.value) params.hostname = filterHost.value
|
||||||
|
if (filterScope.value) params.scopename = filterScope.value
|
||||||
|
reports.value = payload(await api.get('/geenforce/reports', { params }))
|
||||||
|
}
|
||||||
|
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
|
||||||
|
async function openDetail(reportid) {
|
||||||
|
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status) {
|
||||||
|
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
|
||||||
|
}
|
||||||
|
function actionClass(action) {
|
||||||
|
return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger',
|
||||||
|
filtered: '' }[action] || ''
|
||||||
|
}
|
||||||
|
function formatDate(value) { return value ? new Date(value).toLocaleString() : '' }
|
||||||
|
|
||||||
|
load()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.enforcement-reports { max-width: 1100px; }
|
||||||
|
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
|
||||||
|
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
.dim { color: var(--text-light); }
|
||||||
|
.fail-count { color: var(--danger); font-weight: 600; }
|
||||||
|
.btn-small { padding: 0.15rem 0.5rem; font-size: 0.78rem; }
|
||||||
|
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
|
||||||
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex;
|
||||||
|
align-items: center; justify-content: center; z-index: 100; }
|
||||||
|
.modal { background: var(--bg-card); padding: 1.25rem; border-radius: 8px; min-width: 340px;
|
||||||
|
max-height: 90vh; overflow-y: auto; }
|
||||||
|
.modal-wide { min-width: 640px; }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; margin-top: 0.75rem; }
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,16 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="error" class="alert-error">{{ error }}</div>
|
<div v-if="error" class="alert-error">{{ error }}</div>
|
||||||
|
<div v-if="notice" class="alert-ok">{{ notice }}</div>
|
||||||
|
|
||||||
|
<div class="config-row card">
|
||||||
|
<label class="grow">On-share export root
|
||||||
|
<input v-model="shareRoot" placeholder="\\server\share\dt\shopfloor or /path" />
|
||||||
|
</label>
|
||||||
|
<button class="btn" @click="saveConfig">Save share root</button>
|
||||||
|
<span class="config-hint">Used by Export to Share. During Milestone 1 the
|
||||||
|
engine still reads these files; export is your push to the fleet.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="editor-grid">
|
<div class="editor-grid">
|
||||||
<!-- Scope list -->
|
<!-- Scope list -->
|
||||||
@@ -36,6 +46,7 @@
|
|||||||
<h2>{{ detail.scopename }}</h2>
|
<h2>{{ detail.scopename }}</h2>
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<button class="btn btn-primary" @click="publish">Publish</button>
|
<button class="btn btn-primary" @click="publish">Publish</button>
|
||||||
|
<button class="btn" @click="exportShare">Export to Share</button>
|
||||||
<button class="btn" @click="toggleVersions">Versions</button>
|
<button class="btn" @click="toggleVersions">Versions</button>
|
||||||
<button class="btn" @click="loadPreview">Preview</button>
|
<button class="btn" @click="loadPreview">Preview</button>
|
||||||
<button class="btn btn-danger" @click="deleteScope">Delete</button>
|
<button class="btn btn-danger" @click="deleteScope">Delete</button>
|
||||||
@@ -260,6 +271,8 @@ const scopes = ref([])
|
|||||||
const selectedId = ref(null)
|
const selectedId = ref(null)
|
||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const notice = ref('')
|
||||||
|
const shareRoot = ref('')
|
||||||
|
|
||||||
const showNewScope = ref(false)
|
const showNewScope = ref(false)
|
||||||
const newScope = ref({ scopename: '', phase: 'runtime' })
|
const newScope = ref({ scopename: '', phase: 'runtime' })
|
||||||
@@ -278,11 +291,28 @@ const simResult = ref(null)
|
|||||||
|
|
||||||
function payload(response) { return response.data.data }
|
function payload(response) { return response.data.data }
|
||||||
|
|
||||||
|
function flash(message) { notice.value = message; setTimeout(() => { notice.value = '' }, 4000) }
|
||||||
|
|
||||||
async function loadScopes() {
|
async function loadScopes() {
|
||||||
try {
|
try {
|
||||||
scopes.value = payload(await api.get('/geenforce/scopes'))
|
scopes.value = payload(await api.get('/geenforce/scopes'))
|
||||||
} catch (e) { error.value = 'Failed to load PC types' }
|
} catch (e) { error.value = 'Failed to load PC types' }
|
||||||
}
|
}
|
||||||
|
async function loadConfig() {
|
||||||
|
try { shareRoot.value = payload(await api.get('/geenforce/config')).shareroot } catch (e) { /* optional */ }
|
||||||
|
}
|
||||||
|
async function saveConfig() {
|
||||||
|
try {
|
||||||
|
await api.put('/geenforce/config', { shareroot: shareRoot.value })
|
||||||
|
flash('Share root saved.')
|
||||||
|
} catch (e) { error.value = 'Failed to save share root' }
|
||||||
|
}
|
||||||
|
async function exportShare() {
|
||||||
|
try {
|
||||||
|
const result = payload(await api.post(`/geenforce/scopes/${detail.value.scopeid}/export-share`))
|
||||||
|
flash(`Exported to ${result.path}`)
|
||||||
|
} catch (e) { error.value = e.response?.data?.message || 'Export failed' }
|
||||||
|
}
|
||||||
|
|
||||||
async function selectScope(id) {
|
async function selectScope(id) {
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
@@ -453,6 +483,7 @@ function formatDate(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadScopes()
|
loadScopes()
|
||||||
|
loadConfig()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -460,6 +491,14 @@ loadScopes()
|
|||||||
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
|
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
|
||||||
.alert-error { background: var(--danger); color: white; padding: 0.5rem 0.75rem;
|
.alert-error { background: var(--danger); color: white; padding: 0.5rem 0.75rem;
|
||||||
border-radius: 4px; margin-bottom: 1rem; }
|
border-radius: 4px; margin-bottom: 1rem; }
|
||||||
|
.alert-ok { background: var(--success); color: white; padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 4px; margin-bottom: 1rem; }
|
||||||
|
.config-row { display: flex; gap: 0.6rem; align-items: flex-end; padding: 0.6rem 0.75rem;
|
||||||
|
margin-bottom: 1rem; flex-wrap: wrap; }
|
||||||
|
.config-row label { font-size: 0.75rem; color: var(--text-light); display: flex;
|
||||||
|
flex-direction: column; gap: 0.2rem; }
|
||||||
|
.config-row .grow { flex: 1; min-width: 220px; }
|
||||||
|
.config-hint { font-size: 0.72rem; color: var(--text-light); flex-basis: 100%; }
|
||||||
.editor-grid { display: grid; grid-template-columns: 260px 1fr; gap: 1rem; align-items: start; }
|
.editor-grid { display: grid; grid-template-columns: 260px 1fr; gap: 1rem; align-items: start; }
|
||||||
.scope-list { padding: 0.5rem; }
|
.scope-list { padding: 0.5rem; }
|
||||||
.scope-row { padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer; }
|
.scope-row { padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer; }
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ from flask_jwt_extended import jwt_required
|
|||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, success_response, error_response, ErrorCodes, require_permission,
|
db, success_response, error_response, ErrorCodes, require_permission,
|
||||||
service_token_authorized,
|
service_token_authorized, Setting,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
||||||
ManifestEnforcementReport, ENTRY_TYPES, PHASES,
|
ManifestEnforcementReport, ENTRY_TYPES, PHASES,
|
||||||
@@ -421,6 +423,49 @@ def get_version(scopeid, versionnumber):
|
|||||||
'manifest': _json.loads(version.manifestjson)})
|
'manifest': _json.loads(version.manifestjson)})
|
||||||
|
|
||||||
|
|
||||||
|
@geenforce_bp.route('/config', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('geenforce.manage')
|
||||||
|
def get_config():
|
||||||
|
"""Plugin config: the on-share export root (for export-to-share)."""
|
||||||
|
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||||
|
return success_response({'shareroot': setting.value if setting else ''})
|
||||||
|
|
||||||
|
|
||||||
|
@geenforce_bp.route('/config', methods=['PUT'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('geenforce.publish')
|
||||||
|
def put_config():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(),
|
||||||
|
valuetype='string', category='geenforce',
|
||||||
|
description='On-share export root for GE-Enforce manifests')
|
||||||
|
db.session.commit()
|
||||||
|
return success_response({'shareroot': (payload.get('shareroot') or '').strip()})
|
||||||
|
|
||||||
|
|
||||||
|
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('geenforce.publish')
|
||||||
|
def export_share_route(scopeid):
|
||||||
|
"""Write the scope's current published JSON to the configured share root
|
||||||
|
(backing up the old file to _meta/history first)."""
|
||||||
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
|
if not scope:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||||
|
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||||
|
shareroot = setting.value if setting else ''
|
||||||
|
if not shareroot:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Configure the share root first', http_code=400)
|
||||||
|
try:
|
||||||
|
path = service.export_scope_to_share(scope.scopename, scope.phase,
|
||||||
|
shareroot)
|
||||||
|
except (ValueError, OSError) as exc:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
|
||||||
|
return success_response({'path': path})
|
||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@require_permission('geenforce.publish')
|
@require_permission('geenforce.publish')
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ class GeEnforcePlugin(BasePlugin):
|
|||||||
'publish, roll back, and simulate.',
|
'publish, roll back, and simulate.',
|
||||||
'position': 30,
|
'position': 30,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'group': 'Integrations',
|
||||||
|
'to': '/settings/enforcementreports',
|
||||||
|
'icon': 'settings',
|
||||||
|
'title': 'Enforcement Reports',
|
||||||
|
'description': 'Fleet compliance: which PCs received the latest '
|
||||||
|
'manifest and their self-heal / failure results.',
|
||||||
|
'position': 31,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
def init_app(self, app: Flask, db_instance) -> None:
|
def init_app(self, app: Flask, db_instance) -> None:
|
||||||
|
|||||||
65
tests/test_plugins/test_geenforce_export.py
Normal file
65
tests/test_plugins/test_geenforce_export.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""GE-Enforce export-to-share: config the share root, publish, write to share.
|
||||||
|
|
||||||
|
This is the Milestone 1 loop - author + publish in shopdb, then export the
|
||||||
|
published JSON to the on-share manifest with a history backup, engine untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from plugins.geenforce import service
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_with_entry(app, name='gea-shopfloor-cmm'):
|
||||||
|
with app.app_context():
|
||||||
|
service.replace_scope_draft(name, 'runtime', {
|
||||||
|
'Version': '2.6',
|
||||||
|
'Applications': [{'Name': 'Alpha', 'Type': 'MSI',
|
||||||
|
'Installer': 'apps/a.msi'}]})
|
||||||
|
service.publish_scope(name, 'runtime', notes='v1')
|
||||||
|
scope = service.ManifestScope.query.filter_by(scopename=name).first()
|
||||||
|
scopeid = scope.scopeid
|
||||||
|
service.db.session.commit()
|
||||||
|
return scopeid
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_requires_config(client, db, app, auth_headers):
|
||||||
|
scopeid = _scope_with_entry(app)
|
||||||
|
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 400 # no share root configured
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_roundtrip_and_export(client, db, app, auth_headers, tmp_path):
|
||||||
|
scopeid = _scope_with_entry(app)
|
||||||
|
|
||||||
|
# Configure the share root.
|
||||||
|
put = client.put('/api/geenforce/config',
|
||||||
|
json={'shareroot': str(tmp_path)}, headers=auth_headers)
|
||||||
|
assert put.status_code == 200
|
||||||
|
got = client.get('/api/geenforce/config', headers=auth_headers)
|
||||||
|
assert got.get_json()['data']['shareroot'] == str(tmp_path)
|
||||||
|
|
||||||
|
# Export writes the published JSON to <shareroot>/<scope>/manifest.json.
|
||||||
|
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
path = resp.get_json()['data']['path']
|
||||||
|
assert os.path.isfile(path)
|
||||||
|
written = json.load(open(path))
|
||||||
|
assert [e['Name'] for e in written['Applications']] == ['Alpha']
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_backs_up_existing(client, db, app, auth_headers, tmp_path):
|
||||||
|
scopeid = _scope_with_entry(app)
|
||||||
|
client.put('/api/geenforce/config', json={'shareroot': str(tmp_path)},
|
||||||
|
headers=auth_headers)
|
||||||
|
# First export creates the file.
|
||||||
|
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||||
|
headers=auth_headers)
|
||||||
|
# Second export backs the old file into _meta/history.
|
||||||
|
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||||
|
headers=auth_headers)
|
||||||
|
historydir = tmp_path / '_meta' / 'history'
|
||||||
|
assert historydir.is_dir()
|
||||||
|
assert any(historydir.iterdir())
|
||||||
Reference in New Issue
Block a user