Merge pull request #1743 from GSA/1677-user-story-view-table-of-all-jobs-sent-with-my-service

new activity page
This commit is contained in:
Carlo Costino
2024-08-01 15:00:38 -04:00
committed by GitHub
11 changed files with 455 additions and 7 deletions

View File

@@ -452,27 +452,43 @@ td.table-empty-message {
width: 25%;
overflow-wrap: anywhere;
}
td.jobid {
width: 5%;
}
td.template {
width: 20%;
width: 25%;
}
td.time-sent {
width: 20%;
width: 30%;
}
td.sender {
width: 15%;
width: 20%;
overflow-wrap: break-word;
}
td.count-of-recipients {
width: 5%;
}
td.report {
width: 5%;
text-align: center;
}
td.report img {
padding-top: 5px;
}
th {
padding: 0.5rem 1rem
}
td {
padding: 0.5rem 1rem
}
}
@media (max-width: 768px) {
.usa-table-container--scrollable-mobile {
margin: 0;
overflow-y:hidden;
}
}
.usa-table th[data-sortable][aria-sort=ascending], .usa-table th[data-sortable][aria-sort=descending] {
background-color: #a1d3ff;
}
#template-list {

View File

@@ -231,6 +231,11 @@ def naturaltime_without_indefinite_article(date):
)
def convert_time_unixtimestamp(date_string):
dt = datetime.fromisoformat(date_string)
return int(dt.timestamp())
def format_delta(date):
# This method assumes that date is in UTC
date = parse_naive_dt(date)

View File

@@ -3,6 +3,7 @@ from flask import Blueprint
main = Blueprint("main", __name__)
from app.main.views import ( # noqa isort:skip
activity,
add_service,
api_keys,
choose_account,

View File

@@ -0,0 +1,80 @@
from flask import abort, render_template, request, url_for
from app import current_service, job_api_client
from app.formatters import convert_time_unixtimestamp, get_time_left
from app.main import main
from app.utils.pagination import (
generate_next_dict,
generate_pagination_pages,
generate_previous_dict,
get_page_from_request,
)
from app.utils.user import user_has_permissions
@main.route("/activity/services/<uuid:service_id>")
@user_has_permissions()
def all_jobs_activity(service_id):
service_data_retention_days = 7
page = get_page_from_request()
jobs = job_api_client.get_page_of_jobs(service_id, page=page)
all_jobs_dict = generate_job_dict(jobs)
prev_page, next_page, pagination = handle_pagination(jobs, service_id, page)
return render_template(
"views/activity/all-activity.html",
all_jobs_dict=all_jobs_dict,
service_data_retention_days=service_data_retention_days,
next_page=next_page,
prev_page=prev_page,
pagination=pagination,
)
def handle_pagination(jobs, service_id, page):
if page is None:
abort(404, "Invalid page argument ({}).".format(request.args.get("page")))
prev_page = (
generate_previous_dict("main.all_jobs_activity", service_id, page)
if page > 1
else None
)
next_page = (
generate_next_dict("main.all_jobs_activity", service_id, page)
if jobs.get("links", {}).get("next")
else None
)
pagination = generate_pagination_pages(
jobs.get("total", {}), jobs.get("page_size", {}), page
)
return prev_page, next_page, pagination
def generate_job_dict(jobs):
return [
{
"job_id": job["id"],
"time_left": get_time_left(job["created_at"]),
"download_link": url_for(
".view_job_csv", service_id=current_service.id, job_id=job["id"]
),
"view_job_link": url_for(
".view_job", service_id=current_service.id, job_id=job["id"]
),
"created_at": job["created_at"],
"time_sent_data_value": convert_time_unixtimestamp(
job["processing_finished"]
if job["processing_finished"]
else (
job["processing_started"]
if job["processing_started"]
else job["created_at"]
)
),
"processing_finished": job["processing_finished"],
"processing_started": job["processing_started"],
"created_by": job["created_by"],
"template_name": job["template_name"],
}
for job in jobs["data"]
]

View File

@@ -153,6 +153,9 @@ class HeaderNavigation(Navigation):
class MainNavigation(Navigation):
mapping = {
"activity": {
"all_jobs_activity",
},
"dashboard": {
"conversation",
"inbox",

View File

@@ -8,6 +8,7 @@
{% if current_user.has_permissions() %}
{% if current_user.has_permissions('view_activity') %}
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('dashboard') }}" href="{{ url_for('.service_dashboard', service_id=current_service.id) }}">Dashboard</a></li>
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('activity') }}" href="{{ url_for('.all_jobs_activity', service_id=current_service.id) }}">Activity</a></li>
{% endif %}
{% if not current_user.has_permissions('view_activity') %}
<li class="usa-sidenav__item"><a class="{{ casework_navigation.is_selected('sent-messages') }}" href="{{ url_for('.view_notifications', service_id=current_service.id, status='sending,delivered,failed') }}">Sent messages</a></li>

