From d40bde74f8a44e1a8dc9540f136051ad8e450e9f Mon Sep 17 00:00:00 2001 From: Betsy Lorton Date: Fri, 5 Jun 2026 16:41:12 -0400 Subject: [PATCH 1/7] Switch long_description to be rich text editable --- django/django_application/settings/base.py | 1 + django/evaluate_m2/admin.py | 1 - django/evaluate_m2/models.py | 13 ++++++++++++- django/requirements.txt | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/django/django_application/settings/base.py b/django/django_application/settings/base.py index 918334fa..20da722c 100644 --- a/django/django_application/settings/base.py +++ b/django/django_application/settings/base.py @@ -49,6 +49,7 @@ 'django_filters', 'parse_m2.apps.ParseM2Config', 'evaluate_m2.apps.EvaluateM2Config', + 'django_prose_editor', ] MIDDLEWARE = [ diff --git a/django/evaluate_m2/admin.py b/django/evaluate_m2/admin.py index 0e383931..812960ec 100644 --- a/django/evaluate_m2/admin.py +++ b/django/evaluate_m2/admin.py @@ -11,7 +11,6 @@ class EvaluatorMetadataAdmin(admin.ModelAdmin): readonly_fields = [ 'id', 'category', 'fields_used', 'fields_display', - 'long_description', ] fields = [ 'id', 'category', 'fields_used', 'fields_display', diff --git a/django/evaluate_m2/models.py b/django/evaluate_m2/models.py index 9822f67a..3bf7ae83 100644 --- a/django/evaluate_m2/models.py +++ b/django/evaluate_m2/models.py @@ -2,6 +2,7 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.db.models import JSONField +from django_prose_editor.fields import ProseEditorField from parse_m2.models import AccountActivity, Metro2Event @@ -15,7 +16,17 @@ class Meta: id = models.CharField(max_length=200, primary_key=True) category = models.CharField(max_length=200, blank=True) description = models.TextField(blank=True) # short plain language description - long_description = models.TextField(blank=True) + long_description = ProseEditorField( + extensions={ + "Bold": True, + "Italic": True, + "BulletList": True, + "ListItem": True, + "Link": True, + }, + sanitize=True, # Built-in server side sanitization + blank=True, + ) fields_used = JSONField(encoder=DjangoJSONEncoder, null=True) fields_display = JSONField(encoder=DjangoJSONEncoder, null=True) crrg_reference = models.TextField(blank=True) diff --git a/django/requirements.txt b/django/requirements.txt index 6e0a081e..ea242509 100644 --- a/django/requirements.txt +++ b/django/requirements.txt @@ -12,6 +12,7 @@ gunicorn==25.3.0 whitenoise==6.12.0 smart-open[s3]==7.6.0 yappi==1.7.6 +django-prose-editor[sanitize] # Not required directly by Metro2; pinned to avoid CVEs certifi==2026.4.22 From 9709cc0227f7f4eb6c3064c0ea3ad6fedfa4979e Mon Sep 17 00:00:00 2001 From: Virginia Czosek Date: Mon, 8 Jun 2026 14:54:16 -0700 Subject: [PATCH 2/7] Display long description HTML on front end --- django/evaluate_m2/models.py | 4 + .../component/EvaluatorMetadata.cy.tsx | 145 ------------------ .../Evaluator/overview/EvaluatorOverview.tsx | 5 +- .../overview/components/LongDescription.tsx | 51 ------ 4 files changed, 7 insertions(+), 198 deletions(-) delete mode 100644 front-end/cypress/component/EvaluatorMetadata.cy.tsx delete mode 100644 front-end/src/pages/Evaluator/overview/components/LongDescription.tsx diff --git a/django/evaluate_m2/models.py b/django/evaluate_m2/models.py index 3bf7ae83..d6af97b8 100644 --- a/django/evaluate_m2/models.py +++ b/django/evaluate_m2/models.py @@ -21,8 +21,12 @@ class Meta: "Bold": True, "Italic": True, "BulletList": True, + "OrderedList": True, "ListItem": True, "Link": True, + "Heading": { + "levels": [4] + }, }, sanitize=True, # Built-in server side sanitization blank=True, diff --git a/front-end/cypress/component/EvaluatorMetadata.cy.tsx b/front-end/cypress/component/EvaluatorMetadata.cy.tsx deleted file mode 100644 index d58fd1e2..00000000 --- a/front-end/cypress/component/EvaluatorMetadata.cy.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import EvaluatorMetadataSection from '@src/pages/Evaluator/overview/components/Metadata' - -describe('EvaluatorMetadataSection.cy.tsx', () => { - it('should display all metadata fields', () => { - const metadata = { - id: 'test-eval', - description: 'Description of evaluator', - hits: 2222, - accounts_affected: 1111, - inconsistency_start: '1/1/24', - inconsistency_end: '1/1/25', - fields_used: ['acct_stat', 'acct_type'], - long_description: 'Long description of evaluator', - rationale: 'This evaluator checks for a mismatch between two fields.', - potential_harm: 'Description of potential harm.', - alternate_explanation: 'Description of alternate explanation.', - crrg_reference: 'Where to look in the CRRG.' - } - cy.mount() - cy.findByTestId('metadata-populated-fields') - .should('be.visible') - .within(() => { - cy.get('li').should('have.length', 4) - cy.get('li') - .eq(0) - .should('include.text', 'Rationale') - .and('include.text', metadata.rationale) - cy.get('li') - .eq(1) - .should('include.text', 'Potential harm') - .and('include.text', metadata.potential_harm) - cy.get('li') - .eq(2) - .should('include.text', 'Alternate explanation') - .and('include.text', metadata.alternate_explanation) - cy.get('li') - .eq(3) - .should('include.text', 'CRRG reference') - .and('include.text', metadata.crrg_reference) - }) - cy.findByTestId('metadata-contribute').should('not.exist') - }) - - it('should display partial metadata fields', () => { - const metadata = { - id: 'test-eval', - description: 'Description of evaluator', - hits: 2222, - accounts_affected: 1111, - inconsistency_start: '1/1/24', - inconsistency_end: '1/1/25', - fields_used: ['acct_stat', 'acct_type'], - long_description: 'Long description of evaluator', - rationale: 'This evaluator checks for a mismatch between two fields.', - crrg_reference: 'Where to look in the CRRG.' - } - cy.mount() - cy.findByTestId('metadata-populated-fields') - .should('be.visible') - .within(() => { - cy.get('li').should('have.length', 2) - cy.get('li') - .eq(0) - .should('include.text', 'Rationale') - .and('include.text', metadata.rationale) - cy.get('li') - .eq(1) - .should('include.text', 'CRRG reference') - .and('include.text', metadata.crrg_reference) - }) - cy.findByTestId('metadata-contribute').should('be.visible') - cy.findByTestId('metadata-empty-fields') - .should('be.visible') - .within(() => { - cy.get('li').should('have.length', 2) - cy.get('li').eq(0).should('include.text', 'Potential harm') - cy.get('li').eq(1).should('include.text', 'Alternate explanation') - }) - }) - - it('should display missing metadata fields', () => { - const metadata = { - id: 'test-eval', - description: 'Description of evaluator', - hits: 2222, - accounts_affected: 1111, - inconsistency_start: '1/1/24', - inconsistency_end: '1/1/25', - fields_used: ['acct_stat', 'acct_type'], - long_description: 'Long description of evaluator' - } - cy.mount() - cy.findByTestId('metadata-populated-fields').should('not.exist') - cy.findByTestId('metadata-contribute').should('be.visible') - cy.findByTestId('metadata-empty-fields') - .should('be.visible') - .within(() => { - cy.get('li').should('have.length', 4) - cy.get('li').eq(0).should('include.text', 'Rationale') - cy.get('li').eq(1).should('include.text', 'Potential harm') - cy.get('li').eq(2).should('include.text', 'Alternate explanation') - cy.get('li').eq(3).should('include.text', 'CRRG reference') - }) - }) - - it('should display contribute call to action', () => { - const metadata = { - id: 'test-eval', - description: 'Description of evaluator', - hits: 2222, - accounts_affected: 1111, - inconsistency_start: '1/1/24', - inconsistency_end: '1/1/25', - fields_used: ['acct_stat', 'acct_type'], - long_description: 'Long description of evaluator', - rationale: 'This evaluator checks for a mismatch between two fields.', - crrg_reference: 'Where to look in the CRRG.' - } - cy.mount() - cy.findByTestId('metadata-contribute').should('be.visible') - cy.findByTestId('metadata-cta').should('be.visible') - cy.findByTestId('metadata-cta__general').should('be.visible') - cy.findByTestId('metadata-cta__admin').should('be.visible') - }) - - it('should not show admin contribute call to action to non-admins', () => { - const metadata = { - id: 'test-eval', - description: 'Description of evaluator', - hits: 2222, - accounts_affected: 1111, - inconsistency_start: '1/1/24', - inconsistency_end: '1/1/25', - fields_used: ['acct_stat', 'acct_type'], - long_description: 'Long description of evaluator', - rationale: 'This evaluator checks for a mismatch between two fields.', - crrg_reference: 'Where to look in the CRRG.' - } - cy.mount() - cy.findByTestId('metadata-contribute').should('be.visible') - cy.findByTestId('metadata-cta').should('be.visible') - cy.findByTestId('metadata-cta__general').should('be.visible') - cy.findByTestId('metadata-cta__admin').should('not.exist') - }) -}) diff --git a/front-end/src/pages/Evaluator/overview/EvaluatorOverview.tsx b/front-end/src/pages/Evaluator/overview/EvaluatorOverview.tsx index 01e73fed..d49fb2f4 100644 --- a/front-end/src/pages/Evaluator/overview/EvaluatorOverview.tsx +++ b/front-end/src/pages/Evaluator/overview/EvaluatorOverview.tsx @@ -1,9 +1,9 @@ +import DOMPurify from 'dompurify' import Accordion from '@src/components/Accordion/Accordion' import type EvaluatorMetadata from '@src/types/EvaluatorMetadata' import type Event from '@src/types/Event' import type User from '@src/types/User' import type { ReactElement } from 'react' -import EvaluatorLongDescription from './components/LongDescription' import EvaluatorMetadataSection from './components/Metadata' import EvaluatorSummary from './components/Summary' @@ -33,7 +33,8 @@ export default function EvaluatorOverview({
- +
+
diff --git a/front-end/src/pages/Evaluator/overview/components/LongDescription.tsx b/front-end/src/pages/Evaluator/overview/components/LongDescription.tsx deleted file mode 100644 index 26cfe96d..00000000 --- a/front-end/src/pages/Evaluator/overview/components/LongDescription.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { ReactElement } from 'react' -import { Fragment } from 'react' - -/** - * EvaluatorLongDescription - * - * Long descriptions for evaluators are created and stored in an Excel spreadsheet - * where they are formatted for readability with things like bold subheadings. - * That formatting is mostly lost -- with the exception of line breaks -- - * when the spreadsheet is converted into a CSV for inport into the tool's database. - * - * Eventually the spreadsheet's contents may be maintained in a location - * where they can be styled with a WYSIWYG editor. For now, this component provides - * a workaround by taking a long description string that is only formatted with - * line breaks and restoring some of the formatting from the spreadsheet by: - * - splitting the string into segments at double line breaks - * - splitting the segements into lines at single line breaks - * - formatting the first line of a segment as an H4 if it does not contain a - * pseudo code symbol - * - formatting all other lines as paragraphs - * - */ - -interface LongDescriptionData { - content: string -} - -const pseudoCodeSymbols = [':', '<', '>', '=', '≠'] - -export default function EvaluatorLongDescription({ - content -}: LongDescriptionData): ReactElement { - return ( - <> - {content.split('\n\n').map((segment, segmentIndex) => ( - - {segment - .split('\n') - .map((line, lineIndex) => - lineIndex === 0 && - !pseudoCodeSymbols.some(char => line.includes(char)) ? ( -

{line}

- ) : ( -

{line}

- ) - )} -
- ))} - - ) -} From 49cbc2c8064fb5fec79bfdd3df46aedd21c68684 Mon Sep 17 00:00:00 2001 From: Virginia Czosek Date: Mon, 8 Jun 2026 15:17:28 -0700 Subject: [PATCH 3/7] Restore metadata test file. --- .../cypress/component/EvaluatorMetadata.tsx | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 front-end/cypress/component/EvaluatorMetadata.tsx diff --git a/front-end/cypress/component/EvaluatorMetadata.tsx b/front-end/cypress/component/EvaluatorMetadata.tsx new file mode 100644 index 00000000..d58fd1e2 --- /dev/null +++ b/front-end/cypress/component/EvaluatorMetadata.tsx @@ -0,0 +1,145 @@ +import EvaluatorMetadataSection from '@src/pages/Evaluator/overview/components/Metadata' + +describe('EvaluatorMetadataSection.cy.tsx', () => { + it('should display all metadata fields', () => { + const metadata = { + id: 'test-eval', + description: 'Description of evaluator', + hits: 2222, + accounts_affected: 1111, + inconsistency_start: '1/1/24', + inconsistency_end: '1/1/25', + fields_used: ['acct_stat', 'acct_type'], + long_description: 'Long description of evaluator', + rationale: 'This evaluator checks for a mismatch between two fields.', + potential_harm: 'Description of potential harm.', + alternate_explanation: 'Description of alternate explanation.', + crrg_reference: 'Where to look in the CRRG.' + } + cy.mount() + cy.findByTestId('metadata-populated-fields') + .should('be.visible') + .within(() => { + cy.get('li').should('have.length', 4) + cy.get('li') + .eq(0) + .should('include.text', 'Rationale') + .and('include.text', metadata.rationale) + cy.get('li') + .eq(1) + .should('include.text', 'Potential harm') + .and('include.text', metadata.potential_harm) + cy.get('li') + .eq(2) + .should('include.text', 'Alternate explanation') + .and('include.text', metadata.alternate_explanation) + cy.get('li') + .eq(3) + .should('include.text', 'CRRG reference') + .and('include.text', metadata.crrg_reference) + }) + cy.findByTestId('metadata-contribute').should('not.exist') + }) + + it('should display partial metadata fields', () => { + const metadata = { + id: 'test-eval', + description: 'Description of evaluator', + hits: 2222, + accounts_affected: 1111, + inconsistency_start: '1/1/24', + inconsistency_end: '1/1/25', + fields_used: ['acct_stat', 'acct_type'], + long_description: 'Long description of evaluator', + rationale: 'This evaluator checks for a mismatch between two fields.', + crrg_reference: 'Where to look in the CRRG.' + } + cy.mount() + cy.findByTestId('metadata-populated-fields') + .should('be.visible') + .within(() => { + cy.get('li').should('have.length', 2) + cy.get('li') + .eq(0) + .should('include.text', 'Rationale') + .and('include.text', metadata.rationale) + cy.get('li') + .eq(1) + .should('include.text', 'CRRG reference') + .and('include.text', metadata.crrg_reference) + }) + cy.findByTestId('metadata-contribute').should('be.visible') + cy.findByTestId('metadata-empty-fields') + .should('be.visible') + .within(() => { + cy.get('li').should('have.length', 2) + cy.get('li').eq(0).should('include.text', 'Potential harm') + cy.get('li').eq(1).should('include.text', 'Alternate explanation') + }) + }) + + it('should display missing metadata fields', () => { + const metadata = { + id: 'test-eval', + description: 'Description of evaluator', + hits: 2222, + accounts_affected: 1111, + inconsistency_start: '1/1/24', + inconsistency_end: '1/1/25', + fields_used: ['acct_stat', 'acct_type'], + long_description: 'Long description of evaluator' + } + cy.mount() + cy.findByTestId('metadata-populated-fields').should('not.exist') + cy.findByTestId('metadata-contribute').should('be.visible') + cy.findByTestId('metadata-empty-fields') + .should('be.visible') + .within(() => { + cy.get('li').should('have.length', 4) + cy.get('li').eq(0).should('include.text', 'Rationale') + cy.get('li').eq(1).should('include.text', 'Potential harm') + cy.get('li').eq(2).should('include.text', 'Alternate explanation') + cy.get('li').eq(3).should('include.text', 'CRRG reference') + }) + }) + + it('should display contribute call to action', () => { + const metadata = { + id: 'test-eval', + description: 'Description of evaluator', + hits: 2222, + accounts_affected: 1111, + inconsistency_start: '1/1/24', + inconsistency_end: '1/1/25', + fields_used: ['acct_stat', 'acct_type'], + long_description: 'Long description of evaluator', + rationale: 'This evaluator checks for a mismatch between two fields.', + crrg_reference: 'Where to look in the CRRG.' + } + cy.mount() + cy.findByTestId('metadata-contribute').should('be.visible') + cy.findByTestId('metadata-cta').should('be.visible') + cy.findByTestId('metadata-cta__general').should('be.visible') + cy.findByTestId('metadata-cta__admin').should('be.visible') + }) + + it('should not show admin contribute call to action to non-admins', () => { + const metadata = { + id: 'test-eval', + description: 'Description of evaluator', + hits: 2222, + accounts_affected: 1111, + inconsistency_start: '1/1/24', + inconsistency_end: '1/1/25', + fields_used: ['acct_stat', 'acct_type'], + long_description: 'Long description of evaluator', + rationale: 'This evaluator checks for a mismatch between two fields.', + crrg_reference: 'Where to look in the CRRG.' + } + cy.mount() + cy.findByTestId('metadata-contribute').should('be.visible') + cy.findByTestId('metadata-cta').should('be.visible') + cy.findByTestId('metadata-cta__general').should('be.visible') + cy.findByTestId('metadata-cta__admin').should('not.exist') + }) +}) From 5e3f599082cb1ea6f807ec4f4457f63abfaf37ff Mon Sep 17 00:00:00 2001 From: Virginia Czosek Date: Mon, 8 Jun 2026 15:18:27 -0700 Subject: [PATCH 4/7] Rename test file --- .../component/{EvaluatorMetadata.tsx => EvaluatorMetadata.cy.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename front-end/cypress/component/{EvaluatorMetadata.tsx => EvaluatorMetadata.cy.tsx} (100%) diff --git a/front-end/cypress/component/EvaluatorMetadata.tsx b/front-end/cypress/component/EvaluatorMetadata.cy.tsx similarity index 100% rename from front-end/cypress/component/EvaluatorMetadata.tsx rename to front-end/cypress/component/EvaluatorMetadata.cy.tsx From 8eb77e19c347f9325d11eb565a351236908416a5 Mon Sep 17 00:00:00 2001 From: Betsy Lorton Date: Wed, 24 Jun 2026 15:50:38 -0400 Subject: [PATCH 5/7] Fix linting --- django/evaluate_m2/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/django/evaluate_m2/models.py b/django/evaluate_m2/models.py index d6af97b8..77afe06f 100644 --- a/django/evaluate_m2/models.py +++ b/django/evaluate_m2/models.py @@ -2,6 +2,7 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.db.models import JSONField + from django_prose_editor.fields import ProseEditorField from parse_m2.models import AccountActivity, Metro2Event From 9849a0666767fd9160a3593c3aa81903bd0ad2e2 Mon Sep 17 00:00:00 2001 From: Betsy Lorton Date: Wed, 24 Jun 2026 15:54:52 -0400 Subject: [PATCH 6/7] Upgrade package to avoid CVE --- django/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/requirements.txt b/django/requirements.txt index ea242509..ec974422 100644 --- a/django/requirements.txt +++ b/django/requirements.txt @@ -18,6 +18,6 @@ django-prose-editor[sanitize] certifi==2026.4.22 cryptography==48.0.0 idna==3.15 -PyJWT==2.12.1 +PyJWT==2.13.0 requests==2.34.2 urllib3==2.7.0 From 3e12b855c1a93ef2b0ea5f4a20882cb966fec1a4 Mon Sep 17 00:00:00 2001 From: Betsy Lorton Date: Wed, 24 Jun 2026 15:57:24 -0400 Subject: [PATCH 7/7] Don't show richtext field in list view --- django/evaluate_m2/admin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/django/evaluate_m2/admin.py b/django/evaluate_m2/admin.py index 812960ec..205b2f12 100644 --- a/django/evaluate_m2/admin.py +++ b/django/evaluate_m2/admin.py @@ -19,8 +19,7 @@ class EvaluatorMetadataAdmin(admin.ModelAdmin): 'rationale', 'alternate_explanation', ] list_display = [ - 'id', 'category', 'description', 'long_description', - 'fields_used', 'fields_display', + 'id', 'category', 'description', 'fields_used', 'fields_display', ] def has_add_permission(self, request, obj=None):