Add audited workflow activation to CRM customer card

This commit is contained in:
Christian 2026-10-08 21:09:25 +02:00
parent 5eece0d704
commit 16fca2c8f1
16 changed files with 495 additions and 15 deletions

View File

@ -980,6 +980,32 @@
</div> </div>
</div> </div>
<div class="col-12" id="customerWorkflowCard" data-customer-id="{{ customer_id }}" hidden>
<section class="info-card" aria-labelledby="customerWorkflowHeading">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<h5 id="customerWorkflowHeading" class="fw-bold mb-0">Workflows</h5>
<span id="customerWorkflowEffective" class="badge text-bg-secondary">Henter status…</span>
</div>
<div class="d-flex align-items-start justify-content-between flex-wrap gap-3">
<div class="flex-grow-1">
<div class="form-check form-switch mb-1">
<input id="customerWorkflowEnabled" class="form-check-input" type="checkbox" role="switch" disabled>
<label class="form-check-label" for="customerWorkflowEnabled">Brug workflows for kunden</label>
</div>
<div id="customerWorkflowGlobal" class="small text-muted" aria-live="polite"></div>
<div id="customerWorkflowPreference" class="small text-muted"></div>
</div>
<div id="customerWorkflowWrite" class="flex-wrap align-items-end gap-2" hidden>
<label class="small mb-0" for="customerWorkflowReason">Begrundelse
<input id="customerWorkflowReason" class="form-control form-control-sm" placeholder="Hvorfor ændres indstillingen?" minlength="3" maxlength="1000" style="min-width:220px" disabled>
</label>
<button id="customerWorkflowSave" type="button" class="btn btn-sm btn-outline-primary" disabled>Gem</button>
</div>
</div>
<div id="customerWorkflowMessage" class="small mt-2" role="status"></div>
</section>
</div>
<div class="col-12"> <div class="col-12">
<div class="info-card"> <div class="info-card">
<h5 class="fw-bold mb-3">Integration</h5> <h5 class="fw-bold mb-3">Integration</h5>
@ -2220,6 +2246,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="/static/js/customer-workflow.js?v=20261008-1"></script>
<script src="/static/js/contact-mail-import.js?v=20261005-1"></script> <script src="/static/js/contact-mail-import.js?v=20261005-1"></script>
<script src="/static/js/company-contacts.js?v=20261005-2"></script> <script src="/static/js/company-contacts.js?v=20261005-2"></script>
<!-- Alert Notes Components --> <!-- Alert Notes Components -->

View File

