displays: single display type with IP-driven role (dashboard/lobby/kiosk)
One 'display' image resolves what it shows from its own IP, like the existing
visitor-location BU mapping. Extend DashboardDefault with displayrole
(dashboard|lobby|partskiosk; migration 7d28, businessunitid now nullable since
only the dashboard role needs one) + a role->path map. New unauthenticated
GET /api/dashboarddefaults/display-role returns {role, path, businessunitid}
for the caller IP. Settings UI gains a Display selector, showing the business
unit only for the dashboard role.
This commit is contained in:
@@ -6,9 +6,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="setting-description">
|
<p class="setting-description">
|
||||||
Map a kiosk/lobby display's IP address to the business unit it should show.
|
Map a display PC's IP address to what it should show: the shopfloor
|
||||||
The shopfloor dashboard auto-selects this business unit by the display's IP
|
dashboard (and which business unit), the lobby slideshow, or the 3D parts
|
||||||
when none is chosen.
|
kiosk. A single "display" image resolves its role from its own IP.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>IP Address</th>
|
<th>IP Address</th>
|
||||||
|
<th>Display</th>
|
||||||
<th>Business Unit</th>
|
<th>Business Unit</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="d in items" :key="d.dashboarddefaultid">
|
<tr v-for="d in items" :key="d.dashboarddefaultid">
|
||||||
<td class="mono">{{ d.ipaddress }}</td>
|
<td class="mono">{{ d.ipaddress }}</td>
|
||||||
|
<td>{{ roleLabel(d.displayrole) }}</td>
|
||||||
<td>{{ d.businessunit || '-' }}</td>
|
<td>{{ d.businessunit || '-' }}</td>
|
||||||
<td class="cell-truncate" :title="d.description">{{ d.description || '-' }}</td>
|
<td class="cell-truncate" :title="d.description">{{ d.description || '-' }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="items.length === 0">
|
<tr v-if="items.length === 0">
|
||||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||||
No mappings yet
|
No mappings yet
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -60,8 +62,15 @@
|
|||||||
placeholder="e.g., 10.20.30.40" required />
|
placeholder="e.g., 10.20.30.40" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
<label for="displayrole">Display *</label>
|
||||||
|
<select id="displayrole" v-model="form.displayrole" class="form-control" required>
|
||||||
|
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" v-if="form.displayrole === 'dashboard'">
|
||||||
<label for="businessunitid">Business Unit *</label>
|
<label for="businessunitid">Business Unit *</label>
|
||||||
<select id="businessunitid" v-model="form.businessunitid" class="form-control" required>
|
<select id="businessunitid" v-model="form.businessunitid" class="form-control"
|
||||||
|
:required="form.displayrole === 'dashboard'">
|
||||||
<option value="">Select business unit...</option>
|
<option value="">Select business unit...</option>
|
||||||
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
|
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||||
{{ bu.businessunit }}
|
{{ bu.businessunit }}
|
||||||
@@ -108,6 +117,15 @@ import { useToast } from '../../composables/toast'
|
|||||||
import { apiError } from '../../utils/apiError'
|
import { apiError } from '../../utils/apiError'
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ value: 'dashboard', label: 'Shopfloor Dashboard' },
|
||||||
|
{ value: 'lobby', label: 'Lobby Slideshow' },
|
||||||
|
{ value: 'partskiosk', label: '3D Parts Kiosk' },
|
||||||
|
]
|
||||||
|
function roleLabel(value) {
|
||||||
|
return roleOptions.find(r => r.value === value)?.label || value
|
||||||
|
}
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const businessUnits = ref([])
|
const businessUnits = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -120,7 +138,7 @@ const error = ref('')
|
|||||||
const showDeleteModal = ref(false)
|
const showDeleteModal = ref(false)
|
||||||
const toDelete = ref(null)
|
const toDelete = ref(null)
|
||||||
|
|
||||||
const form = ref({ ipaddress: '', businessunitid: '', description: '' })
|
const form = ref({ ipaddress: '', displayrole: 'dashboard', businessunitid: '', description: '' })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -148,9 +166,10 @@ function openModal(item = null) {
|
|||||||
editing.value = item
|
editing.value = item
|
||||||
form.value = item ? {
|
form.value = item ? {
|
||||||
ipaddress: item.ipaddress || '',
|
ipaddress: item.ipaddress || '',
|
||||||
|
displayrole: item.displayrole || 'dashboard',
|
||||||
businessunitid: item.businessunitid || '',
|
businessunitid: item.businessunitid || '',
|
||||||
description: item.description || ''
|
description: item.description || ''
|
||||||
} : { ipaddress: '', businessunitid: '', description: '' }
|
} : { ipaddress: '', displayrole: 'dashboard', businessunitid: '', description: '' }
|
||||||
error.value = ''
|
error.value = ''
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
63
migrations/versions/7d28_dashboarddefault_displayrole.py
Normal file
63
migrations/versions/7d28_dashboarddefault_displayrole.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Add dashboarddefaults.displayrole; make businessunitid nullable
|
||||||
|
|
||||||
|
One "display" PC image resolves its role (dashboard / lobby / partskiosk) from
|
||||||
|
its IP. Existing rows are dashboard mappings, so displayrole defaults to
|
||||||
|
'dashboard'. Lobby / kiosk roles need no business unit, so businessunitid
|
||||||
|
becomes nullable.
|
||||||
|
|
||||||
|
Idempotent guards on column presence / nullability.
|
||||||
|
|
||||||
|
Revision ID: 7d28_dashboarddefault_displayrole
|
||||||
|
Revises: 7d27_roles_color
|
||||||
|
Create Date: 2026-07-21
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = '7d28_dashboarddefault_displayrole'
|
||||||
|
down_revision = '7d27_roles_color'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _cols(insp):
|
||||||
|
return {c['name']: c for c in insp.get_columns('dashboarddefaults')}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if 'dashboarddefaults' not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
cols = _cols(insp)
|
||||||
|
|
||||||
|
if 'displayrole' not in cols:
|
||||||
|
op.add_column('dashboarddefaults', sa.Column(
|
||||||
|
'displayrole', sa.String(length=20),
|
||||||
|
nullable=False, server_default='dashboard'))
|
||||||
|
|
||||||
|
if 'businessunitid' in cols and not cols['businessunitid']['nullable']:
|
||||||
|
op.alter_column('dashboarddefaults', 'businessunitid',
|
||||||
|
existing_type=sa.Integer(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if 'dashboarddefaults' not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
cols = _cols(insp)
|
||||||
|
|
||||||
|
# Only restore NOT NULL if no rows would violate it.
|
||||||
|
if 'businessunitid' in cols and cols['businessunitid']['nullable']:
|
||||||
|
nulls = bind.execute(sa.text(
|
||||||
|
'SELECT COUNT(*) FROM dashboarddefaults '
|
||||||
|
'WHERE businessunitid IS NULL')).scalar()
|
||||||
|
if not nulls:
|
||||||
|
op.alter_column('dashboarddefaults', 'businessunitid',
|
||||||
|
existing_type=sa.Integer(), nullable=False)
|
||||||
|
|
||||||
|
if 'displayrole' in cols:
|
||||||
|
op.drop_column('dashboarddefaults', 'displayrole')
|
||||||
@@ -9,6 +9,7 @@ from flask_jwt_extended import jwt_required
|
|||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import DashboardDefault, BusinessUnit, AuditLog
|
from shopdb.core.models import DashboardDefault, BusinessUnit, AuditLog
|
||||||
|
from shopdb.core.models.dashboarddefault import DISPLAY_ROLES, DISPLAY_ROLE_PATHS
|
||||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ def _serialize(default):
|
|||||||
data = default.to_dict()
|
data = default.to_dict()
|
||||||
data['businessunit'] = default.businessunit.businessunit \
|
data['businessunit'] = default.businessunit.businessunit \
|
||||||
if default.businessunit else None
|
if default.businessunit else None
|
||||||
|
data['displaypath'] = default.displaypath
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +56,35 @@ def visitor_location():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dashboarddefaults_bp.route('/display-role', methods=['GET'])
|
||||||
|
def display_role():
|
||||||
|
"""Resolve what a single 'display' PC should show, from its own IP.
|
||||||
|
|
||||||
|
Unauthenticated: the display launcher calls this at boot. Returns the role
|
||||||
|
(dashboard / lobby / partskiosk), the frontend path it maps to, and the
|
||||||
|
business unit for the dashboard role. Unmapped IP -> null role.
|
||||||
|
"""
|
||||||
|
ipaddress = request.args.get('ipaddress') or _request_ip()
|
||||||
|
default = DashboardDefault.query.filter_by(
|
||||||
|
ipaddress=ipaddress, isactive=True).first()
|
||||||
|
if not default:
|
||||||
|
return success_response({
|
||||||
|
'ipaddress': ipaddress,
|
||||||
|
'role': None,
|
||||||
|
'path': None,
|
||||||
|
'businessunitid': None,
|
||||||
|
'businessunit': None,
|
||||||
|
})
|
||||||
|
return success_response({
|
||||||
|
'ipaddress': ipaddress,
|
||||||
|
'role': default.displayrole,
|
||||||
|
'path': default.displaypath,
|
||||||
|
'businessunitid': default.businessunitid,
|
||||||
|
'businessunit': default.businessunit.businessunit
|
||||||
|
if default.businessunit else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@dashboarddefaults_bp.route('', methods=['GET'])
|
@dashboarddefaults_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@jwt_required(optional=True)
|
||||||
def list_defaults():
|
def list_defaults():
|
||||||
@@ -70,20 +101,30 @@ def create_default():
|
|||||||
"""Create a visitor-IP -> business-unit mapping."""
|
"""Create a visitor-IP -> business-unit mapping."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
ipaddress = (data.get('ipaddress') or '').strip()
|
ipaddress = (data.get('ipaddress') or '').strip()
|
||||||
|
role = (data.get('displayrole') or 'dashboard').strip()
|
||||||
businessunitid = data.get('businessunitid')
|
businessunitid = data.get('businessunitid')
|
||||||
|
|
||||||
if not ipaddress:
|
if not ipaddress:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required')
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required')
|
||||||
if not businessunitid:
|
if role not in DISPLAY_ROLES:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunitid is required')
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
if not db.session.get(BusinessUnit, businessunitid):
|
f'displayrole must be one of {", ".join(DISPLAY_ROLES)}')
|
||||||
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
|
# Only the dashboard role needs a business unit.
|
||||||
http_code=404)
|
if role == 'dashboard':
|
||||||
|
if not businessunitid:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'businessunitid is required for the dashboard role')
|
||||||
|
if not db.session.get(BusinessUnit, businessunitid):
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
|
||||||
|
http_code=404)
|
||||||
|
else:
|
||||||
|
businessunitid = None
|
||||||
if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first():
|
if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first():
|
||||||
return error_response(ErrorCodes.CONFLICT,
|
return error_response(ErrorCodes.CONFLICT,
|
||||||
f"IP {ipaddress} is already mapped", http_code=409)
|
f"IP {ipaddress} is already mapped", http_code=409)
|
||||||
|
|
||||||
default = DashboardDefault(ipaddress=ipaddress, businessunitid=businessunitid,
|
default = DashboardDefault(ipaddress=ipaddress, displayrole=role,
|
||||||
|
businessunitid=businessunitid,
|
||||||
description=data.get('description'))
|
description=data.get('description'))
|
||||||
db.session.add(default)
|
db.session.add(default)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
@@ -104,8 +145,14 @@ def update_default(default_id):
|
|||||||
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
|
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
|
||||||
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
if 'displayrole' in data:
|
||||||
|
role = (data.get('displayrole') or '').strip()
|
||||||
|
if role not in DISPLAY_ROLES:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
f'displayrole must be one of {", ".join(DISPLAY_ROLES)}')
|
||||||
|
default.displayrole = role
|
||||||
if 'businessunitid' in data:
|
if 'businessunitid' in data:
|
||||||
if not db.session.get(BusinessUnit, data['businessunitid']):
|
if data['businessunitid'] and not db.session.get(BusinessUnit, data['businessunitid']):
|
||||||
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
|
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
|
||||||
http_code=404)
|
http_code=404)
|
||||||
default.businessunitid = data['businessunitid']
|
default.businessunitid = data['businessunitid']
|
||||||
@@ -114,6 +161,13 @@ def update_default(default_id):
|
|||||||
if 'description' in data:
|
if 'description' in data:
|
||||||
default.description = data['description']
|
default.description = data['description']
|
||||||
|
|
||||||
|
# Non-dashboard roles carry no business unit; dashboard needs one.
|
||||||
|
if default.displayrole != 'dashboard':
|
||||||
|
default.businessunitid = None
|
||||||
|
elif not default.businessunitid:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'businessunitid is required for the dashboard role')
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response(_serialize(default), message='Mapping updated')
|
return success_response(_serialize(default), message='Mapping updated')
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
"""Dashboard default model: visitor-IP -> business-unit mapping.
|
"""Dashboard default model: display-PC-IP -> display role (+ business unit).
|
||||||
|
|
||||||
A shopfloor / lobby kiosk display resolves which business unit to show by the
|
A single "display" PC image resolves what it should show from its own IP: the
|
||||||
display PC's IP address. Powers the visitor-location lookup the shopfloor and
|
role (shopfloor dashboard, lobby slideshow, or 3D-parts kiosk) and, for the
|
||||||
TV dashboards call when no explicit business unit is given.
|
dashboard role, which business unit. Powers the visitor-location + display-role
|
||||||
|
lookups the kiosks call at launch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from .base import BaseModel
|
from .base import BaseModel
|
||||||
|
|
||||||
|
# Display role -> the frontend kiosk path it maps to. Kept here so the API and
|
||||||
|
# any consumer resolve a role to a URL the same way.
|
||||||
|
DISPLAY_ROLE_PATHS = {
|
||||||
|
'dashboard': '/shopfloor',
|
||||||
|
'lobby': '/tv',
|
||||||
|
'partskiosk': '/parts-kiosk',
|
||||||
|
}
|
||||||
|
DISPLAY_ROLES = tuple(DISPLAY_ROLE_PATHS.keys())
|
||||||
|
|
||||||
|
|
||||||
class DashboardDefault(BaseModel):
|
class DashboardDefault(BaseModel):
|
||||||
"""Maps a display PC IP address to the business unit it should show."""
|
"""Maps a display PC IP address to its display role (+ business unit)."""
|
||||||
__tablename__ = 'dashboarddefaults'
|
__tablename__ = 'dashboarddefaults'
|
||||||
|
|
||||||
dashboarddefaultid = db.Column(db.Integer, primary_key=True)
|
dashboarddefaultid = db.Column(db.Integer, primary_key=True)
|
||||||
ipaddress = db.Column(db.String(50), unique=True, nullable=False)
|
ipaddress = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
|
# Which display the IP drives. Only the dashboard role uses businessunitid.
|
||||||
|
displayrole = db.Column(db.String(20), nullable=False, default='dashboard')
|
||||||
businessunitid = db.Column(
|
businessunitid = db.Column(
|
||||||
db.Integer,
|
db.Integer,
|
||||||
db.ForeignKey('businessunits.businessunitid'),
|
db.ForeignKey('businessunits.businessunitid'),
|
||||||
nullable=False
|
nullable=True
|
||||||
)
|
)
|
||||||
description = db.Column(db.String(255))
|
description = db.Column(db.String(255))
|
||||||
|
|
||||||
businessunit = db.relationship('BusinessUnit')
|
businessunit = db.relationship('BusinessUnit')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def displaypath(self):
|
||||||
|
return DISPLAY_ROLE_PATHS.get(self.displayrole)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<DashboardDefault {self.ipaddress} -> {self.businessunitid}>"
|
return f"<DashboardDefault {self.ipaddress} -> {self.displayrole}>"
|
||||||
|
|||||||
@@ -97,3 +97,48 @@ def test_non_admin_cannot_update_or_delete(client, db, auth_headers,
|
|||||||
del_resp = client.delete(f'/api/dashboarddefaults/{default_id}',
|
del_resp = client.delete(f'/api/dashboarddefaults/{default_id}',
|
||||||
headers=member_headers)
|
headers=member_headers)
|
||||||
assert del_resp.status_code == 403
|
assert del_resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_lobby_role_needs_no_businessunit_and_resolves_path(client, db, auth_headers):
|
||||||
|
"""A lobby-role mapping needs no business unit; display-role returns its path."""
|
||||||
|
created = client.post('/api/dashboarddefaults', json={
|
||||||
|
'ipaddress': '10.20.30.99', 'displayrole': 'lobby',
|
||||||
|
'description': 'Front lobby TV',
|
||||||
|
}, headers=auth_headers)
|
||||||
|
assert created.status_code == 201, created.get_json()
|
||||||
|
assert created.get_json()['data']['businessunitid'] is None
|
||||||
|
|
||||||
|
resolved = client.get('/api/dashboarddefaults/display-role?ipaddress=10.20.30.99')
|
||||||
|
data = resolved.get_json()['data']
|
||||||
|
assert data['role'] == 'lobby'
|
||||||
|
assert data['path'] == '/tv'
|
||||||
|
assert data['businessunitid'] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_partskiosk_role_resolves_path(client, db, auth_headers):
|
||||||
|
client.post('/api/dashboarddefaults', json={
|
||||||
|
'ipaddress': '10.20.30.77', 'displayrole': 'partskiosk',
|
||||||
|
}, headers=auth_headers)
|
||||||
|
data = client.get('/api/dashboarddefaults/display-role?ipaddress=10.20.30.77').get_json()['data']
|
||||||
|
assert data['role'] == 'partskiosk'
|
||||||
|
assert data['path'] == '/parts-kiosk'
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_role_requires_businessunit(client, db, auth_headers):
|
||||||
|
resp = client.post('/api/dashboarddefaults', json={
|
||||||
|
'ipaddress': '10.20.30.55', 'displayrole': 'dashboard',
|
||||||
|
}, headers=auth_headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert 'businessunitid' in resp.get_json()['data']['error']['message'].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_role_rejected(client, db, auth_headers):
|
||||||
|
resp = client.post('/api/dashboarddefaults', json={
|
||||||
|
'ipaddress': '10.20.30.44', 'displayrole': 'bogus',
|
||||||
|
}, headers=auth_headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmapped_ip_display_role_null(client, db):
|
||||||
|
data = client.get('/api/dashboarddefaults/display-role?ipaddress=9.9.9.9').get_json()['data']
|
||||||
|
assert data['role'] is None and data['path'] is None
|
||||||
|
|||||||
Reference in New Issue
Block a user