add Entra device identity to computers and Azure/Thor import sources
PC records now carry Entra/Azure device identity so imported machines keep a durable link back to their directory object instead of matching on hostname alone. Adds azuredeviceid, azureobjectid, and azuredevicecategory to the Computer model with matching migrations, surfaces them read-only in PCDetail as an Entra Device Info card, and lets PCForm edit the device id directly. The computers plugin registers a machineassetnumber import hook that reconciles PC-to-machine links during bulk import and warns on unresolved or conflicting relationships. Adds importsources adapters that normalize Azure device-group and Thor PC inventory exports into canonical ShopDB CSVs via hostname alignment, exposes register_import_hook and RowWarning on the shopdb.api contract surface (contract 0.21.0), and refreshes the Import Site Assets page with clearer source instructions. Includes reconcile_locations maintenance script and coverage for the new import paths.
This commit is contained in:
@@ -6,6 +6,7 @@ import re
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from werkzeug.utils import secure_filename
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from shopdb.extensions import db, cache
|
||||
from shopdb.core.models import Setting, AuditLog
|
||||
@@ -332,6 +333,27 @@ def download_import_template(tablename):
|
||||
return response
|
||||
|
||||
|
||||
def _existing_hostname_map():
|
||||
"""Case-insensitive hostname -> assetnumber map for existing computers.
|
||||
|
||||
Azure device-group exports align on hostname (their displayName), while the
|
||||
import engine keys assets on assetnumber. This lets the Azure adapter route
|
||||
a matched device to its real asset so the import updates in place.
|
||||
"""
|
||||
assets = db.metadata.tables['assets']
|
||||
computers = db.metadata.tables['computers']
|
||||
query = sa_select(
|
||||
computers.c.hostname, assets.c.assetnumber
|
||||
).select_from(
|
||||
computers.join(assets, computers.c.assetid == assets.c.assetid)
|
||||
).where(computers.c.hostname.isnot(None))
|
||||
mapping = {}
|
||||
for hostname, assetnumber in db.session.execute(query):
|
||||
if hostname:
|
||||
mapping[hostname.strip().lower()] = assetnumber
|
||||
return mapping
|
||||
|
||||
|
||||
@settings_bp.route('/asset-import/import', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
@@ -344,10 +366,16 @@ def import_asset_csvs():
|
||||
transaction back and reports what WOULD change; commit=true persists it. The
|
||||
whole run is all-or-nothing - if any file has a row problem, nothing is
|
||||
written even on a commit.
|
||||
|
||||
A 'source' form field selects a foreign adapter (azure, thor); it defaults
|
||||
to 'native' where uploads are already canonical per-table files. Foreign
|
||||
uploads are transformed into canonical files first, then run identically.
|
||||
"""
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from shopdb.core.services.csvimport import (
|
||||
ImportError_, Resolver, dependency_order, import_csv, table_from_filename)
|
||||
from shopdb.core.services.importsources import (
|
||||
ImportSourceError, get_source)
|
||||
|
||||
uploads = request.files.getlist('files')
|
||||
if not uploads:
|
||||
@@ -356,21 +384,66 @@ def import_asset_csvs():
|
||||
commit = str(request.form.get('commit', '')).strip().lower() in (
|
||||
'1', 'true', 'yes', 'on')
|
||||
|
||||
# Map each upload to a table by filename. A later file with the same table
|
||||
# name wins, matching the folder-import behaviour (one file per table).
|
||||
# A foreign source (azure, thor) is transformed into canonical files before
|
||||
# import. 'native' (or nothing) means the files already ARE canonical.
|
||||
source_key = (request.form.get('source') or 'native').strip().lower()
|
||||
source = None
|
||||
if source_key and source_key != 'native':
|
||||
try:
|
||||
source = get_source(source_key)
|
||||
except ImportSourceError as exc:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
source_options = {
|
||||
'computertype': (request.form.get('computertype') or '').strip(),
|
||||
'category': (request.form.get('category') or '').strip(),
|
||||
}
|
||||
|
||||
# Azure and Thor align on hostname: their device name is the ShopDB
|
||||
# computers.hostname, not the assetnumber. Hand the adapter a
|
||||
# case-insensitive hostname -> existing assetnumber map so devices that
|
||||
# already exist update in place (keyed to their real asset) instead of
|
||||
# minting a duplicate asset.
|
||||
if source is not None and getattr(source, 'key', None) in ('azure', 'thor'):
|
||||
source_options['hostname_map'] = _existing_hostname_map()
|
||||
|
||||
texts = {}
|
||||
filenames = {}
|
||||
for upload in uploads:
|
||||
name = upload.filename or ''
|
||||
if not name.lower().endswith('.csv'):
|
||||
continue
|
||||
table = table_from_filename(name)
|
||||
try:
|
||||
texts[table] = upload.read().decode('utf-8-sig')
|
||||
except (UnicodeDecodeError, OSError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Could not read %s as UTF-8 text' % name)
|
||||
filenames[table] = name
|
||||
if source is not None:
|
||||
# One foreign export becomes several canonical files. Each upload is
|
||||
# transformed and its canonical outputs merged; a later file's table
|
||||
# wins, matching the native one-file-per-table rule.
|
||||
for upload in uploads:
|
||||
name = upload.filename or ''
|
||||
if not name.lower().endswith('.csv'):
|
||||
continue
|
||||
try:
|
||||
raw = upload.read().decode('utf-8-sig')
|
||||
except (UnicodeDecodeError, OSError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Could not read %s as UTF-8 text' % name)
|
||||
try:
|
||||
produced = source.transform(raw, options=source_options)
|
||||
except ImportSourceError as exc:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'%s: %s' % (name, str(exc)))
|
||||
for table, csv_text in produced.items():
|
||||
texts[table] = csv_text
|
||||
filenames[table] = '%s (from %s)' % (table, name)
|
||||
else:
|
||||
# Map each upload to a table by filename. A later file with the same
|
||||
# table name wins, matching the folder-import behaviour.
|
||||
for upload in uploads:
|
||||
name = upload.filename or ''
|
||||
if not name.lower().endswith('.csv'):
|
||||
continue
|
||||
table = table_from_filename(name)
|
||||
try:
|
||||
texts[table] = upload.read().decode('utf-8-sig')
|
||||
except (UnicodeDecodeError, OSError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Could not read %s as UTF-8 text' % name)
|
||||
filenames[table] = name
|
||||
|
||||
if not texts:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
@@ -389,6 +462,7 @@ def import_asset_csvs():
|
||||
resolver = Resolver(reconcile_threshold=reconcile_threshold)
|
||||
files_report = []
|
||||
problem_count = 0
|
||||
warning_count = 0
|
||||
reconciled_count = 0
|
||||
request_error = None
|
||||
|
||||
@@ -409,7 +483,17 @@ def import_asset_csvs():
|
||||
{'line': p.line, 'column': p.column, 'message': p.message}
|
||||
for p in result.problems
|
||||
]
|
||||
# Amber tier: a warning is a finding beside a row that still
|
||||
# imported (an unresolved machine link, a contested bay), NOT a
|
||||
# reason to fail the file. Reported alongside problems so the UI can
|
||||
# show Errors / Warnings / Info distinctly, but never gates commit.
|
||||
warnings = [
|
||||
{'line': w.line, 'column': w.column, 'reason': w.reason,
|
||||
'action': w.action, 'subject': w.subject, 'target': w.target}
|
||||
for w in result.warnings
|
||||
]
|
||||
problem_count += len(problems)
|
||||
warning_count += len(warnings)
|
||||
reconciled_count += len(result.reconciliations)
|
||||
files_report.append({
|
||||
'table': table,
|
||||
@@ -417,6 +501,7 @@ def import_asset_csvs():
|
||||
'created': result.created,
|
||||
'updated': result.updated,
|
||||
'problems': problems,
|
||||
'warnings': warnings,
|
||||
'reconciliations': result.reconciliations,
|
||||
})
|
||||
|
||||
@@ -446,6 +531,7 @@ def import_asset_csvs():
|
||||
'files': files_report,
|
||||
'skipped': skipped,
|
||||
'problem_count': problem_count,
|
||||
'warning_count': warning_count,
|
||||
'reconciled_count': reconciled_count,
|
||||
'reconcile_threshold': reconcile_threshold,
|
||||
'total_created': total_created,
|
||||
|
||||
Reference in New Issue
Block a user