@ -0,0 +1,61 @@
"""Customer-card opt-in; global configuration remains the master switch."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, field_validator
from app.core.auth_dependencies import get_current_user, require_permission
from app.core.auth_service import AuthService
from app.core.config import settings
from .service import transaction
router=APIRouter(prefix='/workflows/customers')
async def read_access(user=Depends(get_current_user)):
if not user.get('is_superadmin') and not any(AuthService.user_has_permission(user['id'],permission) for permission in ('customers.view','workflows.view')):
raise HTTPException(403,'Du mangler rettighed til at se kunden eller workflows')
return user
def can_manage(user):
return bool(user.get('is_superadmin') or all(AuthService.user_has_permission(user['id'],permission) for permission in ('customers.edit','workflows.activate')))
def activation_status(customer_id,cursor,user,lock=False):
cursor.execute('SELECT id FROM customers WHERE id=%s'+(' FOR UPDATE' if lock else ''),(customer_id,))
if not cursor.fetchone(): raise HTTPException(404,'Kunden findes ikke')
cursor.execute("SELECT to_regclass('public.bmc_workflow_customer_activation') AS name")
present=cursor.fetchone()
row=None
if present and present.get('name'):
cursor.execute('SELECT * FROM bmc_workflow_customer_activation WHERE customer_id=%s'+(' FOR UPDATE' if lock else ''),(customer_id,))
row=cursor.fetchone()
inherited_enabled=customer_id in settings.WORKFLOWS_CUSTOMER_IDS
enabled=bool(row['enabled']) if row else inherited_enabled
return {'customer_id':customer_id,'enabled':enabled,'explicit_override':bool(row['enabled']) if row else None,
'inherited':row is None,'effective_enabled':bool(settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE=='active' and enabled),
'global_enabled':settings.WORKFLOWS_ENABLED,'mode':settings.WORKFLOWS_MODE,
'can_manage':can_manage(user),'can_view':True,
'updated_at':row.get('updated_at') if row else None,'updated_by':row.get('updated_by') if row else None,
'schema_ready':bool(present and present.get('name'))}
class ActivationUpdate(BaseModel):
enabled:bool
reason:str=Field(min_length=3,max_length=1000)
@field_validator('reason')
@classmethod
def reason_is_meaningful(cls,value):
value=value.strip()
if len(value)<3: raise ValueError('Begrundelsen skal være mindst tre tegn')
return value
@router.get('/{customer_id}/activation')
def get_activation(customer_id:int,user=Depends(read_access)):
with transaction() as cursor: return activation_status(customer_id,cursor,user)
@router.put('/{customer_id}/activation')
def set_activation(customer_id:int,payload:ActivationUpdate,user=Depends(require_permission('customers.edit')),workflow_access=Depends(require_permission('workflows.activate'))):
with transaction() as cursor:
# Parent-row locking serializes first-ever overrides and their audit records.
previous=activation_status(customer_id,cursor,user,lock=True)
if not previous['schema_ready']: raise HTTPException(503,'Kundeaktivering kræver workflowmigration 1067')
cursor.execute('INSERT INTO bmc_workflow_customer_activation(customer_id,enabled,updated_by) VALUES(%s,%s,%s) ON CONFLICT(customer_id) DO UPDATE SET enabled=EXCLUDED.enabled,updated_by=EXCLUDED.updated_by,updated_at=now()',
(customer_id,payload.enabled,user['id']))
cursor.execute('INSERT INTO bmc_workflow_customer_activation_history(customer_id,before_enabled,after_enabled,inherited_enabled,actor_id,reason) VALUES(%s,%s,%s,%s,%s,%s)',
(customer_id,previous['explicit_override'],payload.enabled,customer_id in settings.WORKFLOWS_CUSTOMER_IDS,user['id'],payload.reason))
return activation_status(customer_id,cursor,user)

View File

@ -2,17 +2,50 @@
import json import json
import logging import logging
from app.core.config import settings from app.core.config import settings
from app.core.database import execute_query_single, execute_update from app.core.database import execute_query_single, execute_query, execute_update
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def customer_enabled(customer_id,cursor=None,fallback_ids=None):
"""Saved true/false overrides env preference; missing table preserves legacy config."""
if customer_id is None: return bool(settings.WORKFLOWS_INTERNAL_ENABLED)
fallback=settings.WORKFLOWS_CUSTOMER_IDS if fallback_ids is None else fallback_ids
inherited=str(customer_id) in {str(value) for value in fallback}
if cursor is None:
present=execute_query_single("SELECT to_regclass('public.bmc_workflow_customer_activation') AS name")
if not present or not present.get('name'): return inherited
row=execute_query_single('SELECT enabled FROM bmc_workflow_customer_activation WHERE customer_id=%s FOR SHARE',(int(customer_id),))
else:
cursor.execute("SELECT to_regclass('public.bmc_workflow_customer_activation') AS name")
present=cursor.fetchone()
if not present or not present.get('name'): return inherited
cursor.execute('SELECT enabled FROM bmc_workflow_customer_activation WHERE customer_id=%s FOR SHARE',(int(customer_id),))
row=cursor.fetchone()
return bool(row['enabled']) if row else inherited
def effective_customer_ids(cursor=None):
ids={int(value) for value in settings.WORKFLOWS_CUSTOMER_IDS}
if cursor is None:
present=execute_query_single("SELECT to_regclass('public.bmc_workflow_customer_activation') AS name")
rows=execute_query('SELECT customer_id,enabled FROM bmc_workflow_customer_activation') if present and present.get('name') else []
else:
cursor.execute("SELECT to_regclass('public.bmc_workflow_customer_activation') AS name")
present=cursor.fetchone()
if present and present.get('name'):
cursor.execute('SELECT customer_id,enabled FROM bmc_workflow_customer_activation')
rows=cursor.fetchall()
else: rows=[]
for row in rows:
if row['enabled']: ids.add(int(row['customer_id']))
else: ids.discard(int(row['customer_id']))
return sorted(ids)
def active_for_customer(customer_id): def active_for_customer(customer_id):
if not settings.WORKFLOWS_ENABLED or settings.WORKFLOWS_MODE != 'active': if not settings.WORKFLOWS_ENABLED or settings.WORKFLOWS_MODE != 'active':
return False return False
if customer_id is None: if customer_id is None:
return settings.WORKFLOWS_INTERNAL_ENABLED return settings.WORKFLOWS_INTERNAL_ENABLED
return int(customer_id) in settings.WORKFLOWS_CUSTOMER_IDS return customer_enabled(customer_id)
def configure_runtime(): def configure_runtime():
@ -25,8 +58,6 @@ def configure_runtime():
return return
if settings.WORKFLOWS_MODE not in {'shadow', 'active'}: if settings.WORKFLOWS_MODE not in {'shadow', 'active'}:
raise RuntimeError('WORKFLOWS_MODE must be shadow or active') raise RuntimeError('WORKFLOWS_MODE must be shadow or active')
if settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active' and not settings.WORKFLOWS_CUSTOMER_IDS and not settings.WORKFLOWS_INTERNAL_ENABLED:
raise RuntimeError('Active workflows require an explicit customer allowlist')
execute_update("UPDATE bmc_workflow_runtime SET enabled=%s, mode=%s, customer_ids=%s::jsonb, internal_enabled=%s, updated_at=now() WHERE id=1", execute_update("UPDATE bmc_workflow_runtime SET enabled=%s, mode=%s, customer_ids=%s::jsonb, internal_enabled=%s, updated_at=now() WHERE id=1",
(settings.WORKFLOWS_ENABLED, settings.WORKFLOWS_MODE, json.dumps(settings.WORKFLOWS_CUSTOMER_IDS), settings.WORKFLOWS_INTERNAL_ENABLED)) (settings.WORKFLOWS_ENABLED, settings.WORKFLOWS_MODE, json.dumps(settings.WORKFLOWS_CUSTOMER_IDS), settings.WORKFLOWS_INTERNAL_ENABLED))
@ -36,7 +67,6 @@ def run_workflow_jobs():
return return
from .worker import process_pending_events from .worker import process_pending_events
try: try:
return process_pending_events(limit=20, mode=settings.WORKFLOWS_MODE, return process_pending_events(limit=20, mode=settings.WORKFLOWS_MODE)
customer_ids=settings.WORKFLOWS_CUSTOMER_IDS)
except Exception: except Exception:
logger.exception('Workflow queue processing failed') logger.exception('Workflow queue processing failed')

View File

@ -5,6 +5,7 @@ from psycopg2.extras import Json
from app.core.auth_dependencies import require_permission from app.core.auth_dependencies import require_permission
from app.core.config import settings from app.core.config import settings
from .service import transaction from .service import transaction
from .integration import effective_customer_ids
router=APIRouter(prefix='/workflows/runtime') router=APIRouter(prefix='/workflows/runtime')
@ -15,8 +16,9 @@ def status(user=Depends(require_permission('workflows.view'))):
queues=cursor.fetchall() queues=cursor.fetchall()
cursor.execute("SELECT id,trigger,entity_type,entity_id,status,attempts,error,created_at FROM bmc_workflow_events WHERE status='failed' ORDER BY created_at DESC LIMIT 50") cursor.execute("SELECT id,trigger,entity_type,entity_id,status,attempts,error,created_at FROM bmc_workflow_events WHERE status='failed' ORDER BY created_at DESC LIMIT 50")
failed=cursor.fetchall() failed=cursor.fetchall()
customer_ids=effective_customer_ids(cursor)
return {'enabled':settings.WORKFLOWS_ENABLED,'mode':settings.WORKFLOWS_MODE, return {'enabled':settings.WORKFLOWS_ENABLED,'mode':settings.WORKFLOWS_MODE,
'customer_ids':settings.WORKFLOWS_CUSTOMER_IDS,'internal_enabled':settings.WORKFLOWS_INTERNAL_ENABLED, 'customer_ids':customer_ids,'internal_enabled':settings.WORKFLOWS_INTERNAL_ENABLED,
'ai_enabled':settings.WORKFLOW_AI_ENABLED,'queues':queues,'failed':failed} 'ai_enabled':settings.WORKFLOW_AI_ENABLED,'queues':queues,'failed':failed}
@router.post('/events/{event_id}/retry') @router.post('/events/{event_id}/retry')

View File

@ -65,7 +65,9 @@ def process_pending_events(limit=20, mode='shadow', customer_ids=None):
effective_mode = mode effective_mode = mode
allowed = runtime['customer_ids'] if customer_ids is None else customer_ids allowed = runtime['customer_ids'] if customer_ids is None else customer_ids
internal_allowed=event['entity_type'] in ('time','time_entry') and context.get('customer_id') is None and not context.get('customer_mapping_missing') and runtime.get('internal_enabled',False) internal_allowed=event['entity_type'] in ('time','time_entry') and context.get('customer_id') is None and not context.get('customer_mapping_missing') and runtime.get('internal_enabled',False)
if mode == 'active' and (runtime['mode'] != 'active' or (not internal_allowed and str(context.get('customer_id')) not in [str(value) for value in allowed])): from .integration import customer_enabled
customer_allowed=context.get('customer_id') is not None and customer_enabled(context['customer_id'],cursor,fallback_ids=allowed)
if mode == 'active' and (runtime['mode'] != 'active' or (not internal_allowed and not customer_allowed)):
effective_mode = 'shadow' effective_mode = 'shadow'
result['mode'] = effective_mode result['mode'] = effective_mode
result['billing'] = result.get('decision',{}) result['billing'] = result.get('decision',{})

View File

@ -51,17 +51,17 @@ Notér den udskrevne mappe. `/private/tmp` er midlertidig opbevaring; bevar en p
### Skift testtilstand ### Skift testtilstand
Ret kun de nedenstående nøgler i den **lokale `.env`**; bevar resten af filen og eventuelle eksisterende værdier til senere gendannelse. Kundelisten er JSON med testkundens **Hub-kunde-ID**, ikke et vTiger-ID. Ret kun de nedenstående nøgler i den **lokale `.env`**; bevar resten af filen og eventuelle eksisterende værdier til senere gendannelse. Kundelisten er en bagudkompatibel JSON-standard med **Hub-kunde-ID’er**, ikke vTiger-ID’er. Nye kundetilvalg styres fra kundekortet.
| Nøgle | Slukket | Skyggetilstand | Aktiv test | | Nøgle | Slukket | Skyggetilstand | Aktiv test |
|---|---|---|---| |---|---|---|---|
| `WORKFLOWS_ENABLED` | `false` | `true` | `true` | | `WORKFLOWS_ENABLED` | `false` | `true` | `true` |
| `WORKFLOWS_MODE` | `shadow` | `shadow` | `active` | | `WORKFLOWS_MODE` | `shadow` | `shadow` | `active` |
| `WORKFLOWS_CUSTOMER_IDS` | `[]` | `[TESTKUNDE_ID]` | `[TESTKUNDE_ID]` | | `WORKFLOWS_CUSTOMER_IDS` | `[]` | `[]` | `[]` |
| `WORKFLOWS_INTERNAL_ENABLED` | `false` | `false` | `false` | | `WORKFLOWS_INTERNAL_ENABLED` | `false` | `false` | `false` |
| `WORKFLOW_AI_ENABLED` | `false` | `false` | `false` | | `WORKFLOW_AI_ENABLED` | `false` | `false` | `false` |
Erstat `TESTKUNDE_ID` med et tal, fx `[123]`. En aktiveret workflowversion og en aktiveret motor er to forskellige ting; begge skal være sat korrekt. Aktivér testkunde A på CRM-kundekortet under Oversigt → Workflows → Brug workflows for kunden; skriv en begrundelse og klik Gem. Kundelisten kan nu stå tom. En eksisterende miljøkundeliste bruges kun som standard, indtil kunden har et gemt valg. Et gemt Fra gælder også for en kunde på miljølisten. En aktiveret workflowversion og en aktiveret motor er fortsat to forskellige ting; begge skal være sat korrekt.
Efter ændringer til `.env` skal Docker-servicen genskabes, fordi `env_file` indlæses ved oprettelse. En almindelig restart er ikke tilstrækkelig til at erstatte containerens gamle miljøvariabler. Efter ændringer til `.env` skal Docker-servicen genskabes, fordi `env_file` indlæses ved oprettelse. En almindelig restart er ikke tilstrækkelig til at erstatte containerens gamle miljøvariabler.
@ -313,6 +313,18 @@ Spring denne prøve over, hvis der ikke er en verificeret særskilt e-conomic-te
Permanent afinstallation og fuld databasegendannelse indgår **ikke** i den almindelige manuelle test. Afprøv kun dette i en separat disposable database efter [rollbackvejledningen](workflow-rollback.md). En fuld restore fjerner også senere data. Permanent afinstallation og fuld databasegendannelse indgår **ikke** i den almindelige manuelle test. Afprøv kun dette i en separat disposable database efter [rollbackvejledningen](workflow-rollback.md). En fuld restore fjerner også senere data.
### T21 – Aktivér og deaktivér fra CRM-kundekortet
1. Åbn `/customers/KUNDE_A_ID` → Oversigt → Workflows som administrator eller bruger med både `customers.edit` og `workflows.activate` samt læseadgang til kunder/workflows.
2. Slå Brug workflows for kunden til, skriv `Manuel CRM-test`, og klik Gem. Genindlæs: valget skal stadig være Til. Med global motor slukket viser kortet, at kundens valg er gemt, men ikke aktivt.
3. Start global skyggetilstand og derefter aktiv tilstand som beskrevet ovenfor. I aktiv tilstand skal kortet vise Aktiv for kunden. Registrér ny tid, og kontrollér workflowafregning.
4. Slå kunden fra, begrund og gem. Den globale motor skal forblive aktiv, men kortet vise Ikke aktiv. En ny tid følger den eksisterende arbejdsgang; eksisterende beslutninger og afregninger bevares.
5. Genstart den lokale API: kundens valg skal stadig være Fra. En eventuel gammel miljøkundeliste må ikke tilsidesætte det gemte Fra.
6. Log ind med kun læseadgang. Status må kunne ses, men aktivering/deaktivering må ikke kunne gemmes. Mangler begge læserettigheder, skjules sektionen.
7. Prøv tom eller for kort begrundelse. Valget må ikke blive gemt. Kundekortets øvrige sektioner skal stadig fungere.
**Forventet:** valget gælder uden miljøændring eller API-genstart pr. kunde; motorens globale tilstand er fortsat en hovedkontrol. Kun fremtidig behandling bruger valget; gamle tider genbehandles ikke automatisk. Tilvalg/fravalg registreres med bruger, tidspunkt og begrundelse. Slå testkunden fra efter prøven.
## 8. Fejlrapport og accept ## 8. Fejlrapport og accept
Hvis der er uventet korttræk, dobbelt kladde, forkert kunde, manglende historik eller ændringer i skyggetilstand: stop den aktive test, slå motor/AI fra og bevar data til fejlsøgning. Fortsæt ikke med flere afregninger. Hvis der er uventet korttræk, dobbelt kladde, forkert kunde, manglende historik eller ændringer i skyggetilstand: stop den aktive test, slå motor/AI fra og bevar data til fejlsøgning. Fortsæt ikke med flere afregninger.
@ -341,7 +353,8 @@ Notér ved fejl: testnummer, lokal URL, tidspunkt, brugerrolle, kunde-/sag-/tids
| T18 AI (valgfri) | | | | T18 AI (valgfri) | | |
| T19 e-conomic (valgfri) | | | | T19 e-conomic (valgfri) | | |
| T20 Deaktivering | | | | T20 Deaktivering | | |
| T21 Kundekortaktivering | | |
Grundforløbet er manuelt accepteret, når T01–T17 og T20 består, og ingen alvorlige fejl står åbne. AI og e-conomic kan kun accepteres særskilt efter deres egne tests. Slukket standard er ikke i sig selv godkendelse til produktion. Grundforløbet er manuelt accepteret, når T01–T17, T20 og T21 består, og ingen alvorlige fejl står åbne. AI og e-conomic kan kun accepteres særskilt efter deres egne tests. Slukket standard er ikke i sig selv godkendelse til produktion.
Automatisk SLA/eskalering, aftaleudløb samt eventkilder til `reminder.due` og `notification.created` er endnu ikke tilkoblet og kan ikke accepteres med denne plan. Registrér dem som **Ikke implementeret**, ikke Bestået. Sagoprettelse via workflow er heller ikke implementeret. Se [status](workflow-status.md). Automatisk SLA/eskalering, aftaleudløb samt eventkilder til `reminder.due` og `notification.created` er endnu ikke tilkoblet og kan ikke accepteres med denne plan. Registrér dem som **Ikke implementeret**, ikke Bestået. Sagoprettelse via workflow er heller ikke implementeret. Se [status](workflow-status.md).

View File

@ -40,7 +40,7 @@ Kør den tidligere kode fra den worktree med særskilt lokal konfiguration efter
## Permanent afinstallation af workflowdata ## Permanent afinstallation af workflowdata
Dette fjerner workflowdefinitioner, beslutninger, audit, AI-forslag, nyt taghierarki og nye metadatakolonner. Eksisterende kunder, sager, tidsregistreringer, konkrete tagtildelinger, ordrekladder og korttransaktioner bevares. Catch words fra det oprindelige tag-API bevares; avancerede keywordregler fjernes. Nye workflowspecifikke rettigheder fjernes. Dette fjerner workflowdefinitioner, beslutninger, audit, AI-forslag, nyt taghierarki og nye metadatakolonner. Eksisterende kunder, sager, tidsregistreringer, konkrete tagtildelinger, ordrekladder og korttransaktioner bevares. Catch words fra det oprindelige tag-API bevares; avancerede keywordregler fjernes. Nye workflowspecifikke rettigheder samt kundernes workflowtilvalg og deres historik fjernes.
1. Stop API og scheduler. Verificér featureflags er slået fra. Bevar backupmappe og kontrolsum-manifest. 1. Stop API og scheduler. Verificér featureflags er slået fra. Bevar backupmappe og kontrolsum-manifest.
2. Gennemgå SQL og den planlagte handling: 2. Gennemgå SQL og den planlagte handling:
@ -55,7 +55,7 @@ Dette fjerner workflowdefinitioner, beslutninger, audit, AI-forslag, nyt taghier
./.venv/bin/python scripts/workflow_backup.py uninstall --apply --confirm UNINSTALL-LOCAL-BMC --backup-dir /private/tmp/bmc-workflow-backup-DIT-CHECKPOINT ./.venv/bin/python scripts/workflow_backup.py uninstall --apply --confirm UNINSTALL-LOCAL-BMC --backup-dir /private/tmp/bmc-workflow-backup-DIT-CHECKPOINT
``` ```
Værktøjet tager en ny fuld backup og eksporterer audit før fjernelse. SQL er én transaktion uden `CASCADE`; ukendte afhængigheder stopper fjernelsen. Bevar også den nye backup. Fjern kodefunktionerne eller start ældre kode inden API startes; ny kode må ikke bruges mod den afinstallerede schema. Kontrollér migrationsmekanismen: migrationer 1062–1066 må ikke automatisk genanvendes, mens funktionaliteten ønskes fjernet. Værktøjet tager en ny fuld backup og eksporterer audit før fjernelse. SQL er én transaktion uden `CASCADE`; ukendte afhængigheder stopper fjernelsen. Bevar også den nye backup. Fjern kodefunktionerne eller start ældre kode inden API startes; ny kode må ikke bruges mod den afinstallerede schema. Kontrollér migrationsmekanismen: migrationer 1062–1067 må ikke automatisk genanvendes, mens funktionaliteten ønskes fjernet.
## Fuld databasegendannelse ## Fuld databasegendannelse

View File

@ -41,3 +41,13 @@ De fem nye sider bruger Hub’ens fælles layout, kort og temafarver med kompakt
Workflows/Workflowdrift kræver `workflows.view`; Aftaledækning kræver `workflows.view` og `workflows.manage`; Tagklassifikation kræver `system.admin`. Hvorfor kræver `workflows.decisions.view` og åbnes via tidsregistreringen. Skriveknapper følger de tilsvarende API-rettigheder. Klassifikations- og ressourceeditoren har også adgangskontrol ved direkte sideåbning. Workflows/Workflowdrift kræver `workflows.view`; Aftaledækning kræver `workflows.view` og `workflows.manage`; Tagklassifikation kræver `system.admin`. Hvorfor kræver `workflows.decisions.view` og åbnes via tidsregistreringen. Skriveknapper følger de tilsvarende API-rettigheder. Klassifikations- og ressourceeditoren har også adgangskontrol ved direkte sideåbning.
Render-, syntaks- og rettighedstests er udført lokalt. Visuel kontrol i en browser er ikke udført i denne session. Se [manuel testplan](workflow-manuel-testplan.md) for menu-, rolle- og temakontrol. Render-, syntaks- og rettighedstests er udført lokalt. Visuel kontrol i en browser er ikke udført i denne session. Se [manuel testplan](workflow-manuel-testplan.md) for menu-, rolle- og temakontrol.
## Kundekortaktivering – 8. oktober 2026
CRM → Kunde → Oversigt har en Workflows-sektion. Brug workflows for kunden gemmes med bruger, tidspunkt og begrundelse og gælder uden genstart pr. kunde. Et gemt Til eller Fra overstyrer den gamle `WORKFLOWS_CUSTOMER_IDS`-standard, også efter genstart. En tom miljøliste er nu tilladt i aktiv tilstand; kun tilvalgte kunder får aktiv behandling.
Global `WORKFLOWS_ENABLED` og shadow/active er fortsat hovedkontroller. Kundekortet viser både tilvalget og den faktiske tilstand. Ændring kræver `customers.edit` og `workflows.activate`; læsning kræver `customers.view` eller `workflows.view`. Eksisterende tider genbehandles ikke automatisk. Afregningskontrol, worker og driftsstatus læser samme kundevalg.
Migration 1067 er installeret lokalt. Motoren er fortsat slukket, og ingen kundetilvalg er foretaget som del af installationen. Backup før ændringen ligger i `/private/tmp/bmc-workflow-backup-_tf0w3xd`. Afinstallation og auditbackup omfatter de to nye kundetilvalgstabeller.
139 workflow-/kundekorttests består med 17 databasecases sprunget over i standardkørslen. Databasepakken består særskilt med 27 tests (heraf 10 overlappende mailtests), inklusive persistens, samtidige kundetilvalg og afinstallation. Kundekortets seks JavaScript-adfærdscases indgår i standardkørslen. Manuel browserkontrol og produktionsinstallation er ikke udført.

View File

@ -525,6 +525,8 @@ from app.workflows import export_guard as workflow_exports
app.include_router(workflow_exports.router, prefix="/api/v1", tags=["Workflow Exports"]) app.include_router(workflow_exports.router, prefix="/api/v1", tags=["Workflow Exports"])
from app.workflows import monitoring as workflow_monitoring from app.workflows import monitoring as workflow_monitoring
app.include_router(workflow_monitoring.router, prefix="/api/v1", tags=["Workflow Monitoring"]) app.include_router(workflow_monitoring.router, prefix="/api/v1", tags=["Workflow Monitoring"])
from app.workflows import customer_activation as workflow_customer_activation
app.include_router(workflow_customer_activation.router, prefix="/api/v1", tags=["Customer Workflows"])
app.include_router(workflows_api.router, prefix="/api/v1", tags=["Workflows"]) app.include_router(workflows_api.router, prefix="/api/v1", tags=["Workflows"])
app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"]) app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"])
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"]) app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])

View File

@ -0,0 +1,12 @@
-- Per-customer preferences never enable the global workflow master switch.
CREATE TABLE IF NOT EXISTS bmc_workflow_customer_activation (
customer_id INTEGER PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL,updated_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS bmc_workflow_customer_activation_history (
id BIGSERIAL PRIMARY KEY,customer_id INTEGER NOT NULL,before_enabled BOOLEAN,
after_enabled BOOLEAN NOT NULL,inherited_enabled BOOLEAN NOT NULL,
actor_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@ -90,7 +90,7 @@ def main():
verify_backup(args.backup_dir.resolve()) verify_backup(args.backup_dir.resolve())
# Take fresh full checkpoint and standalone audit export immediately before removal. # Take fresh full checkpoint and standalone audit export immediately before removal.
fresh=backup() fresh=backup()
audit_tables=['bmc_workflow_exports','bmc_workflow_resource_history','bmc_workflow_activation_history','bmc_workflow_versions','bmc_workflows','bmc_workflow_events','bmc_workflow_effects','bmc_workflow_executions','bmc_billing_decisions','bmc_workflow_settlements','workflow_tag_history','workflow_tag_proposals','workflow_tag_keywords','workflow_tag_ai_jobs','bmc_workflow_runtime'] audit_tables=['bmc_workflow_exports','bmc_workflow_customer_activation','bmc_workflow_customer_activation_history','bmc_workflow_resource_history','bmc_workflow_activation_history','bmc_workflow_versions','bmc_workflows','bmc_workflow_events','bmc_workflow_effects','bmc_workflow_executions','bmc_billing_decisions','bmc_workflow_settlements','workflow_tag_history','workflow_tag_proposals','workflow_tag_keywords','workflow_tag_ai_jobs','bmc_workflow_runtime']
with (fresh/'workflow-audit.sql').open('wb') as stream: with (fresh/'workflow-audit.sql').open('wb') as stream:
command(['docker','exec',CONTAINER,'pg_dump','-U',USER,'-d',DB,'--data-only','--no-owner','--no-acl',*[f'--table={table}' for table in audit_tables]],stdout=stream) command(['docker','exec',CONTAINER,'pg_dump','-U',USER,'-d',DB,'--data-only','--no-owner','--no-acl',*[f'--table={table}' for table in audit_tables]],stdout=stream)
metadata=json.loads((fresh/'manifest.json').read_text()) metadata=json.loads((fresh/'manifest.json').read_text())

View File

@ -24,6 +24,8 @@ DROP FUNCTION IF EXISTS bmc_invalidate_tag_times();
DROP FUNCTION IF EXISTS workflow_entity_tag_audit(); DROP FUNCTION IF EXISTS workflow_entity_tag_audit();
DROP FUNCTION IF EXISTS bmc_workflow_version_immutable(); DROP FUNCTION IF EXISTS bmc_workflow_version_immutable();
DROP TABLE IF EXISTS bmc_workflow_exports; DROP TABLE IF EXISTS bmc_workflow_exports;
DROP TABLE IF EXISTS bmc_workflow_customer_activation_history;
DROP TABLE IF EXISTS bmc_workflow_customer_activation;
DROP TABLE IF EXISTS bmc_workflow_resource_history; DROP TABLE IF EXISTS bmc_workflow_resource_history;
DROP TABLE IF EXISTS bmc_workflow_settlements; DROP TABLE IF EXISTS bmc_workflow_settlements;
DROP TABLE IF EXISTS bmc_workflow_effects; DROP TABLE IF EXISTS bmc_workflow_effects;

View File

@ -0,0 +1,91 @@
/* Isolated CRM preference panel; global runtime settings are read-only here. */
(() => {
'use strict';
function initialise() {
const card = document.getElementById('customerWorkflowCard');
if (!card) return;
const customerId = Number(card.dataset.customerId);
if (!Number.isInteger(customerId) || customerId <= 0) return;
const field = name => document.getElementById('customerWorkflow' + name);
const endpoint = '/api/v1/workflows/customers/' + customerId + '/activation';
let saved = null, busy = false;
function message(text, error = false) {
field('Message').textContent = text;
field('Message').className = 'small mt-2 ' + (error ? 'text-danger' : 'text-success');
}
function render(data) {
saved = data;
const canManage = !!data.can_manage && data.schema_ready !== false;
card.hidden = false;
field('Enabled').checked = data.enabled === true;
field('Enabled').disabled = !canManage || busy;
field('Write').hidden = !canManage;
field('Write').classList.toggle('d-flex', !!canManage);
field('Reason').disabled = !canManage || busy;
field('Save').disabled = !canManage || busy;
field('Effective').textContent = data.effective_enabled ? 'Aktiv for kunden' : 'Ikke aktiv';
field('Effective').className = 'badge ' + (data.effective_enabled ? 'text-bg-success' : 'text-bg-secondary');
field('Global').textContent = !data.global_enabled
? 'Workflowmotoren er slukket. Kundens valg er gemt og bruges, når motoren aktiveres.'
: data.mode === 'shadow'
? 'Motoren kører i skyggetilstand. Kundens valg er gemt; afregningen ændres ikke.'
: data.effective_enabled
? 'Automatisk afregning er slået til. Bogholderiet godkender tiden.'
: 'Motoren er aktiv, men workflows er slået fra for denne kunde.';
field('Preference').textContent = 'Kundens gemte valg: ' + (data.enabled ? 'Til' : 'Fra')
+ (data.inherited ? ' · tidligere opsætning' : '');
if (data.schema_ready === false) message('Workflowindstillingen kan først ændres, når databasens workflowopdatering er installeret.', true);
}
async function request(body) {
const response = await fetch(endpoint, {
method: body ? 'PUT' : 'GET', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(typeof data.detail === 'string' ? data.detail : 'Workflowindstillingen kunne ikke hentes.');
error.status = response.status;
throw error;
}
return data;
}
field('Save').addEventListener('click', async () => {
if (!saved?.can_manage || saved.schema_ready === false || busy) return;
const reason = field('Reason').value.trim();
if (reason.length < 3 || reason.length > 1000) {
message('Angiv en kort begrundelse, før du gemmer.', true);
field('Reason').focus();
return;
}
const enabled = field('Enabled').checked;
busy = true;
field('Save').disabled = field('Enabled').disabled = field('Reason').disabled = true;
field('Save').textContent = 'Gemmer…';
try {
const updated = await request({ enabled, reason });
busy = false;
render(updated);
field('Reason').value = '';
message('Kundens workflowvalg er gemt. Eksisterende tidsregistreringer genbehandles ikke.');
} catch (error) {
busy = false;
render(saved);
message(error.message, true);
} finally {
field('Save').textContent = 'Gem';
}
});
request().then(render).catch(error => {
// Lack of workflow access does not interrupt the existing customer page.
if (error.status === 401 || error.status === 403) return;
card.hidden = false;
field('Effective').textContent = 'Status ukendt';
field('Global').textContent = 'Kundens workflowindstilling kunne ikke hentes.';
message(error.message, true);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialise, { once: true });
else initialise();
})();

View File

@ -0,0 +1,46 @@
import shutil
import subprocess
import pytest
@pytest.mark.skipif(not shutil.which('node'), reason='Node required for CRM panel behavior')
@pytest.mark.parametrize('scenario', ['readonly', 'shadow', 'save', 'forbidden', 'read_failure', 'missing_schema'])
def test_customer_workflow_panel_isolated_permissions_and_effective_status(scenario):
script = r'''
const assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
const scenario=SCENARIO,elements={},calls=[];
for(const name of ['Card','Enabled','Write','Reason','Save','Effective','Global','Preference','Message']){
elements['customerWorkflow'+name]={hidden:name==='Card'||name==='Write',disabled:true,checked:false,value:'',textContent:'',dataset:{customerId:'24'},classList:{toggle(){}},listeners:{},addEventListener(type,fn){this.listeners[type]=fn;},focus(){}};
}
const document={readyState:'complete',getElementById:id=>elements[id]};
let data={customer_id:24,enabled:true,inherited:false,effective_enabled:false,global_enabled:false,mode:'active',can_manage:scenario==='save'||scenario==='shadow'};
if(scenario==='shadow')data={...data,global_enabled:true,mode:'shadow'};
if(scenario==='save')data={...data,enabled:false,global_enabled:true};
if(scenario==='missing_schema')data={...data,can_manage:true,schema_ready:false};
const fetch=async(url,options)=>{calls.push({url,options});if(scenario==='read_failure')throw Error('Offline');if(scenario==='forbidden')return{ok:false,status:403,json:async()=>({detail:'No access'})};if(options.method==='PUT')data={...data,...JSON.parse(options.body),effective_enabled:true};return{ok:true,json:async()=>data};};
vm.runInNewContext(fs.readFileSync('static/js/customer-workflow.js','utf8'),{document,fetch,console});
(async()=>{
await new Promise(resolve=>setImmediate(resolve));
assert.equal(calls[0].url,'/api/v1/workflows/customers/24/activation');
const card=elements.customerWorkflowCard;
if(scenario==='forbidden'){assert.equal(card.hidden,true);return;}
assert.equal(card.hidden,false);
if(scenario==='read_failure'){assert.equal(elements.customerWorkflowEnabled.disabled,true);assert.match(elements.customerWorkflowMessage.textContent,/Offline/);return;}
assert.equal(elements.customerWorkflowEffective.textContent,'Ikke aktiv');
if(scenario==='readonly'||scenario==='missing_schema'){
assert.equal(elements.customerWorkflowWrite.hidden,true);assert.equal(elements.customerWorkflowEnabled.disabled,true);
await elements.customerWorkflowSave.listeners.click();assert.equal(calls.length,1);
assert.match(elements.customerWorkflowGlobal.textContent,/slukket/);
if(scenario==='missing_schema')assert.match(elements.customerWorkflowMessage.textContent,/workflowopdatering/);
}else if(scenario==='shadow'){
assert.match(elements.customerWorkflowGlobal.textContent,/skyggetilstand/);assert.equal(elements.customerWorkflowEnabled.checked,true);
}else{
elements.customerWorkflowEnabled.checked=true;elements.customerWorkflowReason.value='Nye regler';
await elements.customerWorkflowSave.listeners.click();
assert.deepEqual(JSON.parse(calls[1].options.body),{enabled:true,reason:'Nye regler'});
assert.equal(calls[1].options.method,'PUT');assert.equal(elements.customerWorkflowEffective.textContent,'Aktiv for kunden');
}
})().catch(error=>{console.error(error);process.exitCode=1;});
'''.replace('SCENARIO', repr(scenario))
result = subprocess.run(['node', '-e', script], text=True, capture_output=True)
assert result.returncode == 0, result.stderr

View File

@ -0,0 +1,122 @@
from contextlib import contextmanager
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.workflows import customer_activation as api, integration, worker
@pytest.fixture
def client(monkeypatch):
state={'exists':True,'schema':True,'override':None,'history':[],'rights':{'customers.view','customers.edit','workflows.activate'}}
class Cursor:
def execute(self,sql,params=None):
self.sql=sql
if sql.startswith('INSERT INTO bmc_workflow_customer_activation('):
state['override']={'customer_id':params[0],'enabled':params[1],'updated_by':params[2],'updated_at':None}
elif sql.startswith('INSERT INTO bmc_workflow_customer_activation_history'):
state['history'].append(params)
def fetchone(self):
if 'FROM customers' in self.sql: return {'id':24} if state['exists'] else None
if 'to_regclass' in self.sql: return {'name':'bmc_workflow_customer_activation' if state['schema'] else None}
return state['override']
@contextmanager
def transaction(): yield Cursor()
monkeypatch.setattr(api,'transaction',transaction)
monkeypatch.setattr(api.AuthService,'user_has_permission',lambda actor,permission:permission in state['rights'])
monkeypatch.setattr(api.settings,'WORKFLOWS_ENABLED',False)
monkeypatch.setattr(api.settings,'WORKFLOWS_MODE','active')
monkeypatch.setattr(api.settings,'WORKFLOWS_CUSTOMER_IDS',[24])
app=FastAPI()
app.include_router(api.router,prefix='/api/v1')
app.dependency_overrides[api.get_current_user]=lambda:{'id':4,'username':'tester','is_superadmin':False}
return TestClient(app),state
def test_inherited_env_preference_does_not_enable_global_master(client):
browser,state=client
response=browser.get('/api/v1/workflows/customers/24/activation')
assert response.status_code==200
body=response.json()
assert body['enabled'] is True and body['inherited'] is True
assert body['explicit_override'] is None and body['effective_enabled'] is False
assert state['history']==[]
def test_false_override_wins_and_records_reason(client):
browser,state=client
response=browser.put('/api/v1/workflows/customers/24/activation',json={'enabled':False,'reason':'Stop automatik'})
assert response.status_code==200
body=response.json()
assert body['enabled'] is False and body['inherited'] is False and body['explicit_override'] is False
assert state['history']==[(24,None,False,True,4,'Stop automatik')]
def test_saved_true_preference_remains_inactive_in_shadow(client,monkeypatch):
browser,state=client
monkeypatch.setattr(api.settings,'WORKFLOWS_ENABLED',True)
monkeypatch.setattr(api.settings,'WORKFLOWS_MODE','shadow')
result=browser.put('/api/v1/workflows/customers/24/activation',json={'enabled':True,'reason':'Kunde har godkendt'})
assert result.status_code==200
assert result.json()['enabled'] is True and result.json()['effective_enabled'] is False
@pytest.mark.parametrize('missing',['customers.edit','workflows.activate'])
def test_write_requires_both_permissions(client,missing):
browser,state=client
state['rights'].remove(missing)
response=browser.put('/api/v1/workflows/customers/24/activation',json={'enabled':True,'reason':'Test årsag'})
assert response.status_code==403 and state['history']==[]
def test_read_accepts_workflow_view_without_customer_view(client):
browser,state=client
state['rights']={'workflows.view'}
response=browser.get('/api/v1/workflows/customers/24/activation')
assert response.status_code==200 and response.json()['can_manage'] is False
def test_nonexistent_customer_and_short_reason_rejected(client):
browser,state=client
state['exists']=False
assert browser.get('/api/v1/workflows/customers/24/activation').status_code==404
state['exists']=True
assert browser.put('/api/v1/workflows/customers/24/activation',json={'enabled':True,'reason':' '}).status_code==422
assert state['history']==[]
def test_missing_schema_reads_env_but_refuses_write(client):
browser,state=client
state['schema']=False
assert browser.get('/api/v1/workflows/customers/24/activation').json()['enabled'] is True
assert browser.put('/api/v1/workflows/customers/24/activation',json={'enabled':True,'reason':'Test årsag'}).status_code==503
def test_helper_false_database_override_wins_even_explicit_fallback(monkeypatch):
cursor=MagicMock()
cursor.fetchone.side_effect=[{'name':'activation'},{'enabled':False}]
assert integration.customer_enabled(24,cursor,fallback_ids=[24]) is False
assert 'FOR SHARE' in cursor.execute.call_args.args[0]
def test_effective_ids_merge_true_and_false_overrides(monkeypatch):
monkeypatch.setattr(integration.settings,'WORKFLOWS_CUSTOMER_IDS',[24,25])
cursor=MagicMock()
cursor.fetchone.return_value={'name':'activation'}
cursor.fetchall.return_value=[{'customer_id':24,'enabled':False},{'customer_id':26,'enabled':True}]
assert integration.effective_customer_ids(cursor)==[25,26]
def test_active_global_empty_env_allows_saved_customer(monkeypatch):
monkeypatch.setattr(integration.settings,'WORKFLOWS_ENABLED',True)
monkeypatch.setattr(integration.settings,'WORKFLOWS_MODE','active')
monkeypatch.setattr(integration.settings,'WORKFLOWS_CUSTOMER_IDS',[])
monkeypatch.setattr(integration,'execute_query_single',lambda sql,params=None:{'name':'activation'} if 'to_regclass' in sql else {'enabled':True})
assert integration.active_for_customer(24) is True
def test_active_empty_environment_does_not_prevent_customer_card_activation(monkeypatch):
monkeypatch.setattr(integration.settings,'WORKFLOWS_ENABLED',True)
monkeypatch.setattr(integration.settings,'WORKFLOWS_MODE','active')
monkeypatch.setattr(integration.settings,'WORKFLOWS_CUSTOMER_IDS',[])
monkeypatch.setattr(integration.settings,'WORKFLOWS_INTERNAL_ENABLED',False)
monkeypatch.setattr(integration,'execute_query_single',lambda sql:{'name':'runtime'})
writes=MagicMock()
monkeypatch.setattr(integration,'execute_update',writes)
integration.configure_runtime()
writes.assert_called_once()
assert 'bmc_workflow_customer_activation' not in writes.call_args.args[0]
def test_real_database_error_does_not_fall_back_to_env_enablement(monkeypatch):
cursor=MagicMock()
cursor.execute.side_effect=RuntimeError('Database unavailable')
with pytest.raises(RuntimeError,match='unavailable'):
integration.customer_enabled(24,cursor,fallback_ids=[24])

View File

@ -0,0 +1,60 @@
"""Opt-in persistence/concurrency checks on the disposable BMC clone only."""
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
import pytest
from psycopg2.extras import RealDictCursor
from test_workflow_db import db
from app.core.database import atomic_transaction
from app.workflows import customer_activation as api,integration
@pytest.fixture
def activation_customer(db):
with db.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute('SELECT id FROM customers ORDER BY id LIMIT 1')
customer=cursor.fetchone()['id']
cursor.execute('SELECT user_id FROM users ORDER BY user_id LIMIT 1')
actor=cursor.fetchone()['user_id']
cursor.execute('SELECT * FROM bmc_workflow_customer_activation WHERE customer_id=%s',(customer,))
previous=cursor.fetchone()
cursor.execute('SELECT COALESCE(MAX(id),0) AS id FROM bmc_workflow_customer_activation_history')
history_start=cursor.fetchone()['id']
cursor.execute('DELETE FROM bmc_workflow_customer_activation WHERE customer_id=%s',(customer,))
yield customer,{'id':actor,'username':'integration-test','is_superadmin':True},history_start
with db.cursor() as cursor:
cursor.execute('DELETE FROM bmc_workflow_customer_activation_history WHERE customer_id=%s AND id>%s',(customer,history_start))
cursor.execute('DELETE FROM bmc_workflow_customer_activation WHERE customer_id=%s',(customer,))
if previous:
cursor.execute('INSERT INTO bmc_workflow_customer_activation(customer_id,enabled,updated_by,updated_at) VALUES(%s,%s,%s,%s)',(customer,previous['enabled'],previous['updated_by'],previous['updated_at']))
def test_saved_override_is_persistent_and_master_off_stays_off(db,activation_customer,monkeypatch):
customer,actor,_=activation_customer
monkeypatch.setattr(integration.settings,'WORKFLOWS_ENABLED',False)
monkeypatch.setattr(integration.settings,'WORKFLOWS_MODE','active')
monkeypatch.setattr(integration.settings,'WORKFLOWS_CUSTOMER_IDS',[])
result=api.set_activation(customer,api.ActivationUpdate(enabled=True,reason='Disposable persistence test'),user=actor,workflow_access=actor)
assert result['enabled'] is True and result['effective_enabled'] is False
assert integration.customer_enabled(customer) is True
assert integration.active_for_customer(customer) is False
disabled=api.set_activation(customer,api.ActivationUpdate(enabled=False,reason='Disabled override test'),user=actor,workflow_access=actor)
assert disabled['explicit_override'] is False
with atomic_transaction() as connection:
with connection.cursor(cursor_factory=RealDictCursor) as cursor:
assert integration.customer_enabled(customer,cursor,fallback_ids=[customer]) is False
def test_concurrent_first_overrides_preserve_correct_audit_chain(db,activation_customer):
customer,actor,history_start=activation_customer
barrier=Barrier(2)
def write(enabled):
barrier.wait(timeout=10)
return api.set_activation(customer,api.ActivationUpdate(enabled=enabled,reason='Concurrent disposable test'),user=actor,workflow_access=actor)
with ThreadPoolExecutor(max_workers=2) as pool:
results=list(pool.map(write,[True,False]))
assert len(results)==2
with db.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute('SELECT before_enabled,after_enabled FROM bmc_workflow_customer_activation_history WHERE customer_id=%s AND id>%s ORDER BY id',(customer,history_start))
history=cursor.fetchall()
assert len(history)==2
assert history[0]['before_enabled'] is None
assert history[1]['before_enabled']==history[0]['after_enabled']
cursor.execute('SELECT enabled FROM bmc_workflow_customer_activation WHERE customer_id=%s',(customer,))
assert cursor.fetchone()['enabled']==history[1]['after_enabled']