GE-Enforce: compliance view, inline payload upload, frontend test harness
Fleet-install compliance for app-linked manifest entries: new service compliance_for_scope + GET /geenforce/scopes/<id>/compliance count active ComputerInstalledApp rows by curated appid (null-safe when computers plugin absent). ManifestEditor gains a compliance panel. Curated appid stays shopdb metadata and never enters manifest JSON, so behavioral parity is unaffected. Inline manifest payloads: store_inline_payload (sha256, 1MB cap, payloadsource='inline') + POST/GET /geenforce/entries/<id>/payload; editor gains an upload control. Entry payload metadata surfaced in _entry_payload. Frontend test harness: extract the editor's entry-form logic into pure entryForm.js (buildEntryPayload, describeEntry, availableEntryTypes, scope gates, ...) and cover it with 45 vitest tests. ManifestEditor now imports those helpers, so the tests exercise the shipped code path (no duplication). 908 backend tests pass; vitest 45 pass; frontend build green; naming green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,17 +7,18 @@ Kept out of the CLI and routes so both share one implementation:
|
||||
- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from shopdb.api import db
|
||||
from shopdb.api import db, Application
|
||||
|
||||
from .models import (
|
||||
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
||||
ManifestEnforcementResult,
|
||||
ManifestEnforcementResult, ManifestPayload,
|
||||
)
|
||||
from .importer import build_entry
|
||||
from .serializer import scope_to_json
|
||||
@@ -27,6 +28,20 @@ def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _installed_app_model():
|
||||
"""Lazily import the computers plugin's ComputerInstalledApp.
|
||||
|
||||
The computers plugin is optional; importing it lazily (not at module load)
|
||||
keeps geenforce usable when computers is absent or disabled. Mirrors
|
||||
collector._computer_models. Returns the model class, or None if absent.
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.models import ComputerInstalledApp
|
||||
return ComputerInstalledApp
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def replace_scope_draft(scopename, phase, manifest):
|
||||
"""Create/refresh a scope and REPLACE its draft entries. Published versions
|
||||
are left untouched. Returns the scope (uncommitted)."""
|
||||
@@ -175,6 +190,111 @@ def _parse_dt(value):
|
||||
return None
|
||||
|
||||
|
||||
def compliance_for_scope(scope):
|
||||
"""Fleet-install coverage for a scope's app-linked entries.
|
||||
|
||||
One row per ManifestEntry that carries a curated appid (unlinked entries
|
||||
are skipped), ordered by sortorder. Counts come from the computers plugin's
|
||||
ComputerInstalledApp (active rows only, one row per PC per app). Degrades
|
||||
gracefully with null counts when the computers plugin is absent.
|
||||
|
||||
Returns the response data dict (scopeid/scopename/phase/computersplugin/rows).
|
||||
"""
|
||||
linked = [e for e in sorted(scope.entries, key=lambda e: e.sortorder)
|
||||
if e.appid is not None]
|
||||
installedmodel = _installed_app_model()
|
||||
computerspresent = installedmodel is not None
|
||||
|
||||
# One grouped query for the whole scope's appid set (fleet is tiny).
|
||||
installedbyapp = {}
|
||||
if computerspresent and linked:
|
||||
appids = {e.appid for e in linked}
|
||||
counted = db.session.query(
|
||||
installedmodel.appid, func.count(installedmodel.id)
|
||||
).filter(
|
||||
installedmodel.isactive == True,
|
||||
installedmodel.appid.in_(appids),
|
||||
).group_by(installedmodel.appid).all()
|
||||
installedbyapp = {appid: count for appid, count in counted}
|
||||
|
||||
rows = []
|
||||
for entry in linked:
|
||||
app = db.session.get(Application, entry.appid)
|
||||
appname = app.appname if app else None
|
||||
expectedversion = (entry.detectionvalue
|
||||
if entry.detectionmethod == 'FileVersion' else None)
|
||||
|
||||
if not computerspresent:
|
||||
installedcount = None
|
||||
versionmatchcount = None
|
||||
coveragenote = ('computers plugin not installed; install counts '
|
||||
'unavailable')
|
||||
else:
|
||||
installedcount = installedbyapp.get(entry.appid, 0)
|
||||
if installedcount == 0:
|
||||
versionmatchcount = None if expectedversion is None else 0
|
||||
coveragenote = 'not installed on any collected PC'
|
||||
elif expectedversion is None:
|
||||
versionmatchcount = None
|
||||
method = entry.detectionmethod or 'none'
|
||||
coveragenote = (f'installed on {installedcount} PC(s); no '
|
||||
f'version target (detection is {method})')
|
||||
else:
|
||||
versionmatchcount = db.session.query(
|
||||
func.count(installedmodel.id)
|
||||
).filter(
|
||||
installedmodel.isactive == True,
|
||||
installedmodel.appid == entry.appid,
|
||||
installedmodel.installedversion == expectedversion,
|
||||
).scalar() or 0
|
||||
coveragenote = (f'{versionmatchcount} of {installedcount} '
|
||||
'collected PCs on the expected version')
|
||||
|
||||
rows.append({
|
||||
'entryid': entry.entryid,
|
||||
'entryname': entry.name,
|
||||
'appid': entry.appid,
|
||||
'appname': appname,
|
||||
'expectedversion': expectedversion,
|
||||
'installedcount': installedcount,
|
||||
'versionmatchcount': versionmatchcount,
|
||||
'coveragenote': coveragenote,
|
||||
})
|
||||
|
||||
return {
|
||||
'scopeid': scope.scopeid,
|
||||
'scopename': scope.scopename,
|
||||
'phase': scope.phase,
|
||||
'computersplugin': computerspresent,
|
||||
'rows': rows,
|
||||
}
|
||||
|
||||
|
||||
def store_inline_payload(entry, filename, contenttype, rawbytes):
|
||||
"""Replace the entry's inline payload with these bytes (uncommitted).
|
||||
|
||||
Computes payloadsha256, upserts the single ManifestPayload row (the table
|
||||
has no unique constraint on entryid, so the one-inline-payload-per-entry
|
||||
rule is enforced here), and points the entry at it (payloadsource='inline').
|
||||
Returns the ManifestPayload.
|
||||
"""
|
||||
sha = hashlib.sha256(rawbytes).hexdigest()
|
||||
ManifestPayload.query.filter_by(entryid=entry.entryid).delete()
|
||||
db.session.flush()
|
||||
payload = ManifestPayload(
|
||||
entryid=entry.entryid,
|
||||
filename=filename,
|
||||
contenttype=contenttype,
|
||||
payloadbytes=rawbytes,
|
||||
payloadsha256=sha,
|
||||
uploadedat=_utcnow())
|
||||
db.session.add(payload)
|
||||
entry.payloadsource = 'inline'
|
||||
entry.payloadref = filename
|
||||
entry.payloadsha256 = sha
|
||||
return payload
|
||||
|
||||
|
||||
def export_scope_to_share(scopename, phase, shareroot):
|
||||
"""Write a scope's current published JSON to the share, backing up the old
|
||||
file to _meta/history first. Returns the written path."""
|
||||
|
||||
Reference in New Issue
Block a user