mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-11 09:28:27 -04:00
Merge branch 'main' into 2813-add-quick-filters
This commit is contained in:
@@ -629,10 +629,10 @@
|
||||
"filename": "tests/conftest.py",
|
||||
"hashed_secret": "f8377c90fcfd699f0ddbdcb30c2c9183d2d933ea",
|
||||
"is_verified": false,
|
||||
"line_number": 3267,
|
||||
"line_number": 3279,
|
||||
"is_secret": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2025-07-31T21:07:14Z"
|
||||
"generated_at": "2025-08-07T18:05:06Z"
|
||||
}
|
||||
|
||||
3
.github/workflows/checks.yml
vendored
3
.github/workflows/checks.yml
vendored
@@ -101,7 +101,8 @@ jobs:
|
||||
# above make e2e-test
|
||||
|
||||
run: |
|
||||
make run-flask-and-wait > admin-server.log 2>&1 &
|
||||
make run-flask > admin-server.log 2>&1 &
|
||||
make wait-for-flask
|
||||
tail -f admin-server.log &
|
||||
make e2e-test
|
||||
|
||||
|
||||
6
Makefile
6
Makefile
@@ -44,6 +44,12 @@ watch-frontend: ## Build frontend and watch for changes
|
||||
run-flask: ## Run flask
|
||||
poetry run newrelic-admin run-program flask run -p 6012 --host=0.0.0.0
|
||||
|
||||
.PHONY: wait-for-flask
|
||||
wait-for-flask:
|
||||
@echo "Waiting for Flask to start..."
|
||||
@timeout 30 bash -c 'until curl -sf http://localhost:6012 > /dev/null 2>&1; do sleep 1; done'
|
||||
@echo "Flask is ready!"
|
||||
|
||||
.PHONY: run-flask-and-wait
|
||||
run-flask-and-wait:
|
||||
@make run-flask &
|
||||
|
||||
@@ -295,24 +295,28 @@
|
||||
liveRegion.textContent = `Data updated for ${selectedText} - last 7 days`;
|
||||
|
||||
const tableHeading = document.querySelector('#tableActivity h2');
|
||||
const senderColumns = document.querySelectorAll('.sender-column');
|
||||
const senderElements = [
|
||||
document.querySelector('[data-column="sender"]'),
|
||||
...document.querySelectorAll('[data-sender]')
|
||||
];
|
||||
const allRows = document.querySelectorAll('#activity-table tbody tr');
|
||||
const caption = document.querySelector('#activity-table caption');
|
||||
const table = document.getElementById('activity-table');
|
||||
const caption = table.querySelector('caption');
|
||||
|
||||
if (selectedValue === 'individual') {
|
||||
|
||||
tableHeading.textContent = 'My activity';
|
||||
caption.textContent = `Table showing the sent jobs for ${currentUserName}`;
|
||||
|
||||
senderColumns.forEach(col => {
|
||||
col.style.display = 'none';
|
||||
senderElements.forEach(el => {
|
||||
if (el) el.style.display = 'none';
|
||||
});
|
||||
|
||||
allRows.forEach(row => row.style.display = 'none');
|
||||
|
||||
const userRows = Array.from(allRows).filter(row => {
|
||||
const senderCell = row.querySelector('.sender-column');
|
||||
const rowSender = senderCell ? senderCell.textContent.trim() : '';
|
||||
const senderCell = row.querySelector('[data-sender]');
|
||||
const rowSender = senderCell ? senderCell.dataset.sender : '';
|
||||
return rowSender === currentUserName;
|
||||
});
|
||||
|
||||
@@ -333,8 +337,8 @@
|
||||
tableHeading.textContent = 'Service activity';
|
||||
caption.textContent = `Table showing the sent jobs for service`;
|
||||
|
||||
senderColumns.forEach(col => {
|
||||
col.style.display = '';
|
||||
senderElements.forEach(el => {
|
||||
if (el) el.style.display = '';
|
||||
});
|
||||
|
||||
allRows.forEach((row, index) => {
|
||||
|
||||
@@ -492,44 +492,12 @@ td.table-empty-message {
|
||||
.job-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
td.file-name {
|
||||
width: 25%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
td.jobid {
|
||||
width: 5%;
|
||||
}
|
||||
td.template {
|
||||
width: 25%;
|
||||
}
|
||||
td.time-sent {
|
||||
width: 15%;
|
||||
}
|
||||
td.sender {
|
||||
width: 20%;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
td.count-of-recipients {
|
||||
width: 5%;
|
||||
}
|
||||
td.report {
|
||||
width: 2%;
|
||||
text-align: center;
|
||||
}
|
||||
td.delivered {
|
||||
width: 2%;
|
||||
text-align: center;
|
||||
}
|
||||
td.failed {
|
||||
width: 2%;
|
||||
text-align: center;
|
||||
}
|
||||
td.report img {
|
||||
padding-top: 5px;
|
||||
}
|
||||
th {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
td {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -73,6 +73,7 @@ def all_jobs_activity(service_id):
|
||||
next_page=next_page,
|
||||
prev_page=prev_page,
|
||||
pagination=pagination,
|
||||
total_jobs=jobs.get("total", 0),
|
||||
**download_availability,
|
||||
download_link_one_day=url_for(
|
||||
".download_notifications_csv",
|
||||
|
||||
@@ -51,6 +51,11 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% if pagination and total_jobs %}
|
||||
<p class="text-center font-body-sm">
|
||||
Page <span class="text-bold">{{ pagination.current }}</span> of <span class="text-bold">{{ pagination.last }}</span> (<span>{{ total_jobs }}</span> total jobs)
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endset %}
|
||||
{% block maincolumn_content %}
|
||||
@@ -88,7 +93,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usa-table-container--scrollable-mobile">
|
||||
<div class="usa-table-container--scrollable-mobile table-overflow-x-auto">
|
||||
<table class="usa-table usa-table--compact job-table">
|
||||
<caption class="usa-sr-only">Table showing all sent jobs for this service</caption>
|
||||
<thead class="table-field-headings">
|
||||
@@ -96,23 +101,20 @@
|
||||
<th scope="col" role="columnheader" class="table-field-heading-first" id="jobId">
|
||||
<span>Job ID#</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<th scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Template</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<th scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Started</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<th scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Sender</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<th scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Report</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Delivered</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Failed</span>
|
||||
<th scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Status</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -120,28 +122,34 @@
|
||||
{% if all_jobs_dict %}
|
||||
{% for job in all_jobs_dict %}
|
||||
<tr class="table-row">
|
||||
<td class="table-field jobid" role="rowheader">
|
||||
<td class="table-field width-5" role="rowheader">
|
||||
<a class="usa-link" href="{{ job.view_job_link }}">
|
||||
{{ job.job_id[:8] if job.job_id else 'Manually entered number' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="table-field template">{{ job.template_name }}</td>
|
||||
<td data-sort-value="{{ job.activity_time | convert_time_unixtimestamp }}" class="table-field time-sent">
|
||||
<td class="table-field width-16">{{ job.template_name }}</td>
|
||||
<td data-sort-value="{{ job.activity_time | convert_time_unixtimestamp }}" class="table-field width-18">
|
||||
{{ job.activity_time|format_datetime_table }}
|
||||
</td>
|
||||
<td class="table-field sender">{{ job.created_by.name }}</td>
|
||||
<td class="table-field report">
|
||||
<td class="table-field width-30 ">{{ job.created_by.name }}</td>
|
||||
<td class="text-center table-field width-2">
|
||||
{% if job.can_download %}
|
||||
<a href="{{ job.download_link }}">
|
||||
<img src="{{ url_for('static', filename='img/material-icons/file_download.svg') }}" alt="">
|
||||
<img src="{{ url_for('static', filename='img/material-icons/file_download.svg') }}" alt="" class="padding-top-05">
|
||||
<span class="usa-sr-only">Download report link</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<span>N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="table-field delivered">{{ job.delivered_count if job.delivered_count is not none else '0' }}</td>
|
||||
<td class="table-field failed">{{ job.failed_count if job.failed_count is not none else '0' }}</td>
|
||||
<td class="table-field width-15">
|
||||
<span class="bg-base-lighter padding-x-05 padding-y-0 font-body-3xs text-bold radius-sm margin-right-05 display-inline-block text-nowrap">
|
||||
{{ job.delivered_count if job.delivered_count is not none else '0' }} delivered
|
||||
</span>
|
||||
<span class="bg-base-lighter padding-x-05 padding-y-0 font-body-3xs text-bold radius-sm display-inline-block text-nowrap">
|
||||
{{ job.failed_count if job.failed_count is not none else '0' }} failed
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
@@ -152,7 +160,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="usa-sr-only usa-table__announcement-region" aria-live="polite"></div>
|
||||
<p><b>Note: </b>Report data is only available for 7 days after your message has been sent</p>
|
||||
<p class="font-body-sm"><b>Note: </b>Report data is only available for 7 days after your message has been sent</p>
|
||||
</div>
|
||||
{{show_pagination}}
|
||||
{% if current_user.has_permissions(ServicePermission.VIEW_ACTIVITY) %}
|
||||
|
||||
@@ -22,15 +22,15 @@
|
||||
<div id="tableActivity" class="table-overflow-x-auto">
|
||||
<h2 id="table-heading" class="margin-top-4 margin-bottom-1">Service activity</h2>
|
||||
|
||||
<table class="usa-table job-table" id="activity-table">
|
||||
<table class="usa-table usa-table--compact job-table" id="activity-table">
|
||||
<caption class="usa-sr-only">Table showing the sent jobs for {{current_service.name}}</caption>
|
||||
|
||||
<thead class="table-field-headings">
|
||||
<tr>
|
||||
<th scope="col" class="table-field-heading-first" id="jobId">Job ID#</th>
|
||||
<th data-sortable scope="col" class="table-field-heading" scope="col">Template</th>
|
||||
<th data-sortable scope="col" class="table-field-heading">Job status</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading sender-column">Sender
|
||||
<th data-sortable scope="col" class="table-field-heading width-30">Job status</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading" data-column="sender">Sender
|
||||
</th>
|
||||
<th data-sortable scope="col" class="table-field-heading"># of Recipients</th>
|
||||
</tr>
|
||||
@@ -39,13 +39,13 @@
|
||||
{% if jobs %}
|
||||
{% for job in jobs %}
|
||||
<tr id="{{ job.id }}">
|
||||
<td class="table-field jobid" role="rowheader">
|
||||
<td class="table-field" role="rowheader">
|
||||
<a class="usa-link" href="{{ url_for('.view_job', service_id=current_service.id, job_id=job.id )}}">
|
||||
{{ job.id[:8] if job.id else 'Manually entered number' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="table-field template">{{ job.template_name }}</td>
|
||||
<td class="table-field time-sent">
|
||||
<td class="table-field width-35">{{ job.template_name }}</td>
|
||||
<td class="table-field width-20">
|
||||
{% if not job.finished_processing %}
|
||||
{% if job.scheduled_for%}
|
||||
Scheduled for {{ job.scheduled_for|format_datetime_table }}
|
||||
@@ -58,8 +58,8 @@
|
||||
Sent on {{ job.processing_started|format_datetime_table }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="table-field sender sender-column">{{ job.created_by.name }}</td>
|
||||
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
|
||||
<td class="table-field" data-sender="{{ job.created_by.name }}">{{ job.created_by.name }}</td>
|
||||
<td class="table-field width-5">{{ job.notification_count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 531 KiB |
8
poetry.lock
generated
8
poetry.lock
generated
@@ -339,14 +339,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.7.14"
|
||||
version = "2025.8.3"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"},
|
||||
{file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"},
|
||||
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
|
||||
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4223,4 +4223,4 @@ cffi = ["cffi (>=1.11)"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12.9"
|
||||
content-hash = "9c4815ed8afb0be6324db1a3a04aaaff93cc697af6be0b9d5bf272bec2f3aee8"
|
||||
content-hash = "13f71f1bead8fec7d973a07f47da9a423f15f642eb0309212a019bb8865651f1"
|
||||
|
||||
@@ -59,7 +59,7 @@ regex = "^2025.7.34"
|
||||
s3transfer = "^0.10.2"
|
||||
shapely = "^2.1.1"
|
||||
smartypants = "^2.0.1"
|
||||
certifi = "^2025.7.14"
|
||||
certifi = "^2025.8.3"
|
||||
charset-normalizer = "^3.4.2"
|
||||
click = "^8.2.1"
|
||||
idna = "^3.7"
|
||||
|
||||
@@ -78,8 +78,7 @@ def test_all_activity(
|
||||
"Started",
|
||||
"Sender",
|
||||
"Report",
|
||||
"Delivered",
|
||||
"Failed",
|
||||
"Status",
|
||||
]
|
||||
|
||||
assert (
|
||||
@@ -91,7 +90,7 @@ def test_all_activity(
|
||||
|
||||
job_row = rows[0]
|
||||
cells = job_row.find_all("td")
|
||||
assert len(cells) == 7, "Expected five columns in the job row"
|
||||
assert len(cells) == 6, "Expected six columns in the job row"
|
||||
|
||||
job_id_cell = cells[0].find("a").get_text(strip=True)
|
||||
|
||||
@@ -114,13 +113,9 @@ def test_all_activity(
|
||||
report_cell = cells[4].find("span").get_text(strip=True)
|
||||
assert report_cell == "N/A", f"Expected report 'N/A', but got '{report_cell}'"
|
||||
|
||||
delivered_cell = cells[5].get_text(strip=True)
|
||||
assert (
|
||||
delivered_cell == "1"
|
||||
), f"Expected delivered count '1', but got '{delivered_cell}'"
|
||||
|
||||
failed_cell = cells[6].get_text(strip=True)
|
||||
assert failed_cell == "5", f"Expected failed count '5', but got '{failed_cell}'"
|
||||
status_cell = cells[5].get_text(strip=True)
|
||||
assert "1 delivered" in status_cell, f"Expected status to contain '1 delivered', but got '{status_cell}'"
|
||||
assert "5 failed" in status_cell, f"Expected status to contain '5 failed', but got '{status_cell}'"
|
||||
|
||||
|
||||
def test_all_activity_no_jobs(client_request, mocker):
|
||||
|
||||
@@ -556,6 +556,18 @@ def mock_update_service_raise_httperror_duplicate_name(mocker):
|
||||
return mocker.patch("app.service_api_client.update_service", side_effect=_update)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_e2e_mode_for_unit_tests(monkeypatch, request):
|
||||
"""
|
||||
Disable E2E test mode for all unit tests to prevent unmocked API calls.
|
||||
E2E tests are run in a separate job and should not interfere with unit tests.
|
||||
"""
|
||||
# Only disable if unit tests are not being run
|
||||
if "end_to_end" not in request.node.nodeid:
|
||||
monkeypatch.delenv("NOTIFY_E2E_TEST_EMAIL", raising=False)
|
||||
monkeypatch.delenv("NOTIFY_E2E_TEST_PASSWORD", raising=False)
|
||||
|
||||
|
||||
SERVICE_ONE_ID = "596364a0-858e-42c8-9062-a8fe822260eb"
|
||||
SERVICE_TWO_ID = "147ad62a-2951-4fa1-9ca0-093cd1a52c52"
|
||||
ORGANISATION_ID = "c011fa40-4cbe-4524-b415-dde2f421bd9c"
|
||||
|
||||
@@ -239,14 +239,19 @@ test('handleDropdownChange updates DOM for individual selection', () => {
|
||||
<h2 id="table-heading"></h2>
|
||||
<table id="activity-table">
|
||||
<caption id="caption"></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-column="sender">Sender</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td class="sender-column">Test User</td></tr>
|
||||
<tr><td class="sender-column">Other User</td></tr>
|
||||
<tr><td class="sender-column">Test User</td></tr>
|
||||
<tr><td class="sender-column">Test User</td></tr>
|
||||
<tr><td class="sender-column">Other User</td></tr>
|
||||
<tr><td class="sender-column">Test User</td></tr>
|
||||
<tr><td class="sender-column">Test User</td></tr>
|
||||
<tr><td data-sender="Test User">Test User</td></tr>
|
||||
<tr><td data-sender="Other User">Other User</td></tr>
|
||||
<tr><td data-sender="Test User">Test User</td></tr>
|
||||
<tr><td data-sender="Test User">Test User</td></tr>
|
||||
<tr><td data-sender="Other User">Other User</td></tr>
|
||||
<tr><td data-sender="Test User">Test User</td></tr>
|
||||
<tr><td data-sender="Test User">Test User</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -269,15 +274,18 @@ test('handleDropdownChange updates DOM for individual selection', () => {
|
||||
expect(document.getElementById('table-heading').textContent).toBe('My activity');
|
||||
expect(document.getElementById('caption').textContent).toContain('Test User');
|
||||
|
||||
document.querySelectorAll('.sender-column').forEach(col => {
|
||||
expect(col.style.display).toBe('none');
|
||||
const senderHeader = document.querySelector('[data-column="sender"]');
|
||||
expect(senderHeader.style.display).toBe('none');
|
||||
|
||||
document.querySelectorAll('[data-sender]').forEach(cell => {
|
||||
expect(cell.style.display).toBe('none');
|
||||
});
|
||||
|
||||
const rows = Array.from(document.querySelectorAll('#activity-table tbody tr'));
|
||||
const visibleRows = rows.filter(row => row.style.display !== 'none');
|
||||
expect(visibleRows.length).toBeLessThanOrEqual(5);
|
||||
visibleRows.forEach(row => {
|
||||
const sender = row.querySelector('.sender-column').textContent.trim();
|
||||
const sender = row.querySelector('[data-sender]').dataset.sender;
|
||||
expect(sender).toBe('Test User');
|
||||
});
|
||||
|
||||
@@ -295,10 +303,15 @@ test('handleDropdownChange shows empty message when user has no jobs', () => {
|
||||
<h2 id="table-heading"></h2>
|
||||
<table id="activity-table">
|
||||
<caption id="caption"></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-column="sender">Sender</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td class="sender-column">Other User</td></tr>
|
||||
<tr><td class="sender-column">Another User</td></tr>
|
||||
<tr><td class="sender-column">Different User</td></tr>
|
||||
<tr><td data-sender="Other User">Other User</td></tr>
|
||||
<tr><td data-sender="Another User">Another User</td></tr>
|
||||
<tr><td data-sender="Different User">Different User</td></tr>
|
||||
<tr class="table-row">
|
||||
<td class="table-empty-message" colspan="10">No batched job messages found (messages are kept for 7 days).</td>
|
||||
</tr>
|
||||
@@ -324,8 +337,11 @@ test('handleDropdownChange shows empty message when user has no jobs', () => {
|
||||
expect(document.getElementById('table-heading').textContent).toBe('My activity');
|
||||
expect(document.getElementById('caption').textContent).toContain('Test User');
|
||||
|
||||
document.querySelectorAll('.sender-column').forEach(col => {
|
||||
expect(col.style.display).toBe('none');
|
||||
const senderHeader = document.querySelector('[data-column="sender"]');
|
||||
expect(senderHeader.style.display).toBe('none');
|
||||
|
||||
document.querySelectorAll('[data-sender]').forEach(cell => {
|
||||
expect(cell.style.display).toBe('none');
|
||||
});
|
||||
|
||||
const emptyMessageRow = document.querySelector('.table-empty-message').closest('tr');
|
||||
|
||||
4
urls.js
4
urls.js
@@ -67,6 +67,10 @@ const routes = {
|
||||
label: 'Team Members',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/users',
|
||||
},
|
||||
{
|
||||
label: 'All Activity',
|
||||
path: '/activity/services/9c765540-266e-474e-b6bb-8e2e0e32b781',
|
||||
},
|
||||
{
|
||||
label: 'Invite User',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/users/invite',
|
||||
|
||||
Reference in New Issue
Block a user