Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66af9939f4
|
||
|
|
95ae9ff23d | ||
|
|
eab599edac | ||
|
|
bd0f76cad5 | ||
|
|
f8d67b28c9 | ||
|
|
e4bb261ead
|
||
|
|
6906859648 | ||
|
|
1c3d163104
|
||
|
|
3803e3e398 | ||
|
|
cb15fc0c27
|
||
|
|
171112162a |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -10,6 +10,8 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- `MarketingTablesGuardrailTest` and `ChannelRulesStalenessTest`.
|
||||
|
||||
### Changed
|
||||
- The Marketing home is business content only: **Configuration Status** and **Agent Activity** (provider configuration, model/tool calls and tokens) are off the home and exposed as `MarketingWidgets.ADMIN_WIDGETS` for a host to place on its Admin area; they need `marketing.admin.read` (hidden without it). **Needs Attention** gains one row per active channel that cannot send ("Sends on <channel> are paused - ask an admin"), linking to the channel.
|
||||
- Home and record widgets require `marketing.read` through `MarketingPermissionChecker` (HIDDEN): a user with app access but no read no longer sees a "You need marketing.read ..." shell; the widgets are omitted.
|
||||
- `QbitCrmGateway` uses qbit-crm 0.2.0's `crm_*` snake_case table names (`crm_contact`, ...). qbit-crm 0.1.0's camelCase tables are no longer supported.
|
||||
- Make the agent limits distributed (#4): `maxConcurrentAgentRuns` counts Running `marketing_agent_run` rows in the database (a new `heartbeatAt` column, updated on each model call, lets runs whose server stopped be marked Failed first), and `maxModelCallsPerMinute` counts model-call steps in the last 60 seconds and makes a run wait rather than fail. The in-memory semaphore and rate window are gone; the settings and Marketing Policy override keep their names and defaults (the policy `maxConcurrentAgentRuns` override now applies). Hosts add `heartbeat_at` to `marketing_agent_run`. The count-then-insert window is still racy; a conditional-update claim (qqq#1178) is a follow-up.
|
||||
- Move to the assistant provider SPI `0.1.0-RC.12` (package `com.kingsrook.qbits.assistant.spi`) and `qbit-build-parent` `2.1.2`, and drop the per-repository guardrail allow-lists. `qbit-assistant-provider-openai` becomes a test dependency; hosts that register it add it themselves.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.marketing.security;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Gates a widget on one exact permission name (marketing.read for the home
|
||||
** widgets, marketing.admin.read for the admin-placeable ones). QQQ's widget
|
||||
** rules can only express "<base>.hasAccess", so this checker is how a widget
|
||||
** requires a read permission. Denied widgets are HIDDEN: omitted from the
|
||||
** dashboard rather than shown locked.
|
||||
*******************************************************************************/
|
||||
public class MarketingPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
|
||||
{
|
||||
public static final String PERMISSION_PROPERTY = "permissionName";
|
||||
|
||||
private String permissionName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission rules requiring the named permission; hidden when missing.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules rules(String permissionName)
|
||||
{
|
||||
return (new QPermissionRules()
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withDenyBehavior(DenyBehavior.HIDDEN)
|
||||
.withCustomPermissionChecker(new QCodeReferenceWithProperties(MarketingPermissionChecker.class, Map.of(PERMISSION_PROPERTY, permissionName))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void initialize(QCodeReference codeReference)
|
||||
{
|
||||
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
|
||||
{
|
||||
this.permissionName = ValueUtils.getValueAsString(withProperties.getProperties().get(PERMISSION_PROPERTY));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deny unless the session holds the permission (fail closed). The system
|
||||
** user (scheduler) passes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session instanceof QSystemUserSession)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(permissionName == null || session == null || !session.hasPermission(permissionName))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean handlesBuildAvailablePermission()
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact permission name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType)
|
||||
{
|
||||
AvailablePermission permission = buildBaseAvailablePermission(rules, permissionSubType, metaDataWithName, objectType);
|
||||
return (permission == null || permissionName == null ? null : permission.withName(permissionName));
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,14 @@ import com.kingsrook.qbits.marketing.model.enums.ContentStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ExecutionStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingPermissionChecker;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
|
||||
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
@@ -75,15 +78,19 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
** The Marketing widgets: the app home dashboard (the one Marketing
|
||||
** overview - other apps only link to it) and two record widgets.
|
||||
**
|
||||
** - Home: Needs Attention, Campaigns, Agent Activity, Configuration Status.
|
||||
** - Home: Needs Attention, Campaigns (business content only).
|
||||
** - Admin-placeable (not on the home): Agent Activity (model/tool calls and
|
||||
** tokens) and Configuration Status (provider configuration) are system
|
||||
** content; a host places ADMIN_WIDGETS on its Admin area. They need
|
||||
** marketing.admin.read.
|
||||
** - Campaign record: Results (per stage; "Not measured" is never shown as 0).
|
||||
** - Content record: Proposed Publication (the exact text, destination,
|
||||
** recipients and schedule a reviewer approves).
|
||||
**
|
||||
** All are full-width tables of at most three columns (or one HTML block),
|
||||
** so they read at every width without inner scrolling. All data is real;
|
||||
** with none, they say so. Renderers require marketing.read and read with
|
||||
** the viewer's record locks.
|
||||
** with none, they say so. Home widgets require marketing.read (omitted, never
|
||||
** shown locked, without it) and read with the viewer's record locks.
|
||||
*******************************************************************************/
|
||||
public final class MarketingWidgets
|
||||
{
|
||||
@@ -96,7 +103,12 @@ public final class MarketingWidgets
|
||||
|
||||
private static final DateTimeFormatter WHEN_FORMAT = DateTimeFormatter.ofPattern("MMM d, yyyy h:mm a z", Locale.US);
|
||||
|
||||
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS, AGENT_ACTIVITY, CONFIGURATION);
|
||||
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// system content: for a host to place on its Admin area, not the home //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
public static final List<String> ADMIN_WIDGETS = List.of(AGENT_ACTIVITY, CONFIGURATION);
|
||||
|
||||
|
||||
|
||||
@@ -115,12 +127,12 @@ public final class MarketingWidgets
|
||||
public static List<QWidgetMetaData> buildWidgets()
|
||||
{
|
||||
return (List.of(
|
||||
widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class),
|
||||
widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class),
|
||||
widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class),
|
||||
widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class),
|
||||
widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class),
|
||||
widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class)));
|
||||
widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class, MarketingPermissions.READ),
|
||||
widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class, MarketingPermissions.READ),
|
||||
widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class, MarketingPermissions.ADMIN_READ),
|
||||
widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class, MarketingPermissions.ADMIN_READ),
|
||||
widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class, MarketingPermissions.READ),
|
||||
widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class, MarketingPermissions.READ)));
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +140,7 @@ public final class MarketingWidgets
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer)
|
||||
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer, String permission)
|
||||
{
|
||||
return (new QWidgetMetaData()
|
||||
.withName(name)
|
||||
@@ -136,7 +148,7 @@ public final class MarketingWidgets
|
||||
.withType(type.getType())
|
||||
.withGridColumns(12)
|
||||
.withIsCard(true)
|
||||
.withPermissionRules(MarketingPermissions.hasAccessRules(MarketingPermissions.BASE))
|
||||
.withPermissionRules(MarketingPermissionChecker.rules(permission))
|
||||
.withCodeReference(new QCodeReference(renderer)));
|
||||
}
|
||||
|
||||
@@ -147,16 +159,26 @@ public final class MarketingWidgets
|
||||
*******************************************************************************/
|
||||
abstract static class Base extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The permission this widget needs (rendering is refused without it, behind
|
||||
** the widget's own rules: no "you need ..." text is ever returned).
|
||||
*******************************************************************************/
|
||||
String requiredPermission()
|
||||
{
|
||||
return (MarketingPermissions.READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(MarketingPermissions.READ)))
|
||||
if(!MarketingPermissions.has(requiredPermission()))
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, "<p>You need " + MarketingPermissions.READ + " to see marketing data.</p>")));
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
return (renderFor(input));
|
||||
}
|
||||
@@ -283,9 +305,33 @@ public final class MarketingWidgets
|
||||
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", when(r.getValue("createDate"))));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// a channel that cannot send: said in business words (the provider detail is in //
|
||||
// the admin-only Configuration Status widget) //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
for(QRecord r : MarketingStore.query(MarketingChannel.TABLE_NAME, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)).withOrderBy(new QFilterOrderBy("id")).withLimit(50)))
|
||||
{
|
||||
if(StringUtils.hasContent(r.getValueString("transportName")) && !canSend(r.getValueString("transportName")))
|
||||
{
|
||||
rows.add(row("item", link(MarketingChannel.TABLE_NAME, r.getValue("id"), r.getValueString("name")), "what", "Sends on " + r.getValueString("name") + " are paused - ask an admin",
|
||||
"since", ""));
|
||||
}
|
||||
}
|
||||
|
||||
return (new RenderWidgetOutput(table(List.of(column("Item", "item", true), column("What", "what", false), column("Since", "since", false)), rows,
|
||||
"Nothing needs attention.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Can the named transport send (registered, and operational or test-only)?
|
||||
*******************************************************************************/
|
||||
private static boolean canSend(String transportName) throws QException
|
||||
{
|
||||
MarketingTransport transport = MarketingRuntime.current().getTransport(transportName);
|
||||
return (transport != null && transport.getStatus().isUsable());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -338,6 +384,17 @@ public final class MarketingWidgets
|
||||
*******************************************************************************/
|
||||
public static class AgentActivity extends Base
|
||||
{
|
||||
/*******************************************************************************
|
||||
** System content: marketing.admin.read.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
String requiredPermission()
|
||||
{
|
||||
return (MarketingPermissions.ADMIN_READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -379,6 +436,17 @@ public final class MarketingWidgets
|
||||
*******************************************************************************/
|
||||
public static class Configuration extends Base
|
||||
{
|
||||
/*******************************************************************************
|
||||
** System content: marketing.admin.read.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
String requiredPermission()
|
||||
{
|
||||
return (MarketingPermissions.ADMIN_READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -29,13 +29,16 @@ import com.kingsrook.qbits.marketing.model.MarketingContent;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingExecution;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingFinding;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingProduct;
|
||||
import com.kingsrook.qbits.marketing.model.enums.AiContentPolicy;
|
||||
import com.kingsrook.qbits.marketing.model.enums.CampaignGoal;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ClaimStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingType;
|
||||
import com.kingsrook.qbits.marketing.processes.MarketingActions;
|
||||
import com.kingsrook.qbits.marketing.readiness.ReadinessChecker;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
|
||||
import com.kingsrook.qbits.marketing.seed.MarketingStarterData;
|
||||
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
|
||||
import com.kingsrook.qbits.marketing.transport.QqqMessagingEmailTransport;
|
||||
@@ -49,8 +52,11 @@ import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolContext;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolInput;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
@@ -64,6 +70,7 @@ import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -117,7 +124,11 @@ class ComponentsTest extends BaseMarketingTest
|
||||
assertThat(campaigns.getRows()).hasSize(4);
|
||||
assertThat(campaigns.getColumns()).hasSizeLessThanOrEqualTo(3);
|
||||
assertThat(((TableData) render(MarketingWidgets.AGENT_ACTIVITY, null).getWidgetData()).getNoRowsFoundHTML()).contains("not run yet");
|
||||
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty();
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the starter email channel's transport is configured but not verified: sends paused //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows())
|
||||
.singleElement().satisfies(r -> assertThat(String.valueOf(r.get("what"))).endsWith("are paused - ask an admin"));
|
||||
|
||||
TableData results = (TableData) render(MarketingWidgets.CAMPAIGN_RESULTS, campaignId).getWidgetData();
|
||||
assertThat(results.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("result"))).startsWith("0 unique actors"));
|
||||
@@ -127,8 +138,81 @@ class ComponentsTest extends BaseMarketingTest
|
||||
RawHTML proposal = (RawHTML) render(MarketingWidgets.CONTENT_PROPOSAL, contentId).getWidgetData();
|
||||
assertThat(proposal.getHtml()).contains("<script>").doesNotContain("<script>").contains("Not approved");
|
||||
|
||||
as(user("no-read", com.kingsrook.qbits.marketing.security.MarketingPermissions.APP_ACCESS));
|
||||
assertThat(((RawHTML) render(MarketingWidgets.CAMPAIGNS, null).getWidgetData()).getHtml()).contains("marketing.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The business home lists only business widgets: no configuration status
|
||||
** and no agent activity (token and model usage), which are admin-placeable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHomeHasNoSystemWidgets()
|
||||
{
|
||||
List<String> home = qInstance.getApp("marketing").getWidgets();
|
||||
assertThat(home).containsExactly(MarketingWidgets.NEEDS_ATTENTION, MarketingWidgets.CAMPAIGNS)
|
||||
.doesNotContain(MarketingWidgets.CONFIGURATION, MarketingWidgets.AGENT_ACTIVITY);
|
||||
assertThat(MarketingWidgets.ADMIN_WIDGETS).containsExactly(MarketingWidgets.AGENT_ACTIVITY, MarketingWidgets.CONFIGURATION);
|
||||
assertThat(qInstance.getWidget(MarketingWidgets.CONFIGURATION)).isNotNull();
|
||||
assertThat(qInstance.getWidget(MarketingWidgets.AGENT_ACTIVITY)).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** App access without marketing.read: every read-requiring widget is omitted
|
||||
** (no permission) and never renders a "You need ..." shell. Admins still
|
||||
** get the moved widgets; read-only users do not.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWidgetsAreOmittedWithoutRead() throws QException
|
||||
{
|
||||
List<String> readWidgets = List.of(MarketingWidgets.NEEDS_ATTENTION, MarketingWidgets.CAMPAIGNS, MarketingWidgets.CAMPAIGN_RESULTS, MarketingWidgets.CONTENT_PROPOSAL);
|
||||
List<String> adminWidgets = MarketingWidgets.ADMIN_WIDGETS;
|
||||
|
||||
as(user("no-read", MarketingPermissions.APP_ACCESS));
|
||||
for(String name : CollectionUtils.mergeLists(readWidgets, adminWidgets))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
|
||||
assertThatThrownBy(() -> render(name, null)).as(name).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
as(user("reader", MarketingPermissions.APP_ACCESS, MarketingPermissions.READ));
|
||||
for(String name : readWidgets)
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isTrue();
|
||||
}
|
||||
for(String name : adminWidgets)
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
|
||||
}
|
||||
|
||||
as(admin());
|
||||
for(String name : adminWidgets)
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isTrue();
|
||||
assertThat(render(name, null).getWidgetData()).as(name).isInstanceOf(TableData.class);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A channel that cannot send adds one business-worded row linking to the
|
||||
** channel; nothing when every channel can send.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNeedsAttentionSaysWhenAChannelCannotSend() throws QException
|
||||
{
|
||||
testChannel();
|
||||
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty();
|
||||
|
||||
Integer blocked = channel("Newsletter", ChannelType.TEST_SINK, "no-such-transport", AiContentPolicy.ALLOWED, true);
|
||||
List<Map<String, Object>> rows = ((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows();
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(String.valueOf(rows.get(0).get("what"))).isEqualTo("Sends on Newsletter are paused - ask an admin");
|
||||
assertThat(String.valueOf(rows.get(0).get("item"))).contains("marketing_channel").contains(String.valueOf(blocked)).contains("Newsletter");
|
||||
assertThat(String.valueOf(rows.get(0))).doesNotContain("transport").doesNotContain("Not configured");
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user