Backup timestamps read wrong because of two faults stacked, which is why it
looked like a single offset.
The API serialised naive ISO ("2026-08-07T12:00:00"), with nothing saying the
value was UTC. JavaScript's new Date() parses that as BROWSER-LOCAL, so every
timestamp shifted by the viewer's offset before any timezone formatting ran.
Every datetime this plugin stores is naive UTC, so the wire format now carries
a trailing Z.
The history view then formatted with toLocaleString(), i.e. the viewer's zone,
ignoring the site_timezone setting entirely. It now loads that setting and
formats through the shared formatInZone helper, matching NotificationsList.
The panel list label is built server-side with strftime, so a client cannot
correct it afterwards. It now converts to the site zone using the same Setting
lookup the notifications plugin uses - without that it showed UTC, four hours
out at West Jefferson.
Tests cover the wire format and that 16:30Z renders as 12:30 in
America/New_York.
342 lines
12 KiB
Python
342 lines
12 KiB
Python
"""Backups API.
|
|
|
|
Read + download only. Revisions are created exclusively through the ADR-006
|
|
collector endpoint (POST /api/collector/backups), so there is deliberately no
|
|
create route here: a hand-posted "backup" that never came off a machine would
|
|
poison the history this feature exists to provide.
|
|
|
|
Downloads differ by storage backend. 'shopdb' kinds re-render from the stored
|
|
projection, which is what lets one revision produce either .reg dialect. 'share'
|
|
kinds are not streamed - ShopDB returns the UNC path for the tech to open,
|
|
because requiring the app server to mount the SFLD share would turn a
|
|
permissions slip into an unexplained empty download.
|
|
"""
|
|
|
|
from datetime import timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from flask import Blueprint, current_app, request, Response
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from shopdb.api import (
|
|
db, Asset,
|
|
success_response, error_response, ErrorCodes,
|
|
require_permission,
|
|
)
|
|
|
|
from ..models import BackupRevision
|
|
from ..models.backup import _utciso
|
|
from ..services.registry import REGISTRY, getkind
|
|
|
|
backups_bp = Blueprint('backups', __name__)
|
|
|
|
|
|
_DEFAULTTZ = 'America/New_York'
|
|
|
|
|
|
def _sitezone():
|
|
"""Site-configured IANA zone (settings key site_timezone).
|
|
|
|
Same lookup the notifications plugin uses. A stored timestamp is UTC, so
|
|
anything rendered server-side has to be converted or it shows the wrong
|
|
wall clock for the site - four hours out at West Jefferson.
|
|
"""
|
|
from shopdb.api import Setting
|
|
row = Setting.query.filter_by(key='site_timezone').first()
|
|
name = row.value if row and row.value else _DEFAULTTZ
|
|
try:
|
|
return ZoneInfo(name)
|
|
except Exception:
|
|
return ZoneInfo(_DEFAULTTZ)
|
|
|
|
|
|
def _label(revision):
|
|
"""Panel list title: kind-agnostic, readable at a glance.
|
|
|
|
Rendered in the SITE zone, not UTC: this string is baked server-side and
|
|
the client cannot correct it afterwards.
|
|
"""
|
|
when = revision.collectedat or revision.createdat
|
|
if not when:
|
|
return 'unknown time'
|
|
local = when.replace(tzinfo=timezone.utc).astimezone(_sitezone())
|
|
stamp = local.strftime('%Y-%m-%d %H:%M')
|
|
if revision.sourcefilename:
|
|
return '{} - {}'.format(stamp, revision.sourcefilename)
|
|
return stamp
|
|
|
|
|
|
def _summary(revision, islatest=False):
|
|
data = revision.to_dict()
|
|
data['label'] = _label(revision)
|
|
data['islatest'] = islatest
|
|
kind = getkind(revision.backupkind)
|
|
data['formats'] = kind.formats() if kind else []
|
|
return data
|
|
|
|
|
|
@backups_bp.route('/kinds', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('backups.view')
|
|
def list_kinds():
|
|
"""Registered backup kinds, for UI that needs to enumerate them."""
|
|
return success_response([
|
|
{
|
|
'key': kind.key,
|
|
'displayname': kind.displayname,
|
|
'storagebackend': kind.storagebackend,
|
|
'assettypes': list(kind.assettypes),
|
|
'formats': kind.formats(),
|
|
}
|
|
for kind in REGISTRY.values()
|
|
])
|
|
|
|
|
|
@backups_bp.route('/asset/<int:assetid>', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('backups.view')
|
|
def asset_revisions(assetid):
|
|
"""Revision history for one asset, newest first.
|
|
|
|
This is the asset-panel endpoint. Optional ?kind= narrows to a single kind,
|
|
which is how each per-kind panel scopes itself.
|
|
"""
|
|
asset = db.session.get(Asset, assetid)
|
|
if asset is None:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
|
|
|
query = db.session.query(BackupRevision).filter(
|
|
BackupRevision.assetid == assetid)
|
|
|
|
kindkey = (request.args.get('kind') or '').strip().lower()
|
|
if kindkey:
|
|
if getkind(kindkey) is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Unknown kind: {}'.format(kindkey))
|
|
query = query.filter(BackupRevision.backupkind == kindkey)
|
|
|
|
revisions = query.order_by(BackupRevision.backuprevisionid.desc()).all()
|
|
|
|
# "Latest" is per kind, not per asset, so an unfiltered listing still marks
|
|
# the current revision of each kind correctly.
|
|
seen = set()
|
|
out = []
|
|
for revision in revisions:
|
|
islatest = revision.backupkind not in seen
|
|
seen.add(revision.backupkind)
|
|
out.append(_summary(revision, islatest=islatest))
|
|
return success_response(out)
|
|
|
|
|
|
@backups_bp.route('/asset/<int:assetid>/info', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('backups.view')
|
|
def asset_info(assetid):
|
|
"""A kind's 'at a glance' card, built from its LATEST revision.
|
|
|
|
Generic across kinds: the requested kind owns both the panel declaration
|
|
and the payload (see BackupKind.infopanel / buildinfo), so a successor to
|
|
NTLARS/DNC gets its own card without a new endpoint.
|
|
|
|
Empty when the machine has no revision of that kind yet, which the generic
|
|
renderer turns into the panel's empty text.
|
|
"""
|
|
kindkey = (request.args.get('kind') or 'ntlars').strip().lower()
|
|
kind = getkind(kindkey)
|
|
if kind is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Unknown kind: {}'.format(kindkey))
|
|
|
|
revision = (db.session.query(BackupRevision)
|
|
.filter(BackupRevision.assetid == assetid,
|
|
BackupRevision.backupkind == kindkey)
|
|
.order_by(BackupRevision.backuprevisionid.desc())
|
|
.first())
|
|
if revision is None:
|
|
return success_response({'fields': [], 'sectioncount': 0})
|
|
|
|
data = kind.buildinfo(
|
|
revision.payload, assetid,
|
|
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'))
|
|
data['backuprevisionid'] = revision.backuprevisionid
|
|
data['collectedat'] = _utciso(revision.collectedat)
|
|
return success_response(data)
|
|
|
|
|
|
@backups_bp.route('/revisions/<int:backuprevisionid>', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('backups.view')
|
|
def get_revision(backuprevisionid):
|
|
"""One revision, including its parsed payload where there is one."""
|
|
revision = db.session.get(BackupRevision, backuprevisionid)
|
|
if revision is None:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
|
http_code=404)
|
|
data = revision.to_dict(includepayload=True)
|
|
data['label'] = _label(revision)
|
|
kind = getkind(revision.backupkind)
|
|
data['formats'] = kind.formats() if kind else []
|
|
data['displayname'] = kind.displayname if kind else revision.backupkind
|
|
return success_response(data)
|
|
|
|
|
|
@backups_bp.route('/revisions/<int:backuprevisionid>/download', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('backups.download')
|
|
def download_revision(backuprevisionid):
|
|
"""Render a revision back to its native file format.
|
|
|
|
?format=ntlars (default for NTLARS) omits WOW6432Node - the form the
|
|
NTLARS Load... button expects
|
|
?format=wow6432node includes WOW6432Node - for `reg import` on 64-bit
|
|
"""
|
|
revision = db.session.get(BackupRevision, backuprevisionid)
|
|
if revision is None:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
|
http_code=404)
|
|
|
|
kind = getkind(revision.backupkind)
|
|
if kind is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Unknown kind: {}'.format(revision.backupkind))
|
|
|
|
if revision.storagebackend == 'share':
|
|
# Not an error: the file exists, ShopDB just is not the one serving it.
|
|
return success_response({
|
|
'storagebackend': 'share',
|
|
'sharepath': revision.sharepath,
|
|
'sourcefilename': revision.sourcefilename,
|
|
'message': 'This backup lives on the SFLD share. Open the path directly.',
|
|
})
|
|
|
|
formats = kind.formats()
|
|
formatid = (request.args.get('format') or '').strip().lower()
|
|
if not formatid:
|
|
formatid = formats[0]['id'] if formats else ''
|
|
if not any(f['id'] == formatid for f in formats):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Unknown format {!r} for kind {}'.format(formatid, kind.key))
|
|
|
|
asset = db.session.get(Asset, revision.assetid)
|
|
assetnumber = asset.assetnumber if asset else str(revision.assetid)
|
|
when = revision.collectedat or revision.createdat
|
|
|
|
comments = [
|
|
'{} backup from ShopDB'.format(kind.displayname),
|
|
'Asset: {}'.format(assetnumber),
|
|
'Captured: {}'.format(when.strftime('%Y-%m-%d %H:%M:%S') if when else 'unknown'),
|
|
'Source PC: {}'.format(revision.sourcehostname or 'unknown'),
|
|
'Revision: {} ({})'.format(revision.backuprevisionid,
|
|
(revision.contenthash or '')[:12]),
|
|
]
|
|
|
|
try:
|
|
raw, ext, mimetype = kind.render(revision.payload, formatid,
|
|
comments=comments)
|
|
except ValueError as exc:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
|
|
|
# Named for the MACHINE, not the revision or the kind: a tech restoring bay
|
|
# 3204 wants 3204.reg, matching how the per-machine backups on the share
|
|
# have always been named. sourcefilename is deliberately not reused here -
|
|
# a seeded revision carries "3204.reg" already and appending an extension
|
|
# to it produced "3204.reg.reg".
|
|
filename = '{}{}'.format(assetnumber, ext)
|
|
if formatid == 'wow6432node':
|
|
# The two dialects must not collide in a downloads folder, and the
|
|
# suffix says which one will import correctly outside NTLARS.
|
|
filename = '{}-wow6432node{}'.format(assetnumber, ext)
|
|
|
|
return Response(
|
|
raw,
|
|
mimetype=mimetype,
|
|
headers={'Content-Disposition': 'attachment; filename="{}"'.format(filename)},
|
|
)
|
|
|
|
|
|
@backups_bp.route('/revisions/<int:backuprevisionid>/diff', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('backups.view')
|
|
def diff_revision(backuprevisionid):
|
|
"""Diff two revisions of the same asset+kind.
|
|
|
|
?against=<id> picks the comparison revision; default is the immediately
|
|
preceding one, which answers "what changed" without the user choosing.
|
|
"""
|
|
revision = db.session.get(BackupRevision, backuprevisionid)
|
|
if revision is None:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
|
http_code=404)
|
|
if revision.storagebackend != 'shopdb':
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Kind {} stores opaque files and cannot be diffed'.format(
|
|
revision.backupkind))
|
|
|
|
againstid = request.args.get('against', type=int)
|
|
if againstid:
|
|
other = db.session.get(BackupRevision, againstid)
|
|
else:
|
|
other = (db.session.query(BackupRevision)
|
|
.filter(BackupRevision.assetid == revision.assetid,
|
|
BackupRevision.backupkind == revision.backupkind,
|
|
BackupRevision.backuprevisionid < revision.backuprevisionid)
|
|
.order_by(BackupRevision.backuprevisionid.desc())
|
|
.first())
|
|
|
|
if other is None:
|
|
return success_response({
|
|
'backuprevisionid': revision.backuprevisionid,
|
|
'againstid': None,
|
|
'changes': [],
|
|
'message': 'No earlier revision to compare against.',
|
|
})
|
|
|
|
changes = _diffprojections(other.payload, revision.payload)
|
|
return success_response({
|
|
'backuprevisionid': revision.backuprevisionid,
|
|
'againstid': other.backuprevisionid,
|
|
'changes': changes,
|
|
'changecount': len(changes),
|
|
})
|
|
|
|
|
|
def _flatten(projection):
|
|
"""{(subkey, valuename): (type, data)} from a stored projection."""
|
|
flat = {}
|
|
for key in (projection or {}).get('keys', []):
|
|
path = key.get('path', '')
|
|
for name, entry in (key.get('values') or {}).items():
|
|
flat[(path, name)] = (entry.get('type'), entry.get('data'))
|
|
return flat
|
|
|
|
|
|
def _diffprojections(old, new):
|
|
oldflat = _flatten(old)
|
|
newflat = _flatten(new)
|
|
|
|
changes = []
|
|
for ref in sorted(set(oldflat) | set(newflat)):
|
|
path, name = ref
|
|
before = oldflat.get(ref)
|
|
after = newflat.get(ref)
|
|
if before == after:
|
|
continue
|
|
if before is None:
|
|
change = 'added'
|
|
elif after is None:
|
|
change = 'removed'
|
|
else:
|
|
change = 'changed'
|
|
changes.append({
|
|
'keypath': path,
|
|
'valuename': name,
|
|
'change': change,
|
|
'before': None if before is None else before[1],
|
|
'after': None if after is None else after[1],
|
|
'beforetype': None if before is None else before[0],
|
|
'aftertype': None if after is None else after[0],
|
|
})
|
|
return changes
|