"""GE-Enforce plugin API. Two audiences: - Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest. Full CRUD + publish lands in P2; this is the P1/first-slice read surface. - Client (service token, geenforce.fetch scope): GET /manifest serves the CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the collector's managed-token pattern (X-API-Key or Bearer PAT), plus an optional GEENFORCE_API_KEY env bootstrap. """ from functools import wraps from flask import Blueprint, request, current_app, Response from flask_jwt_extended import jwt_required from shopdb.api import ( db, success_response, error_response, ErrorCodes, require_permission, service_token_authorized, ) from ..models import ManifestScope, ManifestPublishedVersion from ..serializer import scope_to_manifest geenforce_bp = Blueprint('geenforce', __name__) FETCH_SCOPE = 'geenforce.fetch' def require_fetch_token(f): """Require a geenforce.fetch service token OR the env bootstrap key.""" @wraps(f) def decorated(*args, **kwargs): if service_token_authorized(FETCH_SCOPE): return f(*args, **kwargs) expected = current_app.config.get('GEENFORCE_API_KEY') if expected and request.headers.get('X-API-Key') == expected: return f(*args, **kwargs) return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', http_code=401) return decorated # -- client-facing endpoint --------------------------------------------------- @geenforce_bp.route('/manifest', methods=['GET']) @require_fetch_token def get_manifest(): """Serve the current published manifest for a scope (fat-client: full scope). Query: pctype (=scopename, required), phase (default runtime). The engine filters client-side, matching today, so subtype/hostname/machinenumber/ cmmversion are accepted but not applied here. """ scopename = (request.args.get('pctype') or '').strip() phase = (request.args.get('phase') or 'runtime').strip() if not scopename: return error_response(ErrorCodes.VALIDATION_ERROR, 'pctype is required', http_code=400) scope = ManifestScope.query.filter_by( scopename=scopename, phase=phase).first() if not scope: return error_response(ErrorCodes.NOT_FOUND, f'No scope: {scopename}', http_code=404) published = scope.publishedversions.filter_by(iscurrent=True).first() if not published: return error_response(ErrorCodes.NOT_FOUND, f'{scopename} has no published version', http_code=404) etag = f'"{scope.scopeid}-v{published.versionnumber}"' if request.headers.get('If-None-Match') == etag: return Response(status=304, headers={'ETag': etag}) return Response(published.manifestjson, mimetype='application/json', headers={'ETag': etag, 'X-Manifest-Version': str(published.versionnumber)}) # -- admin read surface (P2 adds full CRUD + publish) ------------------------- @geenforce_bp.route('/scopes', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') def list_scopes(): """List imaging PC-type scopes with entry + published-version counts.""" scopes = ManifestScope.query.order_by( ManifestScope.phase, ManifestScope.scopename).all() data = [] for scope in scopes: current = scope.publishedversions.filter_by(iscurrent=True).first() data.append({ 'scopeid': scope.scopeid, 'scopename': scope.scopename, 'phase': scope.phase, 'manifestversion': scope.manifestversion, 'computertypeid': scope.computertypeid, 'measuringtooltypeid': scope.measuringtooltypeid, 'iscommon': scope.iscommon, 'entrycount': len(scope.entries), 'publishedversion': current.versionnumber if current else None, }) return success_response(data) @geenforce_bp.route('/scopes//preview', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') def preview_scope(scopeid): """Render the DRAFT manifest JSON a publish would freeze (review before ship).""" scope = db.session.get(ManifestScope, scopeid) if not scope: return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) return success_response({'scopename': scope.scopename, 'manifest': scope_to_manifest(scope)})