mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-24 16:24:08 -04:00
Merge branch 'main' of https://github.com/GSA/notifications-admin into 1669-toggle-between-recent-activity-and-all-activity
This commit is contained in:
@@ -414,6 +414,30 @@ td.table-empty-message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-status-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
|
||||||
|
thead tr th {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead,
|
||||||
|
tbody,
|
||||||
|
tr {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:first-child,
|
||||||
|
td:first-child {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(2),R
|
||||||
|
td:nth-child(2) {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.usage-table {
|
.usage-table {
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -433,27 +457,43 @@ td.table-empty-message {
|
|||||||
width: 25%;
|
width: 25%;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
td.jobid {
|
||||||
|
width: 5%;
|
||||||
|
}
|
||||||
td.template {
|
td.template {
|
||||||
width: 20%;
|
width: 25%;
|
||||||
}
|
}
|
||||||
td.time-sent {
|
td.time-sent {
|
||||||
width: 20%;
|
width: 30%;
|
||||||
}
|
}
|
||||||
td.sender {
|
td.sender {
|
||||||
width: 15%;
|
width: 20%;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
td.count-of-recipients {
|
td.count-of-recipients {
|
||||||
width: 5%;
|
width: 5%;
|
||||||
}
|
}
|
||||||
td.report {
|
td.report {
|
||||||
width: 5%;
|
width: 5%;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
td.report img {
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
th {
|
th {
|
||||||
padding: 0.5rem 1rem
|
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 {
|
#template-list {
|
||||||
@@ -468,6 +508,10 @@ td.table-empty-message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usa-prose > p.max-width-full {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
|
|||||||
@@ -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):
|
def format_delta(date):
|
||||||
# This method assumes that date is in UTC
|
# This method assumes that date is in UTC
|
||||||
date = parse_naive_dt(date)
|
date = parse_naive_dt(date)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from flask import Blueprint
|
|||||||
main = Blueprint("main", __name__)
|
main = Blueprint("main", __name__)
|
||||||
|
|
||||||
from app.main.views import ( # noqa isort:skip
|
from app.main.views import ( # noqa isort:skip
|
||||||
|
activity,
|
||||||
add_service,
|
add_service,
|
||||||
api_keys,
|
api_keys,
|
||||||
choose_account,
|
choose_account,
|
||||||
|
|||||||
80
app/main/views/activity.py
Normal file
80
app/main/views/activity.py
Normal 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"]
|
||||||
|
]
|
||||||
@@ -153,6 +153,9 @@ class HeaderNavigation(Navigation):
|
|||||||
|
|
||||||
class MainNavigation(Navigation):
|
class MainNavigation(Navigation):
|
||||||
mapping = {
|
mapping = {
|
||||||
|
"activity": {
|
||||||
|
"all_jobs_activity",
|
||||||
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"conversation",
|
"conversation",
|
||||||
"inbox",
|
"inbox",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
from app.extensions import redis_client
|
from app.extensions import redis_client
|
||||||
from app.notify_client import NotifyAdminAPIClient, _attach_current_user, cache
|
from app.notify_client import NotifyAdminAPIClient, _attach_current_user, cache
|
||||||
from app.utils.csv import get_user_preferred_timezone
|
from app.utils.csv import get_user_preferred_timezone
|
||||||
@@ -27,9 +29,7 @@ class JobApiClient(NotifyAdminAPIClient):
|
|||||||
|
|
||||||
def get_job(self, service_id, job_id):
|
def get_job(self, service_id, job_id):
|
||||||
params = {}
|
params = {}
|
||||||
job = self.get(
|
job = self.get(url=f"/service/{service_id}/job/{job_id}", params=params)
|
||||||
url="/service/{}/job/{}".format(service_id, job_id), params=params
|
|
||||||
)
|
|
||||||
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
@@ -40,13 +40,16 @@ class JobApiClient(NotifyAdminAPIClient):
|
|||||||
if statuses is not None:
|
if statuses is not None:
|
||||||
params["statuses"] = ",".join(statuses)
|
params["statuses"] = ",".join(statuses)
|
||||||
|
|
||||||
return self.get(url="/service/{}/job".format(service_id), params=params)
|
job = self.get(url=f"/service/{service_id}/job", params=params)
|
||||||
|
from pprint import pformat
|
||||||
|
current_app.logger.info(pformat(job))
|
||||||
|
return job
|
||||||
|
|
||||||
def get_uploads(self, service_id, limit_days=None, page=1):
|
def get_uploads(self, service_id, limit_days=None, page=1):
|
||||||
params = {"page": page}
|
params = {"page": page}
|
||||||
if limit_days is not None:
|
if limit_days is not None:
|
||||||
params["limit_days"] = limit_days
|
params["limit_days"] = limit_days
|
||||||
return self.get(url="/service/{}/upload".format(service_id), params=params)
|
return self.get(url=f"/service/{service_id}/upload", params=params)
|
||||||
|
|
||||||
def has_sent_previously(
|
def has_sent_previously(
|
||||||
self, service_id, template_id, template_version, original_file_name
|
self, service_id, template_id, template_version, original_file_name
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{% if current_user.has_permissions() %}
|
{% if current_user.has_permissions() %}
|
||||||
{% if current_user.has_permissions('view_activity') %}
|
{% 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('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 %}
|
{% endif %}
|
||||||
{% if not current_user.has_permissions('view_activity') %}
|
{% 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>
|
<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>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
{% if notifications %}
|
{% if notifications %}
|
||||||
<div class="dashboard-table table-overflow-x-auto">
|
<div class="dashboard-table job-status-table table-overflow-x-auto">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if job.still_processing %}
|
{% if job.still_processing %}
|
||||||
<p class="bottom-gutter hint">
|
<p class="bottom-gutter hint">
|
||||||
@@ -40,15 +40,16 @@
|
|||||||
notifications,
|
notifications,
|
||||||
caption=uploaded_file_name,
|
caption=uploaded_file_name,
|
||||||
caption_visible=False,
|
caption_visible=False,
|
||||||
|
border_visible=True,
|
||||||
empty_message='No messages to show yet…' if job.awaiting_processing_or_recently_processed else 'These messages have been deleted because they were sent more than {} days ago'.format(service_data_retention_days),
|
empty_message='No messages to show yet…' if job.awaiting_processing_or_recently_processed else 'These messages have been deleted because they were sent more than {} days ago'.format(service_data_retention_days),
|
||||||
field_headings=[
|
field_headings=[
|
||||||
'Recipient',
|
'Recipient',
|
||||||
'Status'
|
'Message status'
|
||||||
],
|
],
|
||||||
field_headings_visible=False
|
field_headings_visible=False
|
||||||
) %}
|
) %}
|
||||||
{% call row_heading() %}
|
{% call row_heading() %}
|
||||||
<a class="usa-link file-list-filename" href="{{ url_for('.view_notification', service_id=current_service.id, notification_id=item.id, from_job=job.id) }}">{{ item.to }}</a>
|
<a class="usa-link file-list-filename" href="{{ url_for('.view_notification', service_id=current_service.id, notification_id=item.id, from_job=job.id) }}">{{ item.to | format_phone_number_human_readable }}</a>
|
||||||
<p class="file-list-hint">
|
<p class="file-list-hint">
|
||||||
{{ item.preview_of_content }}
|
{{ item.preview_of_content }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
126
app/templates/views/activity/all-activity.html
Normal file
126
app/templates/views/activity/all-activity.html
Normal 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 %}
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="notification-status">
|
<p class="notification-status max-width-full">
|
||||||
Messages will remain in pending state until carrier status is received, typically 5 minutes.
|
Messages are sent immediately to the cell phone carrier, but will remain in "pending" status until we hear back from the carrier they have received it and attempted deliver. More information on <a class="usa-link" href="{{ url_for('main.message_status') }}">delivery status</a>.
|
||||||
</p>
|
</p>
|
||||||
{% if not job.processing_finished %}
|
{% if not job.processing_finished %}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -29,3 +29,15 @@ def generate_previous_next_dict(view, service_id, page, title, url_args):
|
|||||||
"title": title,
|
"title": title,
|
||||||
"label": "page {}".format(page),
|
"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
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ plugins.uglify = require('gulp-uglify');
|
|||||||
const paths = {
|
const paths = {
|
||||||
src: 'app/assets/',
|
src: 'app/assets/',
|
||||||
dist: 'app/static/',
|
dist: 'app/static/',
|
||||||
templates: 'app/templates/',
|
|
||||||
npm: 'node_modules/',
|
npm: 'node_modules/',
|
||||||
toolkit: 'node_modules/govuk_frontend_toolkit/',
|
toolkit: 'node_modules/govuk_frontend_toolkit/',
|
||||||
govuk_frontend: 'node_modules/govuk-frontend/'
|
govuk_frontend: 'node_modules/govuk-frontend/'
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from collections import namedtuple
|
|||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from govuk_bank_holidays.bank_holidays import BankHolidays
|
|
||||||
|
|
||||||
from notifications_utils.countries.data import Postage
|
from notifications_utils.countries.data import Postage
|
||||||
from notifications_utils.timezones import utc_string_to_aware_gmt_datetime
|
from notifications_utils.timezones import utc_string_to_aware_gmt_datetime
|
||||||
@@ -18,16 +17,6 @@ CANCELLABLE_JOB_LETTER_STATUSES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
non_working_days_dvla = BankHolidays(
|
|
||||||
use_cached_holidays=True,
|
|
||||||
weekend=(5, 6),
|
|
||||||
)
|
|
||||||
non_working_days_royal_mail = BankHolidays(
|
|
||||||
use_cached_holidays=True,
|
|
||||||
weekend=(6,), # Only Sunday (day 6 of the week) is a non-working day
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def set_gmt_hour(day, hour):
|
def set_gmt_hour(day, hour):
|
||||||
return (
|
return (
|
||||||
day.astimezone(pytz.timezone("Europe/London"))
|
day.astimezone(pytz.timezone("Europe/London"))
|
||||||
@@ -36,28 +25,27 @@ def set_gmt_hour(day, hour):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_next_work_day(date, non_working_days):
|
def get_next_work_day(date, non_working_days=None):
|
||||||
next_day = date + timedelta(days=1)
|
next_day = date + timedelta(days=1)
|
||||||
if non_working_days.is_work_day(
|
if non_working_days and non_working_days.is_work_day(
|
||||||
date=next_day.date(),
|
date=next_day.date(),
|
||||||
division=BankHolidays.ENGLAND_AND_WALES,
|
|
||||||
):
|
):
|
||||||
return next_day
|
return next_day
|
||||||
return get_next_work_day(next_day, non_working_days)
|
return get_next_work_day(next_day)
|
||||||
|
|
||||||
|
|
||||||
def get_next_dvla_working_day(date):
|
def get_next_dvla_working_day(date):
|
||||||
"""
|
"""
|
||||||
Printing takes place monday to friday, excluding bank holidays
|
Printing takes place monday to friday, excluding bank holidays
|
||||||
"""
|
"""
|
||||||
return get_next_work_day(date, non_working_days=non_working_days_dvla)
|
return get_next_work_day(date)
|
||||||
|
|
||||||
|
|
||||||
def get_next_royal_mail_working_day(date):
|
def get_next_royal_mail_working_day(date):
|
||||||
"""
|
"""
|
||||||
Royal mail deliver letters on monday to saturday
|
Royal mail deliver letters on monday to saturday
|
||||||
"""
|
"""
|
||||||
return get_next_work_day(date, non_working_days=non_working_days_royal_mail)
|
return get_next_work_day(date)
|
||||||
|
|
||||||
|
|
||||||
def get_delivery_day(date, *, days_to_deliver):
|
def get_delivery_day(date, *, days_to_deliver):
|
||||||
|
|||||||
16
poetry.lock
generated
16
poetry.lock
generated
@@ -966,20 +966,6 @@ files = [
|
|||||||
{file = "geojson-3.1.0.tar.gz", hash = "sha256:58a7fa40727ea058efc28b0e9ff0099eadf6d0965e04690830208d3ef571adac"},
|
{file = "geojson-3.1.0.tar.gz", hash = "sha256:58a7fa40727ea058efc28b0e9ff0099eadf6d0965e04690830208d3ef571adac"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "govuk-bank-holidays"
|
|
||||||
version = "0.14"
|
|
||||||
description = "Tool to load UK bank holidays from GOV.UK"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
files = [
|
|
||||||
{file = "govuk-bank-holidays-0.14.tar.gz", hash = "sha256:ce85102423b72908957d25981f616494729686515d5d66c09a1d35a354ce20a6"},
|
|
||||||
{file = "govuk_bank_holidays-0.14-py3-none-any.whl", hash = "sha256:da485c4a40c6c874c925916e492e3f20b807cffba7eed5f07fb69327aef6b10b"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
requests = "*"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.0.3"
|
version = "3.0.3"
|
||||||
@@ -3106,4 +3092,4 @@ files = [
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.12.2"
|
python-versions = "^3.12.2"
|
||||||
content-hash = "6420327e4cabd4b5e6b7903607f5c435c53f98b10b3da42da988a18da5fd1717"
|
content-hash = "b271104f669ce0a8e78fb09299b61cf0502cc81a18213dda00f77c759b6e0209"
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ flask-basicauth = "~=0.2"
|
|||||||
flask-login = "^0.6"
|
flask-login = "^0.6"
|
||||||
flask-talisman = "*"
|
flask-talisman = "*"
|
||||||
flask-wtf = "^1.2"
|
flask-wtf = "^1.2"
|
||||||
govuk-bank-holidays = "^0.14"
|
|
||||||
gunicorn = {version = "==22.0.0", extras = ["eventlet"]}
|
gunicorn = {version = "==22.0.0", extras = ["eventlet"]}
|
||||||
humanize = "~=4.10"
|
humanize = "~=4.10"
|
||||||
itsdangerous = "~=2.2"
|
itsdangerous = "~=2.2"
|
||||||
|
|||||||
@@ -1362,7 +1362,7 @@ def test_menu_all_services_for_platform_admin_user(
|
|||||||
page = str(page)
|
page = str(page)
|
||||||
assert url_for("main.choose_template", service_id=service_one["id"]) in page
|
assert url_for("main.choose_template", service_id=service_one["id"]) in page
|
||||||
assert url_for("main.service_settings", service_id=service_one["id"]) in page
|
assert url_for("main.service_settings", service_id=service_one["id"]) in page
|
||||||
assert url_for('main.api_keys', service_id=service_one['id']) not in page
|
assert url_for("main.api_keys", service_id=service_one["id"]) not in page
|
||||||
|
|
||||||
|
|
||||||
def test_route_for_service_permissions(
|
def test_route_for_service_permissions(
|
||||||
|
|||||||
@@ -353,10 +353,7 @@ def test_should_show_scheduled_job(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert page.select("main p a")[0]["href"] == url_for(
|
assert page.select("main p a")[0]["href"] == url_for(
|
||||||
"main.view_template_version",
|
"main.message_status",
|
||||||
service_id=SERVICE_ONE_ID,
|
|
||||||
template_id="5d729fbd-239c-44ab-b498-75a985f3198f",
|
|
||||||
version=1,
|
|
||||||
)
|
)
|
||||||
assert page.select_one("main button[type=submit]").text.strip() == "Cancel sending"
|
assert page.select_one("main button[type=submit]").text.strip() == "Cancel sending"
|
||||||
|
|
||||||
@@ -421,7 +418,7 @@ def test_should_show_updates_for_one_job_as_json(
|
|||||||
assert "failed" in content["counts"]
|
assert "failed" in content["counts"]
|
||||||
assert "Recipient" in content["notifications"]
|
assert "Recipient" in content["notifications"]
|
||||||
assert "2021234567" in content["notifications"]
|
assert "2021234567" in content["notifications"]
|
||||||
assert "Status" in content["notifications"]
|
assert "Message status" in content["notifications"]
|
||||||
assert "Delivered" in content["notifications"]
|
assert "Delivered" in content["notifications"]
|
||||||
assert "01-01-2016 at 12:00 AM" in content["notifications"]
|
assert "01-01-2016 at 12:00 AM" in content["notifications"]
|
||||||
|
|
||||||
@@ -462,7 +459,7 @@ def test_should_show_updates_for_scheduled_job_as_json(
|
|||||||
assert "failed" in content["counts"]
|
assert "failed" in content["counts"]
|
||||||
assert "Recipient" in content["notifications"]
|
assert "Recipient" in content["notifications"]
|
||||||
assert "2021234567" in content["notifications"]
|
assert "2021234567" in content["notifications"]
|
||||||
assert "Status" in content["notifications"]
|
assert "Message status" in content["notifications"]
|
||||||
assert "Delivered" in content["notifications"]
|
assert "Delivered" in content["notifications"]
|
||||||
assert "01-01-2016 at 12:00 AM" in content["notifications"]
|
assert "01-01-2016 at 12:00 AM" in content["notifications"]
|
||||||
|
|
||||||
@@ -496,5 +493,6 @@ def test_should_show_message_note(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert normalize_spaces(page.select_one("main p.notification-status").text) == (
|
assert normalize_spaces(page.select_one("main p.notification-status").text) == (
|
||||||
"Messages will remain in pending state until carrier status is received, typically 5 minutes."
|
'Messages are sent immediately to the cell phone carrier, but will remain in "pending" status until we hear '
|
||||||
|
"back from the carrier they have received it and attempted deliver. More information on delivery status."
|
||||||
)
|
)
|
||||||
|
|||||||
183
tests/app/main/views/test_jobs_activity.py
Normal file
183
tests/app/main/views/test_jobs_activity.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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}"
|
||||||
@@ -25,6 +25,7 @@ EXCLUDED_ENDPOINTS = tuple(
|
|||||||
"add_organization",
|
"add_organization",
|
||||||
"add_service",
|
"add_service",
|
||||||
"add_service_template",
|
"add_service_template",
|
||||||
|
"all_jobs_activity",
|
||||||
"api_callbacks",
|
"api_callbacks",
|
||||||
"api_documentation",
|
"api_documentation",
|
||||||
"api_integration",
|
"api_integration",
|
||||||
@@ -400,6 +401,7 @@ def test_navigation_urls(
|
|||||||
assert [a["href"] for a in page.select(".nav a")] == [
|
assert [a["href"] for a in page.select(".nav a")] == [
|
||||||
"/services/{}/templates".format(SERVICE_ONE_ID),
|
"/services/{}/templates".format(SERVICE_ONE_ID),
|
||||||
"/services/{}".format(SERVICE_ONE_ID),
|
"/services/{}".format(SERVICE_ONE_ID),
|
||||||
|
"/activity/services/{}".format(SERVICE_ONE_ID),
|
||||||
# "/services/{}/usage".format(SERVICE_ONE_ID),
|
# "/services/{}/usage".format(SERVICE_ONE_ID),
|
||||||
# "/services/{}/users".format(SERVICE_ONE_ID),
|
# "/services/{}/users".format(SERVICE_ONE_ID),
|
||||||
# "/services/{}/service-settings".format(SERVICE_ONE_ID),
|
# "/services/{}/service-settings".format(SERVICE_ONE_ID),
|
||||||
|
|||||||
@@ -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):
|
def test_generate_previous_dict(client_request):
|
||||||
@@ -20,3 +26,30 @@ def test_generate_previous_next_dict_adds_other_url_args(client_request):
|
|||||||
"main.view_notifications", "foo", 2, {"message_type": "blah"}
|
"main.view_notifications", "foo", 2, {"message_type": "blah"}
|
||||||
)
|
)
|
||||||
assert "notifications/blah" in result["url"]
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user