View File

@@ -0,0 +1,126 @@
{% extends "withnav_template.html" %}
{% block service_page_title %}
All activity
{% endblock %}
{% set show_pagination %}
{% if prev_page or next_page %}
<nav aria-label="Pagination" class="usa-pagination">
<ul class="usa-pagination__list">
{% if prev_page %}
<li class="usa-pagination__item usa-pagination__arrow">
<a
href="{{prev_page['url']}}"
class="usa-pagination__link usa-pagination__previous-page"
aria-label="Previous page"
>
<img src="{{ url_for('static', filename='/img/usa-icons/navigate_before.svg') }}" alt="arrow">
<span class="usa-pagination__link-text">Previous</span></a
>
</li>
{% endif %}
{% if pagination %}
{% for page in pagination.pages %}
{% if page == pagination.current %}
<li class="usa-pagination__item usa-pagination__page-no">
<span class="usa-pagination__button usa-current" aria-label="Page {{ page }}" aria-current="true">
{{ page }}
</span>
</li>
{% else %}
<li class="usa-pagination__item">
<a class="usa-pagination__button" href="?page={{ page }}">
{{ page }}
</a>
</li>
{% endif %}
{% if page == 3 and pagination.last > 4 %}
<li class="usa-pagination__item usa-pagination__overflow" aria-label="ellipsis indicating non-visible pages">
<span></span>
</li>
{% endif %}
{% endfor %}
{% endif %}
{% if next_page %}
<li class="usa-pagination__item usa-pagination__arrow">
<a
href="{{ next_page['url'] }}"
class="usa-pagination__link usa-pagination__next-page"
aria-label="Next page"
>
<span class="usa-pagination__link-text">Next </span>
<img src="{{ url_for('static', filename='/img/usa-icons/navigate_next.svg') }}" alt="arrow">
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endset %}
{% block maincolumn_content %}
<div class="margin-bottom-8">
<h1 class="usa-sr-only">All activity</h1>
<h2 class="font-body-2xl line-height-sans-2 margin-0">All activity</h2>
<h2 class="margin-top-4 margin-bottom-1">Sent jobs</h2>
<div class="usa-table-container--scrollable-mobile">
<table class="usa-table usa-table--compact job-table">
<caption></caption>
<thead class="table-field-headings">
<tr>
<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">
<span>Template</span>
</th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
<span>Time sent</span>
</th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
<span>Sender</span>
</th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
<span>Report</span>
</th>
</tr>
</thead>
<tbody>
{% if all_jobs_dict %}
{% for job in all_jobs_dict %}
<tr class="table-row">
<td class="table-field jobid" scope="row" 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.time_sent_data_value}}" class="table-field time-sent">
{{ (job.processing_finished if job.processing_finished else job.processing_started
if job.processing_started else job.created_at)|format_datetime_table }}
</td>
<td class="table-field sender">{{ job.created_by.name }}</td>
<td class="table-field report">
{% if job.time_left != "Data no longer available" %}
<a href="{{ job.download_link }}"><img src="{{ url_for('static', filename='img/material-icons/file_download.svg') }}" alt="File Download Icon"></a>
{% elif job %}
<span>N/A</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr class="table-row">
<td class="table-empty-message" colspan="10">No batched job messages found (messages are kept for {{ service_data_retention_days }} days).</td>
</tr>
{% endif %}
</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>
</div>
{{show_pagination}}
</div>
{% endblock %}

View File

@@ -29,3 +29,15 @@ def generate_previous_next_dict(view, service_id, page, title, url_args):
"title": title,
"label": "page {}".format(page),
}
def generate_pagination_pages(total_items, page_size, current_page):
total_pages = (total_items + page_size - 1) // page_size
pagination = {"current": current_page, "pages": [], "last": total_pages}
if total_pages <= 9:
pagination["pages"] = list(range(1, total_pages + 1))
else:
start_page = max(1, min(current_page - 4, total_pages - 8))
end_page = min(start_page + 8, total_pages)
pagination["pages"] = list(range(start_page, end_page + 1))
return pagination

View File

@@ -0,0 +1,179 @@
from bs4 import BeautifulSoup
from app.utils.pagination import get_page_from_request
from tests.conftest import SERVICE_ONE_ID
MOCK_JOBS = {
"data": [
{
"archived": False,
"created_at": "2024-01-04T20:43:52+00:00",
"created_by": {
"id": "mocked_user_id",
"name": "mocked_user",
},
"id": "55b242b5-9f62-4271-aff7-039e9c320578",
"job_status": "finished",
"notification_count": 1,
"original_file_name": "mocked_file.csv",
"processing_finished": "2024-01-25T23:02:25+00:00",
"processing_started": "2024-01-25T23:02:24+00:00",
"scheduled_for": None,
"service": "21b3ee3d-1cb0-4666-bfa0-9c5ac26d3fe3",
"service_name": {"name": "Mock Texting Service"},
"statistics": [{"count": 1, "status": "sending"}],
"template": "6a456418-498c-4c86-b0cd-9403c14a216c",
"template_name": "Mock Template Name",
"template_type": "sms",
"template_version": 3,
"updated_at": "2024-01-25T23:02:25+00:00",
}
],
'links': {
'last': '/service/21b3ee3d-1cb0-4666-bfa0-9c5ac26d3fe3/job?page=3',
'next': '/service/21b3ee3d-1cb0-4666-bfa0-9c5ac26d3fe3/job?page=3',
'prev': '/service/21b3ee3d-1cb0-4666-bfa0-9c5ac26d3fe3/job?page=1'
},
'page_size': 50,
'total': 115
}
def test_all_activity(
client_request,
mocker,
):
current_page = get_page_from_request()
mock_get_page_of_jobs = mocker.patch(
"app.job_api_client.get_page_of_jobs", return_value=MOCK_JOBS
)
response = client_request.get_response(
"main.all_jobs_activity",
service_id=SERVICE_ONE_ID,
page=current_page,
)
assert response.status_code == 200, "Request failed"
assert response.data is not None, "Response data is None"
assert "All activity" in response.text
mock_get_page_of_jobs.assert_called_with(SERVICE_ONE_ID, page=current_page)
page = BeautifulSoup(response.data, 'html.parser')
table = page.find('table')
assert table is not None, "Table not found in the response"
headers = [th.get_text(strip=True) for th in table.find_all('th')]
expected_headers = ["Job ID#", "Template", "Time sent", "Sender", "Report"]
assert headers == expected_headers, f"Expected headers {expected_headers}, but got {headers}"
rows = table.find('tbody').find_all('tr', class_='table-row')
assert len(rows) == 1, "Expected one job row in the table"
job_row = rows[0]
cells = job_row.find_all('td')
assert len(cells) == 5, "Expected five columns in the job row"
job_id_cell = cells[0].find('a').get_text(strip=True)
assert job_id_cell == "55b242b5", f"Expected job ID '55b242b5', but got '{job_id_cell}'"
template_cell = cells[1].get_text(strip=True)
assert template_cell == "Mock Template Name", (
f"Expected template 'Mock Template Name', but got '{template_cell}'"
)
time_sent_cell = cells[2].get_text(strip=True)
assert time_sent_cell == "01-25-2024 at 06:02 PM", (
f"Expected time sent '01-25-2024 at 06:02 PM', but got '{time_sent_cell}'"
)
sender_cell = cells[3].get_text(strip=True)
assert sender_cell == "mocked_user", f"Expected sender 'mocked_user', but got '{sender_cell}'"
report_cell = cells[4].find('span').get_text(strip=True)
assert report_cell == "N/A", f"Expected report 'N/A', but got '{report_cell}'"
mock_get_page_of_jobs.assert_called_with(SERVICE_ONE_ID, page=current_page)
def test_all_activity_no_jobs(
client_request,
mocker
):
current_page = get_page_from_request()
mock_get_page_of_jobs = mocker.patch(
"app.job_api_client.get_page_of_jobs",
return_value={
"data": [],
'links': {
'last': '/service/21b3ee3d-1cb0-4666-bfa0-9c5ac26d3fe3/job?page=1',
'next': None,
'prev': None
},
'page_size': 50,
'total': 0
}
)
response = client_request.get_response(
"main.all_jobs_activity",
service_id=SERVICE_ONE_ID,
page=current_page,
)
assert response.status_code == 200, "Request failed"
page = BeautifulSoup(response.data, 'html.parser')
no_jobs_message_td = page.find('td', class_='table-empty-message')
assert no_jobs_message_td is not None, "No jobs message not found in the response"
expected_message = "No batched job messages found (messages are kept for 7 days)."
actual_message = no_jobs_message_td.get_text(strip=True)
assert expected_message == actual_message, (
f"Expected message '{expected_message}', but got '{actual_message}'"
)
mock_get_page_of_jobs.assert_called_with(SERVICE_ONE_ID, page=current_page)
def test_all_activity_pagination(client_request, mocker):
current_page = get_page_from_request()
mock_get_page_of_jobs = mocker.patch(
"app.job_api_client.get_page_of_jobs",
return_value={
"data": [
{
"id": f"job-{i}",
"created_at": "2024-01-25T23:02:25+00:00",
"created_by": {"name": "mocked_user"},
"processing_finished": "2024-01-25T23:02:25+00:00",
"processing_started": "2024-01-25T23:02:24+00:00",
"template_name": "Mock Template Name",
"original_file_name": "mocked_file.csv",
"notification_count": 1
} for i in range(1, 101)
],
'links': {
'last': '/service/21b3ee3d-1cb0-4666-bfa0-9c5ac26d3fe3/job?page=2',
'next': '/service/21b3ee3d-1cb0-4666-bfa0-9c5ac26d3fe3/job?page=2',
'prev': None
},
'page_size': 50,
'total': 100
}
)
response = client_request.get_response(
"main.all_jobs_activity",
service_id=SERVICE_ONE_ID,
page=current_page,
)
mock_get_page_of_jobs.assert_called_with(SERVICE_ONE_ID, page=current_page)
page = BeautifulSoup(response.data, 'html.parser')
pagination_controls = page.find_all('li', class_='usa-pagination__item')
assert pagination_controls, "Pagination controls not found in the response"
pagination_texts = [item.get_text(strip=True) for item in pagination_controls]
expected_pagination_texts = ['1', '2', 'Next']
assert pagination_texts == expected_pagination_texts, (
f"Expected pagination controls {expected_pagination_texts}, but got {pagination_texts}"
)

View File

@@ -25,6 +25,7 @@ EXCLUDED_ENDPOINTS = tuple(
"add_organization",
"add_service",
"add_service_template",
"all_jobs_activity",
"api_callbacks",
"api_documentation",
"api_integration",
@@ -400,6 +401,7 @@ def test_navigation_urls(
assert [a["href"] for a in page.select(".nav a")] == [
"/services/{}/templates".format(SERVICE_ONE_ID),
"/services/{}".format(SERVICE_ONE_ID),
"/activity/services/{}".format(SERVICE_ONE_ID),
# "/services/{}/usage".format(SERVICE_ONE_ID),
# "/services/{}/users".format(SERVICE_ONE_ID),
# "/services/{}/service-settings".format(SERVICE_ONE_ID),

View File

@@ -1,4 +1,10 @@
from app.utils.pagination import generate_next_dict, generate_previous_dict
import pytest
from app.utils.pagination import (
generate_next_dict,
generate_pagination_pages,
generate_previous_dict,
)
def test_generate_previous_dict(client_request):
@@ -20,3 +26,20 @@ def test_generate_previous_next_dict_adds_other_url_args(client_request):
"main.view_notifications", "foo", 2, {"message_type": "blah"}
)
assert "notifications/blah" in result["url"]
@pytest.mark.parametrize(
("total_items", "page_size", "current_page", "expected"),
[
(100, 50, 1, {"current": 1, "pages": [1, 2], "last": 2}),
(450, 50, 1, {"current": 1, "pages": [1, 2, 3, 4, 5, 6, 7, 8, 9], "last": 9}),
(500, 50, 1, {"current": 1, "pages": [1, 2, 3, 4, 5, 6, 7, 8, 9], "last": 10}),
(500, 50, 5, {"current": 5, "pages": [1, 2, 3, 4, 5, 6, 7, 8, 9], "last": 10}),
(500, 50, 6, {"current": 6, "pages": [2, 3, 4, 5, 6, 7, 8, 9, 10], "last": 10}),
(500, 50, 10, {"current": 10, "pages": [2, 3, 4, 5, 6, 7, 8, 9, 10], "last": 10}),
(950, 50, 15, {"current": 15, "pages": [11, 12, 13, 14, 15, 16, 17, 18, 19], "last": 19}),
],
)
def test_generate_pagination_pages(total_items, page_size, current_page, expected):
result = generate_pagination_pages(total_items, page_size, current_page)
assert result == expected